roots: add effect and toolchain forms to the web ui
commit
a734cc6roots: add effect and toolchain forms to the web ui
POST /effects mirrors git ents effect add, including its trigger-must-parse pre-write check; POST /toolchains records a recipe given in Recipe::parse’s text format via toolchain::register.
Assisted-by: Claude:claude-fable-5
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
crates/cli/ents-web/src/router.rs
@@ -46,7 +46,10 @@
"/account",
get(pages::account::show::<O>).post(pages::account::update::<O>),
)
- .route("/effects", get(pages::effects::list::<O>))
+ .route(
+ "/effects",
+ get(pages::effects::list::<O>).post(pages::effects::create::<O>),
+ )
.route("/effects/{name}", get(pages::effects::show::<O>))
.route("/commits", get(pages::commits::list::<O>))
.route("/commit/{oid}", get(pages::commits::show::<O>))
@@ -61,7 +64,10 @@
.route("/redactions", get(pages::redactions::list::<O>))
.route("/redactions/{id}", get(pages::redactions::show::<O>))
.route("/search", get(pages::search::show::<O>))
- .route("/toolchains", get(pages::toolchains::list::<O>))
+ .route(
+ "/toolchains",
+ get(pages::toolchains::list::<O>).post(pages::toolchains::register::<O>),
+ )
.route("/toolchains/{name}", get(pages::toolchains::show::<O>))
.route(
"/comments",
crates/cli/ents-web/tests/router.rs
@@ -2221,6 +2221,95 @@
);
}
+/// `POST /effects` defines an effect as a signed mutation on
+/// `refs/meta/effects/<name>` and redirects to its show page; the list
+/// page then names it -- the web counterpart of `git ents effect add`.
+#[tokio::test]
+async fn effect_form_defines_an_effect() {
+ let dir = seed_repo(&[("README.md", "# hi\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state.clone());
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/effects").await;
+
+ let form = format!(
+ "name=unit&trigger=rev(refs/heads/main)&run=cargo+nextest+run&toolchains=rust&csrf={csrf}"
+ );
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/effects")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(form))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ response.status().is_redirection(),
+ "effect write did not succeed: {:?}",
+ response.status()
+ );
+
+ let list = get_body(&router, "/effects").await;
+ assert!(list.contains("unit"), "the new effect lists: {list}");
+ let show = get_body(&router, "/effects/unit").await;
+ assert!(
+ show.contains("rev(refs/heads/main)") && show.contains("parses"),
+ "the show page renders the trigger and its parse check: {show}"
+ );
+}
+
+/// `POST /toolchains` records a toolchain from a recipe given as text
+/// (`ents_kiln::toolchain::register`) and redirects to its show page --
+/// the recipe-flow counterpart of `git ents toolchain import`.
+#[tokio::test]
+async fn toolchain_form_registers_a_recipe() {
+ let dir = seed_repo(&[("README.md", "# hi\n")]);
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ dir.path().to_owned(),
+ );
+ let router = ents_web::router(state.clone());
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/toolchains").await;
+
+ let form =
+ format!("name=empty&recipe=embedded+4b825dc642cb6eb9a060e54bf8d69288fbee4904&csrf={csrf}");
+ let response = router
+ .clone()
+ .oneshot(
+ Request::post("/toolchains")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(form))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ response.status().is_redirection(),
+ "toolchain write did not succeed: {:?}",
+ response.status()
+ );
+
+ let list = get_body(&router, "/toolchains").await;
+ assert!(list.contains("empty"), "the new toolchain lists: {list}");
+ let show = get_body(&router, "/toolchains/empty").await;
+ assert!(
+ show.contains("Embedded"),
+ "the show page renders the recorded recipe: {show}"
+ );
+}
+
/// `GET /toolchains` surfaces a toolchain written by an older schema
/// (piece 1's bug: this repository's own
/// `refs/meta/toolchains/{rust,sccache,zig}` still carry it) through the
crates/cli/ents-web/src/pages/effects.rs
@@ -8,13 +8,17 @@
use std::sync::Arc;
+use axum::Form;
use axum::extract::{Path, State};
-use ents_model::Effect;
+use axum::response::{IntoResponse, Redirect};
+use ents_model::{Effect, namespace};
use ents_query::Query;
use gix_object::{Find, Write};
use maud::html;
+use serde::Deserialize;
use crate::error::{Error, Result};
+use crate::session::Session;
use crate::state::AppState;
/// `GET /effects`.
@@ -22,7 +26,10 @@
/// # Errors
///
/// Propagates a ref-store or object read failure.
-pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
+pub async fn list<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+) -> Result<maud::Markup>
where
O: Find + Write + Send + 'static,
{
@@ -37,7 +44,7 @@
let table = if rows.is_empty() {
super::blankslate(
"No effects yet",
- html! { "Registered effects and their trigger queries appear here." },
+ html! { "Define one with the form below." },
)
} else {
crate::render::list_table(&rows, "name", |id| format!("/effects/{id}"))
@@ -50,10 +57,103 @@
html! {
(crate::render::unreadable_disclosure(&failures))
(table)
+ h2 { "Define an Effect" }
+ (add_form(&session))
},
))
}
+/// The define-effect form (`POST /effects`) -- `git ents effect add`'s
+/// own arguments as form fields.
+fn add_form(session: &Session) -> maud::Markup {
+ html! {
+ form method="post" action="/effects" {
+ (super::csrf_input(session))
+ label { "name" input type="text" name="name"; }
+ label {
+ "trigger"
+ input type="text" name="trigger" placeholder="query.grammar trigger";
+ }
+ label { "run" input type="text" name="run" placeholder="command to run"; }
+ label {
+ "toolchains"
+ input type="text" name="toolchains" placeholder="rust, node";
+ }
+ button type="submit" { "Define Effect" }
+ }
+ }
+}
+
+/// The form fields `POST /effects` accepts.
+#[derive(Debug, Deserialize)]
+pub struct AddForm {
+ /// Name to record the effect under (`refs/meta/effects/<name>`).
+ name: String,
+ /// The query the effect triggers on (`query.grammar`).
+ trigger: String,
+ /// The command the effect runs.
+ run: String,
+ /// Comma- or whitespace-separated toolchain names.
+ #[serde(default)]
+ toolchains: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /effects`: define (or replace) an effect as a signed mutation on
+/// `refs/meta/effects/<name>` -- the web counterpart of
+/// `git ents effect add`, sharing its pre-write rule that the trigger must
+/// parse (`ents_receive::reconcile`'s tolerance rule would otherwise
+/// silently skip a malformed one on every future scan).
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match;
+/// [`Error::InvalidArgument`] on an unparsable trigger or an empty name;
+/// otherwise propagates the `receive` proposal's own failures.
+// @relation(roots.web-signing, roots.web-session, scope=function)
+pub async fn create<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Form(form): Form<AddForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let _: Query = form.trigger.parse().map_err(|_source| {
+ Error::InvalidArgument(format!("unparsable trigger: {}", form.trigger))
+ })?;
+ let name = form.name.trim();
+ let ref_name = namespace::effect_ref(name)
+ .map_err(|_invalid| Error::InvalidArgument(format!("invalid effect name: {name}")))?;
+ let effect = Effect {
+ name: name.to_owned(),
+ trigger: form.trigger,
+ toolchains: form
+ .toolchains
+ .split([',', ' '])
+ .map(str::trim)
+ .filter(|part| !part.is_empty())
+ .map(str::to_owned)
+ .collect(),
+ run: form.run,
+ };
+ let identity = state.identity.as_ref();
+ let outcome = ents_receive::propose_entity(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ ref_name,
+ &effect,
+ &crate::receive_identity!(identity),
+ &format!("Define effect {name}"),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/effects/{name}")))
+}
+
/// `GET /effects/{name}`.
///
/// # Errors
crates/cli/ents-web/src/pages/toolchains.rs
@@ -3,19 +3,26 @@
//! [`ents_kiln::Recipe`] needs domain-specific rendering (`Embedded` vs
//! `Downloaded`, each with its own provenance shape) that would otherwise
//! push a `match Recipe::Embedded { .. } => ...` into the generic
-//! reflection walk [`crate::render`] exists to keep type-agnostic. Import
-//! is not wired here: it stays a `git ents toolchain import` operation,
-//! since it takes a local directory path, not form data a browser can
-//! supply.
+//! reflection walk [`crate::render`] exists to keep type-agnostic.
+//! Directory import stays a `git ents toolchain import` operation (it
+//! takes a local directory path, not form data a browser can supply);
+//! what `POST /toolchains` wires instead is [`toolchain::register`],
+//! taking a recipe as text ([`ents_kiln::Recipe::parse`]'s own format)
+//! -- an `embedded <tree-oid>` line or a `downloaded` component list is
+//! exactly form data.
use std::sync::Arc;
+use axum::Form;
use axum::extract::{Path, State};
+use axum::response::{IntoResponse, Redirect};
use ents_kiln::toolchain;
use gix_object::{Find, Write};
use maud::html;
+use serde::Deserialize;
use crate::error::{Error, Result};
+use crate::session::Session;
use crate::state::AppState;
/// `GET /toolchains`.
@@ -33,7 +40,10 @@
/// # Errors
///
/// Propagates a ref-store read failure.
-pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<maud::Markup>
+pub async fn list<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+) -> Result<maud::Markup>
where
O: Find + Write + Send + 'static,
{
@@ -70,10 +80,85 @@
html! {
(crate::render::unreadable_disclosure(&failures))
(listing)
+ h2 { "Import a Toolchain" }
+ (import_form(&session))
},
))
}
+/// The import-toolchain form (`POST /toolchains`): a name and a recipe in
+/// [`ents_kiln::Recipe::parse`]'s own text format.
+fn import_form(session: &Session) -> maud::Markup {
+ html! {
+ form method="post" action="/toolchains" {
+ (super::csrf_input(session))
+ label { "name" input type="text" name="name"; }
+ label {
+ "recipe"
+ textarea name="recipe"
+ placeholder="embedded <tree-oid>\nor:\ndownloaded\n<url> <sha256> <strip> [dest]" {}
+ }
+ button type="submit" { "Import Toolchain" }
+ }
+ }
+}
+
+/// The form fields `POST /toolchains` accepts.
+#[derive(Debug, Deserialize)]
+pub struct ImportForm {
+ /// Name to record the toolchain under (`refs/meta/toolchains/<name>`).
+ name: String,
+ /// The recipe text ([`ents_kiln::Recipe::parse`]).
+ recipe: String,
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /toolchains`: record a toolchain from a recipe given as text
+/// ([`toolchain::register`]) as a signed mutation on
+/// `refs/meta/toolchains/<name>` -- the recipe-flow counterpart of
+/// `git ents toolchain import`, whose directory walk cannot arrive as
+/// form data (this module's own top-level doc).
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match;
+/// [`Error::InvalidArgument`] on a recipe that does not parse or a name
+/// that cannot form a ref; otherwise propagates the `receive` proposal's
+/// own failures.
+// @relation(roots.web-signing, roots.web-session, scope=function)
+pub async fn register<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Form(form): Form<ImportForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let recipe = ents_kiln::Recipe::parse(&form.recipe)
+ .map_err(|source| Error::InvalidArgument(format!("invalid recipe: {source}")))?;
+ let name = form.name.trim();
+ let identity = state.identity.as_ref();
+ let outcome = toolchain::register(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ name,
+ &recipe,
+ &crate::receive_identity!(identity),
+ state.mode,
+ )
+ .map_err(|source| match source {
+ ents_effect::Error::InvalidToolchainName(bad) => {
+ Error::InvalidArgument(format!("invalid toolchain name: {bad}"))
+ }
+ other => Error::from(other),
+ })?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/toolchains/{name}")))
+}
+
/// `GET /toolchains/{name}`: the toolchain's recorded recipe and import
/// log.
///