Merge branch 'worktree-agent-sessions': review + agent-config batch
commit
680d07cMerge branch 'worktree-agent-sessions': review + agent-config batch
Items 1-4a atop the agent-sessions feature: auto/manual review-policy descriptions, forge-wide agent provider + default model in config, review withdraw state, and the issue-shaped review detail page.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
crates/cli/ents-web/src/router.rs
@@ -74,7 +74,15 @@
"/reviews/{target}/{member}/comment",
post(pages::commits::review_comment::<O>),
)
+ .route(
+ "/reviews/{target}/{member}/withdraw",
+ post(pages::reviews::withdraw::<O>),
+ )
.route("/reviews", get(pages::reviews::list::<O>))
+ .route(
+ "/reviews/{target}/{member}",
+ get(pages::reviews::show::<O>),
+ )
.route("/files", get(pages::files::root::<O>))
.route("/files/{*path}", get(pages::files::show::<O>))
.route("/meta", get(pages::meta::show::<O>))
crates/cli/ents-web/tests/router.rs
@@ -4191,3 +4191,267 @@
let detail = get_body(&router, &format!("/agents/{id}")).await;
assert!(detail.contains("Confirm plan"));
}
+
+/// `GET /reviews` (`crate::pages::reviews`): a withdrawn review stays in
+/// `refs/meta/reviews/*`'s own history (`model.review`, append-only) but
+/// must not render in this aggregate listing, while an ordinary active
+/// review of the same target still does -- the filter this page's own
+/// `list` applies on `ents_forge::review::ReviewState::Withdrawn`.
+#[tokio::test]
+async fn reviews_list_hides_a_withdrawn_review_but_keeps_an_active_one() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let target = "0123456789abcdef0123456789abcdef01234567";
+ let reviewed = gix_hash::ObjectId::from_hex(target.as_bytes()).expect("valid hex");
+
+ let active_ref =
+ ents_model::namespace::review_ref(target, &MemberId::new("alice")).expect("valid");
+ write_meta_entity(
+ &refs,
+ &objects,
+ active_ref,
+ &ents_forge::review::Review::new(
+ reviewed,
+ ents_forge::review::Verdict::Approve,
+ "looks good",
+ ),
+ None,
+ 100,
+ );
+
+ let withdrawn_ref =
+ ents_model::namespace::review_ref(target, &MemberId::new("bob")).expect("valid");
+ write_meta_entity(
+ &refs,
+ &objects,
+ withdrawn_ref,
+ &ents_forge::review::Review::new(
+ reviewed,
+ ents_forge::review::Verdict::RequestChanges,
+ "please fix this",
+ )
+ .withdrawn(),
+ None,
+ 100,
+ );
+
+ let state = build_state_with(
+ FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(state);
+
+ let body = get_body(&router, "/reviews").await;
+ assert!(body.contains("alice"), "active review still lists: {body}");
+ assert!(!body.contains("bob"), "withdrawn review must not render: {body}");
+}
+
+/// `GET /reviews/{target}/{member}` (`crate::pages::reviews::show`): a
+/// review started through the commit page's own form (`POST
+/// /commit/{oid}/review`) gets its own detail page -- the verdict chip, an
+/// `active` state badge, the rendered body, and (since the viewing
+/// identity is the review's own author) a withdraw control -- and the
+/// Reviews split's own sidebar (`reviews_sidebar`) links to it, newest
+/// first, beside `GET /reviews`'s own aggregate listing.
+#[tokio::test]
+// @relation(model.review, lens.parity, scope=function, role=Verifies)
+async fn review_detail_page_renders_for_its_own_author_with_a_withdraw_control() {
+ let dir = seed_repo(&[("src/main.rs", "fn main() {}\n")]);
+ let oid = head_oid(dir.path());
+ let state = build_state_at(
+ FixtureIdentity {
+ name: "reviewer",
+ 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, &format!("/commit/{oid}")).await;
+ let started = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/commit/{oid}/review"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie.clone())
+ .body(Body::from(format!(
+ "verdict=approve&body=looks+good+to+me&csrf={csrf}"
+ )))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(started.status().is_redirection(), "{:?}", started.status());
+
+ let commit_page = get_body(&router, &format!("/commit/{oid}")).await;
+ let review_id = commit_page
+ .split_once("/reviews/")
+ .and_then(|(_, rest)| rest.split_once("/comment"))
+ .map(|(id, _)| id)
+ .expect("a review comment form links in")
+ .to_owned();
+
+ // The sidebar/list both link to the review's own detail page.
+ let list = get_body(&router, "/reviews").await;
+ assert!(
+ list.contains(&format!("href=\"/reviews/{review_id}\"")),
+ "the sidebar links the active review's own detail page: {list}"
+ );
+
+ let detail = get_body(&router, &format!("/reviews/{review_id}")).await;
+ assert!(
+ detail.contains("class=\"verdict verdict-approve\""),
+ "the verdict chip renders: {detail}"
+ );
+ assert!(
+ detail.contains("looks good to me"),
+ "the review body renders as its own doc-body: {detail}"
+ );
+ assert!(
+ detail.contains(">active<"),
+ "the state badge names the review active: {detail}"
+ );
+ assert!(
+ detail.contains(&format!("action=\"/reviews/{review_id}/withdraw\"")),
+ "the review's own author sees a withdraw control: {detail}"
+ );
+
+ // Withdrawing redirects back to the same detail page, which still
+ // renders -- but now with the withdrawn indicator instead of the
+ // button -- while the sidebar and the aggregate list both drop it.
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, &detail_path(&review_id)).await;
+ let withdrawn = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/reviews/{review_id}/withdraw"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from(format!("csrf={csrf}")))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ withdrawn.status().is_redirection(),
+ "{:?}",
+ withdrawn.status()
+ );
+
+ let detail_after = get_body(&router, &format!("/reviews/{review_id}")).await;
+ assert!(
+ detail_after.contains("withdrawn"),
+ "the withdrawn review's own page states so plainly: {detail_after}"
+ );
+ assert!(
+ !detail_after.contains(&format!("action=\"/reviews/{review_id}/withdraw\"")),
+ "a withdrawn review no longer offers the withdraw control: {detail_after}"
+ );
+
+ let list_after = get_body(&router, "/reviews").await;
+ assert!(
+ !list_after.contains(&format!("href=\"/reviews/{review_id}\"")),
+ "the withdrawn review drops out of the aggregate list: {list_after}"
+ );
+}
+
+/// `format!("/reviews/{{review_id}}")`, spelled once so
+/// [`review_detail_page_renders_for_its_own_author_with_a_withdraw_control`]
+/// can reuse the same detail path both to fetch a fresh CSRF token and to
+/// `POST` the withdraw form against it.
+fn detail_path(review_id: &str) -> String {
+ format!("/reviews/{review_id}")
+}
+
+/// `crate::pages::reviews::show`'s withdraw control only ever renders for
+/// the review's *own* author -- a second identity viewing the same
+/// still-active review sees the metadata card and the thread, but no
+/// withdraw form at all, exactly as `commits::reviews_section` never lets
+/// one member's page render a button that would fail
+/// `ents_forge::review::withdraw`'s own author check.
+#[tokio::test]
+async fn review_detail_page_hides_the_withdraw_control_from_a_non_author() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let target = "0123456789abcdef0123456789abcdef01234567";
+ let reviewed = gix_hash::ObjectId::from_hex(target.as_bytes()).expect("valid hex");
+
+ let review_ref =
+ ents_model::namespace::review_ref(target, &MemberId::new("carol")).expect("valid");
+ write_meta_entity(
+ &refs,
+ &objects,
+ review_ref,
+ &ents_forge::review::Review::new(
+ reviewed,
+ ents_forge::review::Verdict::Approve,
+ "review body from carol",
+ ),
+ None,
+ 100,
+ );
+
+ let state = build_state_with(
+ FixtureIdentity {
+ name: "onlooker",
+ key: Keypair::from_seed(2),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(state);
+
+ let detail = get_body(&router, &format!("/reviews/{target}/carol")).await;
+ assert!(
+ detail.contains("review body from carol"),
+ "the review still renders for a non-author viewer: {detail}"
+ );
+ assert!(
+ !detail.contains("/withdraw\""),
+ "a non-author viewer sees no withdraw control: {detail}"
+ );
+}
+
+/// `POST /reviews/{target}/{member}/withdraw` is a state-changing route
+/// gated the same way every other mutation in this crate is
+/// (`roots.web-session`): no CSRF field at all is rejected outright,
+/// regardless of whether the named review even exists.
+#[tokio::test]
+async fn review_withdraw_is_rejected_without_a_valid_csrf_token() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(Arc::clone(&state));
+
+ let no_csrf = router
+ .clone()
+ .oneshot(
+ Request::post("/reviews/0123456789abcdef0123456789abcdef01234567/carol/withdraw")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert!(
+ !no_csrf.status().is_success() && !no_csrf.status().is_redirection(),
+ "a POST with no csrf field must not withdraw a review"
+ );
+
+ let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/reviews").await;
+ let wrong = router
+ .oneshot(
+ Request::post("/reviews/0123456789abcdef0123456789abcdef01234567/carol/withdraw")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .header(header::COOKIE, cookie)
+ .body(Body::from("csrf=not-the-token"))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(wrong.status(), StatusCode::BAD_REQUEST);
+}
crates/cli/git-ents/src/cli.rs
@@ -102,6 +102,17 @@
#[facet(args::subcommand)]
action: AccountAction,
},
+ /// Manage this repository's forge-wide configuration at
+ /// `refs/meta/config`: today, non-secret agent-runtime defaults (a
+ /// provider name, a default model id) alongside the gate's own
+ /// `epoch`/`workers` fields. The API token is never here; it lives in
+ /// the deployment-time credential seam
+ /// (`GIT_ENTS_CREDENTIALS_FILE`), never in a signed, replicated tree.
+ Config {
+ /// The config action to run.
+ #[facet(args::subcommand)]
+ action: ConfigAction,
+ },
/// Manage the configured effects at `refs/meta/effects/<name>` and run
/// them locally.
Effect {
@@ -317,6 +328,32 @@
},
}
+/// `git ents config` actions.
+#[derive(Facet)]
+#[repr(u8)]
+pub enum ConfigAction {
+ /// Show this repository's current forge-wide configuration.
+ Show,
+ /// Narrow the agent-runtime defaults. Each flag is independent: omit
+ /// one to leave whatever it currently holds untouched rather than
+ /// clearing it.
+ Set {
+ /// The agent runtime's provider name (e.g. `anthropic`);
+ /// non-secret -- the API token itself is never a flag here, only
+ /// `git-ents`'s own `GIT_ENTS_CREDENTIALS_FILE` seam.
+ #[facet(args::named)]
+ agent_provider: Option<String>,
+ /// The agent runtime's default model id (e.g.
+ /// `claude-sonnet-5`), used whenever a session start omits its
+ /// own `--model`.
+ #[facet(args::named)]
+ agent_default_model: Option<String>,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
+}
+
/// `git ents effect` actions.
#[derive(Facet)]
#[repr(u8)]
crates/cli/git-ents/src/exe.rs
@@ -8,8 +8,8 @@
)]
use crate::cli::{
- AccountAction, AgentAction, Cli, CommentAction, EffectAction, HookAction, InboxAction,
- IssueAction, MembersAction, RedactAction, ReviewAction, ToolchainAction, Top,
+ AccountAction, AgentAction, Cli, CommentAction, ConfigAction, EffectAction, HookAction,
+ InboxAction, IssueAction, MembersAction, RedactAction, ReviewAction, ToolchainAction, Top,
};
use crate::commands;
use crate::error::Result;
@@ -69,6 +69,7 @@
}
Top::Members { action } => run_members(action, out),
Top::Account { action } => run_account(action, out),
+ Top::Config { action } => run_config(action, out),
Top::Effect { action } => run_effect(action, out),
Top::Toolchain { action } => run_toolchain(action, out),
Top::Comment { action } => run_comment(action, out),
@@ -172,6 +173,34 @@
Ok(())
}
+fn run_config(action: ConfigAction, out: &mut impl std::io::Write) -> Result<()> {
+ let root = LocalRoot::discover(".")?;
+ match action {
+ ConfigAction::Show => {
+ let config = commands::config::show(&root)?;
+ let _ = writeln!(
+ out,
+ "agent_provider: {}",
+ config.agent_provider.as_deref().unwrap_or("(unset)")
+ );
+ let _ = writeln!(
+ out,
+ "agent_default_model: {}",
+ config.agent_default_model.as_deref().unwrap_or("(unset)")
+ );
+ }
+ ConfigAction::Set {
+ agent_provider,
+ agent_default_model,
+ key,
+ } => {
+ commands::config::set(&root, agent_provider, agent_default_model, key)?;
+ let _ = writeln!(out, "config updated");
+ }
+ }
+ Ok(())
+}
+
fn run_effect(action: EffectAction, out: &mut impl std::io::Write) -> Result<()> {
let root = LocalRoot::discover(".")?;
match action {
@@ -471,6 +500,10 @@
let target = commands::review::new(&root, new, key)?;
let _ = writeln!(out, "reviewed {}", ents_forge::abbreviate_id(&target));
}
+ ReviewAction::Withdraw { target, key } => {
+ let target = commands::review::withdraw(&root, target, key)?;
+ let _ = writeln!(out, "withdrew {}", ents_forge::abbreviate_id(&target));
+ }
ReviewAction::List { target } => {
for ((review_target, member), review) in commands::review::list(&root, target)? {
let _ = writeln!(
crates/cli/git-ents/tests/review.rs
@@ -20,7 +20,7 @@
use ents_forge::comment::NewComment;
use ents_forge::review::NewReview;
-use ents_forge::review::Verdict;
+use ents_forge::review::{ReviewState, Verdict};
use git_ents::commands::{comment, members, review};
use git_ents::root::LocalRoot;
use gix_object::{CommitRef, Find, Write as _};
@@ -244,3 +244,92 @@
"re-review advances in place, not a second row"
);
}
+
+/// `model.review`: `git ents review withdraw` writes a new `Withdrawn`
+/// entity onto the reviewer's own existing ref, preserving the verdict and
+/// body untouched — append-only, so the prior `Active` commit stays in the
+/// ref's history rather than being replaced by a different shape.
+// @relation(model.review, model.review-pin, roots.local, scope=function, role=Verifies)
+#[test]
+fn withdraw_preserves_verdict_and_body_and_flips_only_state() {
+ let fixture = common::Fixture::new(1);
+ let reviewed = commit_file(fixture.path(), "file.txt", "line one\n");
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "reviewer", None, Some(fixture.key_path.clone())).expect("enrolls");
+
+ let new = NewReview {
+ target: "HEAD".to_owned(),
+ verdict: Verdict::RequestChanges,
+ body: "please fix this".to_owned(),
+ };
+ let target = review::new(&root, new, Some(fixture.key_path.clone())).expect("reviews");
+
+ let withdrawn_target =
+ review::withdraw(&root, reviewed.to_string(), Some(fixture.key_path.clone()))
+ .expect("withdraws");
+ assert_eq!(withdrawn_target, target, "withdraw advances the same ref");
+
+ let (review, _thread) = review::show(&root, &target, "reviewer").expect("shows");
+ assert_eq!(review.state, ReviewState::Withdrawn);
+ assert_eq!(review.verdict, Verdict::RequestChanges);
+ assert_eq!(review.body, "please fix this");
+ assert_eq!(review.target(), reviewed);
+
+ // The chain is the audit trail: the review still enumerates from
+ // `list` (only the web listings hide a withdrawn row), and there is
+ // still exactly one review row, not a second one.
+ let all = review::list(&root, None).expect("lists");
+ assert_eq!(all.len(), 1);
+ assert_eq!(all[0].1.state, ReviewState::Withdrawn);
+}
+
+/// `model.review`: withdrawing when this member has never reviewed
+/// `target` (or an ancestor of it) is a clear refusal — there is nothing to
+/// withdraw.
+// @relation(model.review, roots.local, scope=function, role=Verifies)
+#[test]
+fn withdraw_refuses_when_no_review_exists() {
+ let fixture = common::Fixture::new(1);
+ commit_file(fixture.path(), "file.txt", "line one\n");
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "reviewer", None, Some(fixture.key_path.clone())).expect("enrolls");
+
+ let err = review::withdraw(&root, "HEAD".to_owned(), Some(fixture.key_path.clone()))
+ .expect_err("nothing to withdraw");
+ assert!(
+ matches!(err, git_ents::error::Error::Forge(_)),
+ "expected Error::Forge, got {err:?}"
+ );
+ assert!(
+ err.to_string().contains("not found"),
+ "expected a NotFound refusal, got {err}"
+ );
+}
+
+/// `model.review`: withdrawing an already-withdrawn review is a
+/// no-op-ish re-write, not an error — the same ref simply advances again
+/// with the same `Withdrawn` state.
+// @relation(model.review, roots.local, scope=function, role=Verifies)
+#[test]
+fn withdrawing_an_already_withdrawn_review_is_not_an_error() {
+ let fixture = common::Fixture::new(1);
+ commit_file(fixture.path(), "file.txt", "line one\n");
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+ members::add(&root, "reviewer", None, Some(fixture.key_path.clone())).expect("enrolls");
+
+ let new = NewReview {
+ target: "HEAD".to_owned(),
+ verdict: Verdict::Approve,
+ body: "looks good".to_owned(),
+ };
+ let target = review::new(&root, new, Some(fixture.key_path.clone())).expect("reviews");
+
+ review::withdraw(&root, "HEAD".to_owned(), Some(fixture.key_path.clone()))
+ .expect("withdraws");
+ review::withdraw(&root, "HEAD".to_owned(), Some(fixture.key_path.clone()))
+ .expect("withdrawing again is not an error");
+
+ let (review, _thread) = review::show(&root, &target, "reviewer").expect("shows");
+ assert_eq!(review.state, ReviewState::Withdrawn);
+ assert_eq!(review.verdict, Verdict::Approve);
+}
crates/kernel/ents-gate/src/config.rs
@@ -23,11 +23,14 @@
///
/// `model.sdoc` defines no Config entity yet, so this struct is the
/// first (and currently only) definition of the config tree's shape; it
-/// lives here rather than in `ents-model` because these are the only
-/// fields any crate reads today. When configuration grows non-gate fields
-/// (description, role rules, ...), the entity moves to `ents-model` and
-/// that change is a storage migration like any other struct change
-/// (`meta-ref.migration`).
+/// lives here rather than in `ents-model` because these were, until now,
+/// the only fields any crate reads. `agent_provider`/`agent_default_model`
+/// (below) are the first fields this crate itself has no use for — they
+/// exist purely for other crates (`ents-web`'s new-session form) to read
+/// — but they stay here rather than moving to `ents-model` today: that
+/// move is still deferred until configuration grows enough non-gate
+/// fields to justify the storage migration (`meta-ref.migration`) in one
+/// pass, rather than one field at a time.
///
/// `epoch` is `None` on a config written before verification was turned
/// on. Once it is `Some`, the gate applies the tip invariant to every
@@ -61,6 +64,21 @@
/// narrowing lands. Empty by default: no worker is designated until a
/// signed config write adds one.
pub workers: Vec<MemberId>,
+ /// The agent runtime's provider name (e.g. `"anthropic"`), forge-wide;
+ /// `None` until a signed config write sets one. Non-secret by
+ /// construction — the credential that authenticates to the provider
+ /// never lives here or anywhere in `refs/meta/*`, only in the
+ /// deployment-time seam (`git-ents`'s `credentials.rs`,
+ /// `GIT_ENTS_CREDENTIALS_FILE`). The agent runtime that would consume
+ /// this is not built yet; storing and reading it is this field's
+ /// entire job for now.
+ pub agent_provider: Option<String>,
+ /// The agent runtime's default model id (e.g. `"claude-sonnet-5"`),
+ /// forge-wide; `None` until a signed config write sets one. Read as a
+ /// fallback ahead of any hardcoded default a caller carries — today,
+ /// `ents-web`'s new-agent-session form falls back to it before its own
+ /// compiled-in constant.
+ pub agent_default_model: Option<String>,
}
/// The config recorded by the tree of the commit at `oid`, or an
@@ -119,3 +137,21 @@
) -> Result<Vec<MemberId>> {
Ok(current_config(refs, objects)?.workers)
}
+
+/// The forge-wide agent provider name currently in force, read from
+/// `refs/meta/config`'s tip; `None` when the config ref does not exist or
+/// names no provider. Public (unlike [`designated_workers`], which only
+/// this crate's gate consults) because the only consumer today, an agent
+/// runtime, does not live in this crate.
+pub fn agent_provider(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Option<String>> {
+ Ok(current_config(refs, objects)?.agent_provider)
+}
+
+/// The forge-wide default agent model id currently in force, read from
+/// `refs/meta/config`'s tip; `None` when the config ref does not exist or
+/// names no default — callers fall back to their own compiled-in default
+/// (`ents-web`'s new-agent-session form, today) rather than treating
+/// `None` as an error.
+pub fn agent_default_model(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Option<String>> {
+ Ok(current_config(refs, objects)?.agent_default_model)
+}
crates/kernel/ents-gate/src/lib.rs
@@ -155,7 +155,7 @@
mod verdict;
mod verify;
-pub use config::Config;
+pub use config::{Config, agent_default_model, agent_provider};
pub use error::{Error, Result};
pub use verdict::{Admission, AdmissionKind, Refusal, Requirement, Verdict};
pub use verify::{Update, verify};
crates/kernel/ents-gate/tests/gate.rs
@@ -856,6 +856,7 @@
&Config {
epoch: Some(200),
workers: vec![MemberId::new(worker_id)],
+ ..Config::default()
},
Some(&f.admin),
seconds,
crates/cli/ents-web/src/assets/ents.css
@@ -572,6 +572,7 @@
}
.picker .opt .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ink-4); }
.picker .opt.active { font-weight: 600; }
+.opt-help { font-size: 12px; line-height: 1.5; margin: 8px 0 0; }
/* Label picker chips (`labelPicker`): pick existing or type-and-create. */
.label-chip {
crates/cli/ents-web/src/pages/agents.rs
@@ -61,6 +61,7 @@
.map(|entry| (entry.refname, entry.error))
.collect();
let default_base = default_base_ref(&state);
+ let default_model = resolved_default_model(&state);
Ok(super::layout_split(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
@@ -79,7 +80,7 @@
}
div.card {
div.card-header { "Start an Agent Session" }
- (new_form(&session, &default_base))
+ (new_form(&session, &default_base, &default_model))
}
}
},
@@ -696,18 +697,19 @@
/// The start-a-session form (`POST /agents`, `docs/agent-sessions-plan.adoc`'s
/// Phase 3, "mobile-critical"): a prompt textarea, a base-branch text input
/// pre-filled with `default_base` ([`default_base_ref`]), a model text
-/// input pre-filled with [`DEFAULT_MODEL`], and a closed two-option review
-/// policy picker defaulting to `manual` (mirrors
-/// `crate::pages::commits::start_review_form`'s identical closed-verdict
-/// picker) -- deliberately no toolchain or retry field: the plan's own
-/// words are "complexity lives in the session doc, not the form."
-fn new_form(session: &Session, default_base: &str) -> Markup {
+/// input pre-filled with `default_model` ([`resolved_default_model`]), and
+/// a closed two-option review policy picker defaulting to `manual`
+/// (mirrors `crate::pages::commits::start_review_form`'s identical
+/// closed-verdict picker) -- deliberately no toolchain or retry field: the
+/// plan's own words are "complexity lives in the session doc, not the
+/// form."
+fn new_form(session: &Session, default_base: &str, default_model: &str) -> Markup {
html! {
form method="post" action="/agents" {
(super::csrf_input(session))
label { "prompt" textarea name="prompt" {} }
label { "base branch" input type="text" name="base_ref" value=(default_base); }
- label { "model" input type="text" name="model" value=(DEFAULT_MODEL); }
+ label { "model" input type="text" name="model" value=(default_model); }
div {
p.muted { "review policy" }
div.picker {
@@ -722,6 +724,12 @@
"auto"
}
}
+ p.opt-help.muted {
+ strong { "Manual" }
+ " — no review opens on its own; you start one yourself when you want it. "
+ strong { "Auto" }
+ " — a review of the result opens automatically once the run finishes."
+ }
}
div.composer-buttons {
a.composer-cancel href="/agents" { "Cancel" }
@@ -731,11 +739,26 @@
}
}
-/// The model id [`new_form`] pre-fills and [`NewForm::model`] defaults to
-/// when a submission omits the field entirely -- the same default id this
-/// codebase's own fixtures and `git-ents::agent_worker` tests already use.
+/// The model id [`resolved_default_model`] falls back to when
+/// `refs/meta/config` names no `agent_default_model` (or does not exist
+/// yet) -- the same default id this codebase's own fixtures and
+/// `git-ents::agent_worker` tests already use.
const DEFAULT_MODEL: &str = "claude-sonnet-5";
+/// The model id [`new_form`] pre-fills and [`NewForm::model`] falls back to
+/// when a submission omits the field entirely: `refs/meta/config`'s
+/// `agent_default_model` (`ents_gate::agent_default_model`) when a signed
+/// config write has set one, else [`DEFAULT_MODEL`] -- a forge-wide
+/// default lets an operator change what every new session starts at
+/// without touching this crate's own compiled-in fallback, which stays as
+/// the floor for a repository that has never configured one.
+fn resolved_default_model<O: Find>(state: &AppState<O>) -> String {
+ ents_gate::agent_default_model(state.refs.as_ref(), &*state.objects())
+ .ok()
+ .flatten()
+ .unwrap_or_else(|| DEFAULT_MODEL.to_owned())
+}
+
/// The base ref [`new_form`] pre-fills: `refs/heads/<branch>` for the
/// served repository's own current `HEAD` branch
/// ([`super::RepoHeader::from_state`]), or plain `HEAD` when that cannot
@@ -776,10 +799,11 @@
/// [`default_base_ref`]).
#[serde(default = "default_base_ref_field")]
base_ref: String,
- /// The model id the run executes against; defaults to
- /// [`DEFAULT_MODEL`].
- #[serde(default = "default_model_field")]
- model: String,
+ /// The model id the run executes against; `None` when a submission
+ /// omits the field entirely, resolved by [`resolved_default_model`]
+ /// (see [`create`]).
+ #[serde(default)]
+ model: Option<String>,
/// The session's initially resolved review policy: `auto` or `manual`;
/// defaults to `manual` (see [`default_review_policy`]).
#[serde(default = "default_review_policy")]
@@ -788,11 +812,6 @@
csrf: String,
}
-/// [`NewForm::model`]'s serde default -- see [`DEFAULT_MODEL`].
-fn default_model_field() -> String {
- DEFAULT_MODEL.to_owned()
-}
-
/// `POST /agents`: start an agent session owned by the current signing
/// identity's resolved member (`ents_forge::agent::new`), signed
/// (`roots.web-signing`) on behalf of the current session
@@ -818,10 +837,11 @@
super::require_csrf(&session, &form.csrf)?;
let member = session_owner(&state);
let identity = state.identity.as_ref();
+ let model = form.model.unwrap_or_else(|| resolved_default_model(&state));
let new = NewAgentSession {
member,
prompt: form.prompt,
- model: form.model,
+ model,
toolchains: Vec::new(),
base_ref: form.base_ref,
review_policy: form
crates/cli/ents-web/src/pages/commits.rs
@@ -485,13 +485,16 @@
commit_id: ObjectId,
oid: &str,
) -> Markup {
- let reviews = ents_forge::review::list(
+ let mut reviews = ents_forge::review::list(
state.refs.as_ref(),
&*state.objects(),
&state.path,
Some(&commit_id.to_string()),
)
.unwrap_or_default();
+ // Withdrawn reviews stay in history (`model.review`, append-only) but
+ // drop out of this section, same as `crate::pages::reviews`'s own list.
+ reviews.retain(|(_, review)| review.state != ents_forge::review::ReviewState::Withdrawn);
let return_to = format!("/commit/{oid}");
html! {
h2 { "Reviews" }
@@ -531,8 +534,11 @@
/// The comment-on-this-review form (`POST /reviews/{target}/{member}/comment`):
/// a contextual comment naming `reviews/<target>/<member>`
/// (`model.comment-context`), so a review's discussion can start from the
-/// web and not only the CLI or lens.
-fn review_comment_form(
+/// web and not only the CLI or lens. Shared with
+/// [`super::reviews::show`]'s own review detail page -- the same composer,
+/// not a second one, whether the review's thread renders on its home
+/// commit's page or on its own.
+pub(crate) fn review_comment_form(
session: &Session,
target: &str,
member: &ents_model::MemberId,
@@ -768,7 +774,7 @@
O: Find + Write + Send + 'static,
{
super::require_csrf(&session, &form.csrf)?;
- let member = reviewer_member_id(&state);
+ let member = super::reviewer_member_id(&state);
let identity = state.identity.as_ref();
let new = ents_forge::review::NewReview {
target: oid.clone(),
@@ -791,37 +797,6 @@
Ok(Redirect::to(&format!("/commit/{oid}")))
}
-/// The acting session's member id -- the composite review key's
-/// `<member>` segment -- resolved the same way
-/// [`super::account::resolve_member_by_key`] does, falling back to a short
-/// hash of the public key when no enrolled member matches: mirrors
-/// `git_ents::commands::serve::build_state`'s identical fallback
-/// (`roots.web-signing`: an unenrolled local identity may still review,
-/// exactly as it may still browse and comment).
-fn reviewer_member_id<O: Find>(state: &AppState<O>) -> ents_model::MemberId {
- let pubkey = state.identity.public_openssh();
- super::account::resolve_member_by_key(state, &pubkey)
- .map(|(id, _member)| id)
- .unwrap_or_else(|_source| ents_model::MemberId::new(short_key_fingerprint(&pubkey)))
-}
-
-/// The first twelve characters of `pubkey`'s key-material token -- mirrors
-/// `git_ents::commands::short_fingerprint`'s identical fallback label.
-fn short_key_fingerprint(pubkey: &str) -> String {
- let hex: String = pubkey
- .split_whitespace()
- .nth(1)
- .unwrap_or(pubkey)
- .chars()
- .take(12)
- .collect();
- if hex.is_empty() {
- "member".to_owned()
- } else {
- hex
- }
-}
-
/// Validate `text` as a full, well-formed object id -- hex characters only,
/// at the exact length the served repository's hash kind expects (this
/// page does not resolve abbreviated prefixes; [`super::commits::list`]'s
crates/cli/ents-web/src/pages/mod.rs
@@ -19,12 +19,17 @@
//! and [`agents`] are rail items of their own -- `Tab::Commits`,
//! `Tab::Reviews`, `Tab::Issues`, and `Tab::Agents` (Agents,
//! `docs/agent-sessions-plan.adoc`'s Phase 3) in [`layout`]'s icon rail,
-//! alongside the dashboard, code, threads, and meta items. `reviews::list`
-//! (`GET /reviews`) is a read-only aggregate across every commit's own
-//! reviews (`commits::reviews_section` renders the same
-//! [`ents_forge::review`] entities scoped to one commit; this module has no
-//! writes of its own -- every mutation still posts through `commits`'s own
-//! routes). [`search`]
+//! alongside the dashboard, code, threads, and meta items. [`reviews`]'s
+//! `list` (`GET /reviews`) and `show` (`GET /reviews/{target}/{member}`)
+//! are a read-only aggregate and a per-review detail page over the same
+//! [`ents_forge::review`] entities [`commits::reviews_section`] renders
+//! scoped to one commit; starting a review still only ever posts through
+//! `commits`'s own route (`POST /commit/{oid}/review`), but withdrawing one
+//! (`POST /reviews/{target}/{member}/withdraw`) and commenting on one
+//! (`POST /reviews/{target}/{member}/comment`, `commits::review_comment`)
+//! are reachable from either page -- a review's own page and its home
+//! commit's page render the identical thread and composer, never two.
+//! [`search`]
//! renders with no rail item active at all; it is reached from the
//! `.wb-bar`'s own `.palette` search form rather than any rail item.
@@ -651,3 +656,39 @@
});
format!("scope-c{}", hash.checked_rem(6).unwrap_or(0))
}
+
+/// The acting session's member id -- the composite review key's
+/// `<member>` segment -- resolved the same way
+/// [`account::resolve_member_by_key`] does, falling back to a short hash of
+/// the public key when no enrolled member matches: mirrors
+/// `git_ents::commands::serve::build_state`'s identical fallback
+/// (`roots.web-signing`: an unenrolled local identity may still review or
+/// withdraw a review, exactly as it may still browse and comment). Shared
+/// by [`super::commits`] (starting a review) and [`super::reviews`]
+/// (withdrawing one) -- both need the same "which member is this session,
+/// as far as the review namespace is concerned" answer, so it lives here
+/// rather than in either page module.
+pub(crate) fn reviewer_member_id<O: Find>(state: &AppState<O>) -> ents_model::MemberId {
+ let pubkey = state.identity.public_openssh();
+ account::resolve_member_by_key(state, &pubkey)
+ .map(|(id, _member)| id)
+ .unwrap_or_else(|_source| ents_model::MemberId::new(short_key_fingerprint(&pubkey)))
+}
+
+/// The first twelve characters of `pubkey`'s key-material token --
+/// mirrors `git_ents::commands::short_fingerprint`'s identical fallback
+/// label. [`reviewer_member_id`]'s own helper.
+fn short_key_fingerprint(pubkey: &str) -> String {
+ let hex: String = pubkey
+ .split_whitespace()
+ .nth(1)
+ .unwrap_or(pubkey)
+ .chars()
+ .take(12)
+ .collect();
+ if hex.is_empty() {
+ "member".to_owned()
+ } else {
+ hex
+ }
+}
crates/cli/ents-web/src/pages/reviews.rs
@@ -1,94 +1,132 @@
-//! `GET /reviews`: every review recorded in this repository, newest
-//! first -- a read-only aggregate across commits, alongside
-//! `crate::pages::commits`'s own per-commit `reviews_section` rather than
-//! replacing it. Every mutation (starting a review, commenting on one)
-//! still posts through `commits`'s own routes; this module has none of
-//! its own.
+//! `GET /reviews`, `GET /reviews/{target}/{member}`,
+//! `POST /reviews/{target}/{member}/withdraw`: the review surface's own
+//! aggregate list and per-review detail page -- a read-only aggregate
+//! across commits, alongside `crate::pages::commits`'s own per-commit
+//! `reviews_section` rather than replacing it. Starting a review still only
+//! ever posts through `commits`'s own route (`POST /commit/{oid}/review`);
+//! commenting on one (`POST /reviews/{target}/{member}/comment`) is
+//! `commits::review_comment`, shared verbatim by both pages that render a
+//! review's thread. Withdrawing one is this module's own mutation: every
+//! read is `ents_forge::review::{list,show}` and the withdraw write is
+//! `ents_forge::review::withdraw` -- the web is another caller of that one
+//! library func, never a second review-state machine (`lens.parity`).
+//!
+//! A review's own page ([`show`]) renders even when the review is
+//! [`ents_forge::review::ReviewState::Withdrawn`] -- a direct link stays
+//! live -- while [`list`] and [`reviews_sidebar`] both filter withdrawn
+//! rows out, mirroring `commits::reviews_section`'s own stance: withdrawal
+//! is append-only (`model.review`), it retracts a verdict from the
+//! aggregate views, never from history or from the one page a direct link
+//! still reaches.
use std::sync::Arc;
+use axum::Form;
+use axum::extract::{Path, State};
+use axum::response::{IntoResponse, Redirect};
+use ents_forge::review::{self, Review, ReviewState};
+use ents_model::MemberId;
use gix::bstr::ByteSlice as _;
use gix_object::{Find, Write};
use maud::{Markup, html};
+use serde::Deserialize;
use crate::error::Result;
+use crate::session::Session;
use crate::state::AppState;
-/// `GET /reviews`: list [`ents_forge::review::list`]'s full result (no
-/// `target` filter), newest reviewer-commit first. Best effort per row,
-/// mirroring `crate::pages::commits::reviews_section`'s own stance: a
-/// review whose reviewer-commit chain or target subject fails to read
-/// still renders, just without the piece that failed, rather than
-/// dropping the row or failing the page.
+/// Every non-withdrawn review recorded in this repository
+/// (`ents_forge::review::list`, no `target` filter), each paired with the
+/// review ref's own tip-commit time when it could be read (`model.review`
+/// stores no timestamp field of its own), newest first. The one aggregate
+/// read [`list`]'s cards and [`reviews_sidebar`]'s rows both build from, so
+/// the withdrawn filter and the ordering are computed in exactly one place.
+/// Best effort throughout, mirroring `commits::reviews_section`'s own
+/// stance: a review whose reviewer-commit chain fails to read still sorts
+/// (last, its time treated as `0`) rather than dropping the row.
+fn active_reviews<O: Find + Write>(
+ state: &AppState<O>,
+) -> Vec<(String, MemberId, Review, Option<i64>)> {
+ let mut rows = review::list(state.refs.as_ref(), &*state.objects(), &state.path, None)
+ .unwrap_or_default();
+ rows.retain(|(_, review)| review.state != ReviewState::Withdrawn);
+ let mut with_time: Vec<(String, MemberId, Review, Option<i64>)> = rows
+ .into_iter()
+ .map(|((target, member), review)| {
+ let seconds = ents_model::namespace::review_ref(&target, &member)
+ .ok()
+ .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten())
+ .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok())
+ .map(|(_author, seconds)| seconds);
+ (target, member, review, seconds)
+ })
+ .collect();
+ with_time.sort_by_key(|(.., seconds)| std::cmp::Reverse(seconds.unwrap_or(0)));
+ with_time
+}
+
+/// `GET /reviews`: [`active_reviews`]'s full result rendered as one card
+/// per review -- verdict, reviewer, and the target commit's own subject --
+/// beside [`reviews_sidebar`]'s compact newest-first nav (`crate::pages::layout_split`).
///
/// # Errors
///
/// Propagates a ref-store or object read failure enumerating the reviews
/// themselves ([`ents_forge::review::list`]); a per-row read failure
/// degrades that row instead of failing the page.
-pub async fn list<O>(
- axum::extract::State(state): axum::extract::State<Arc<AppState<O>>>,
-) -> Result<Markup>
+pub async fn list<O>(State(state): State<Arc<AppState<O>>>) -> Result<Markup>
where
O: Find + Write + Send + 'static,
{
- let mut rows = ents_forge::review::list(state.refs.as_ref(), &*state.objects(), &state.path, None)
- .unwrap_or_default();
+ let rows = active_reviews(&state);
let repo = gix::open(&state.path).ok();
- let mut with_time: Vec<(i64, Markup)> = rows
- .drain(..)
- .map(|((target, member), review)| {
- let reviewer = ents_model::namespace::review_ref(&target, &member)
- .ok()
- .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten())
- .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok());
- let seconds = reviewer.as_ref().map_or(0, |(_author, seconds)| *seconds);
+ let cards: Vec<Markup> = rows
+ .iter()
+ .map(|(target, member, review, seconds)| {
let subject = repo.as_ref().and_then(|repo| {
let oid = gix_hash::ObjectId::from_hex(target.as_bytes()).ok()?;
let commit = repo.find_commit(oid).ok()?;
let message = commit.message().ok()?;
Some(message.title.to_str_lossy().into_owned())
});
- let short = target.get(..7).unwrap_or(&target).to_owned();
- (
- seconds,
- html! {
- div.card {
- div.comment-meta {
- span class={ "verdict verdict-" (review.verdict) } { (review.verdict) }
- (super::avatar(member.as_str()))
- span.author { (member) }
- span.spacer {}
- a href={ "/commit/" (target) } { code { (short) } }
- @if let Some(subject) = &subject {
- span.muted { (subject) }
- }
- }
- @if let Some((_author, seconds)) = &reviewer {
- span.entry-size { (super::ago(*seconds)) }
+ let short = target.get(..7).unwrap_or(target).to_owned();
+ html! {
+ div.card {
+ div.comment-meta {
+ span class={ "verdict verdict-" (review.verdict) } { (review.verdict) }
+ (super::avatar(member.as_str()))
+ span.author { (member) }
+ span.spacer {}
+ a href={ "/reviews/" (target) "/" (member) } { code { (short) } }
+ @if let Some(subject) = &subject {
+ span.muted { (subject) }
}
}
- },
- )
+ @if let Some(seconds) = seconds {
+ span.entry-size { (super::ago(*seconds)) }
+ }
+ }
+ }
})
.collect();
- with_time.sort_by(|(a, _), (b, _)| b.cmp(a));
- Ok(super::layout(
+ Ok(super::layout_split(
&super::RepoHeader::from_state(&state),
&super::identity_label(&state),
super::Tab::Reviews,
"Reviews",
+ false,
+ reviews_sidebar(&rows, None),
html! {
div.readable {
- @if with_time.is_empty() {
+ @if cards.is_empty() {
(super::blankslate(
"No reviews yet",
html! { "Record one from a commit's own page." },
))
} @else {
- @for (_seconds, card) in &with_time {
+ @for card in &cards {
(card)
}
}
@@ -96,3 +134,217 @@
},
))
}
+
+/// The Reviews split's `.tree` sidebar (mirrors `issues::issues_sidebar`):
+/// every [`active_reviews`] row as a two-line `.side-row` -- its verdict and
+/// reviewer on the title line, the target commit's abbreviated id on the
+/// locator line -- linking to [`show`]'s own page, `.active` naming the
+/// viewed `(target, member)` pair. Withdrawn reviews are already filtered
+/// out of `rows` by [`active_reviews`]; they stay reachable only by a
+/// direct link to [`show`], never from this nav.
+fn reviews_sidebar(
+ rows: &[(String, MemberId, Review, Option<i64>)],
+ active: Option<(&str, &str)>,
+) -> Markup {
+ html! {
+ div.tree-head {
+ span { "Reviews" }
+ }
+ @if rows.is_empty() {
+ span.tree-note { "No reviews yet." }
+ }
+ @for (target, member, review, _seconds) in rows {
+ a.side-row.active[active == Some((target.as_str(), member.as_str()))]
+ href={ "/reviews/" (target) "/" (member) }
+ {
+ span.side-title {
+ span class={ "verdict verdict-" (review.verdict) } { (review.verdict) }
+ " " (member.as_str())
+ }
+ span.side-meta {
+ span.locator { "on " (ents_forge::abbreviate_id(target)) }
+ }
+ }
+ }
+ }
+}
+
+/// `GET /reviews/{target}/{member}`: one review
+/// (`ents_forge::review::show`), its verdict/state/target/reviewer metadata
+/// card, its body rendered as AsciiDoc, its discussion thread, a comment
+/// composer, and -- only for the review's own author while it is still
+/// [`ReviewState::Active`] -- a withdraw control. Renders even for a
+/// withdrawn review (this module's own doc: a direct link stays live; only
+/// [`list`]/[`reviews_sidebar`] hide a withdrawn row).
+///
+/// # Errors
+///
+/// [`crate::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) if
+/// `target`/`member` has no review ref at all; otherwise propagates a
+/// ref-store or object read failure.
+// @relation(model.review, model.comment-context, lens.parity, scope=function)
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path((target, member)): Path<(String, String)>,
+) -> Result<Markup>
+where
+ O: Find + Write + Send + 'static,
+{
+ let member = MemberId::new(member);
+ let (review, thread) =
+ review::show(state.refs.as_ref(), &*state.objects(), &target, &member)?;
+ let reviewer = ents_model::namespace::review_ref(&target, &member)
+ .ok()
+ .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten())
+ .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok());
+ let body =
+ crate::asciidoc::to_html(&review.body).unwrap_or_else(|_| html! { p { (review.body) } });
+ let return_to = format!("/reviews/{target}/{member}");
+ let is_author = super::reviewer_member_id(&state) == member;
+ // Best-effort: the sidebar listing every other review beside this one
+ // is navigation chrome, never a reason to fail this review's own page.
+ let rows = active_reviews(&state);
+
+ Ok(super::layout_split(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Reviews,
+ &format!("Review of {}", ents_forge::abbreviate_id(&target)),
+ false,
+ reviews_sidebar(&rows, Some((&target, member.as_str()))),
+ html! {
+ (super::child_crumbs("reviews", "/reviews", ents_forge::abbreviate_id(&target)))
+ div.readable {
+ div.card {
+ dl.entity-view {
+ dt { "verdict" }
+ dd { span class={ "verdict verdict-" (review.verdict) } { (review.verdict) } }
+ dt { "state" }
+ dd { (state_badge(review.state)) }
+ dt { "target" }
+ dd { a href={ "/commit/" (target) } { code { (target) } } }
+ dt { "reviewer" }
+ dd { (super::avatar(member.as_str())) " @" (member.as_str()) }
+ dt { "reviewed" }
+ dd {
+ @if let Some((_author, seconds)) = &reviewer {
+ (super::ago(*seconds))
+ } @else {
+ span.muted { "unknown" }
+ }
+ }
+ }
+ div.doc-body { (body) }
+ }
+ @if review.state == ReviewState::Active {
+ @if is_author {
+ (withdraw_form(&session, &target, &member))
+ }
+ } @else {
+ p.muted { "This review has been withdrawn." }
+ }
+ h2 { "Discussion" }
+ @if thread.is_empty() {
+ (super::blankslate(
+ "No comments yet",
+ html! { "Start the discussion below." },
+ ))
+ } @else {
+ (crate::pages::comments::thread_section(&state, &session, &thread, &return_to))
+ }
+ div.card {
+ div.card-header { "Add a comment" }
+ (super::commits::review_comment_form(&session, &target, &member, &return_to))
+ }
+ }
+ },
+ ))
+}
+
+/// The review detail card's `state` `dd` (see [`show`]): a plain neutral
+/// `.chip.chip-pill` naming `active`, or the same grey `.state-closed`
+/// treatment `issues::state_chip` gives a closed issue naming `withdrawn`
+/// instead -- so a direct link to a retracted verdict states plainly, at a
+/// glance, that it no longer stands.
+fn state_badge(state: ReviewState) -> Markup {
+ match state {
+ ReviewState::Active => html! {
+ span.chip.chip-pill { "active" }
+ },
+ ReviewState::Withdrawn => html! {
+ span.chip.chip-pill.state-closed { "withdrawn" }
+ },
+ }
+}
+
+/// The withdraw-this-review control (`POST /reviews/{target}/{member}/withdraw`),
+/// rendered by [`show`] only for the review's own author while it is still
+/// [`ReviewState::Active`] -- retracting a verdict stays a decision only
+/// its author can make, the same way `ents-gate`'s own `owner_mutation`
+/// check refuses anyone else's attempt at the ref level
+/// (`ents_forge::review::withdraw`'s own doc).
+fn withdraw_form(session: &Session, target: &str, member: &MemberId) -> Markup {
+ html! {
+ form method="post" action=(format!("/reviews/{target}/{member}/withdraw")) {
+ (super::csrf_input(session))
+ button type="submit" { "Withdraw review" }
+ }
+ }
+}
+
+/// The form fields `POST /reviews/{target}/{member}/withdraw` accepts.
+#[derive(Debug, Deserialize)]
+pub struct WithdrawForm {
+ /// The per-session CSRF token (`roots.web-session`).
+ csrf: String,
+}
+
+/// `POST /reviews/{target}/{member}/withdraw`: retract the signed-in
+/// member's own review of `target` (`ents_forge::review::withdraw`) -- the
+/// web is another caller of that one library func, driving the identical
+/// mutation `git ents review withdraw` does. The path's own `member`
+/// segment names whose review [`show`] rendered, but the write always
+/// targets the *signed-in* identity's own member id
+/// ([`super::reviewer_member_id`]), never the path's: this handler can only
+/// ever build and write `reviews/<target>/<the signer>`. A member who is
+/// not the review's author therefore has no matching
+/// `refs/meta/reviews/<target>/<member>` of their own to advance, and the
+/// mutation fails with [`ents_forge::Error::NotFound`] rather than
+/// touching anyone else's ref -- no divergent ownership check is added
+/// here; `ents-gate`'s own `identity_binding`/`owner_mutation` checks on
+/// this namespace back the same refusal up independently (see
+/// [`ents_forge::review::withdraw`]'s own doc).
+///
+/// # Errors
+///
+/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise
+/// propagates [`ents_forge::review::withdraw`]'s own failures (including
+/// [`ents_forge::Error::NotFound`] when the signed-in member has no review
+/// reaching `target`).
+// @relation(model.review, roots.web-signing, roots.web-session, lens.parity, scope=function)
+pub async fn withdraw<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ Path((target, _member)): Path<(String, String)>,
+ Form(form): Form<WithdrawForm>,
+) -> Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ let member = super::reviewer_member_id(&state);
+ let identity = state.identity.as_ref();
+ let (target_hex, outcome) = review::withdraw(
+ state.refs.as_ref(),
+ &*state.objects(),
+ state.events.as_ref(),
+ &state.path,
+ &target,
+ &member,
+ &crate::receive_identity!(identity, crate::pages::member_author(&session)),
+ state.mode,
+ )?;
+ crate::error::outcome_to_result(outcome)?;
+ Ok(Redirect::to(&format!("/reviews/{target_hex}/{member}")))
+}
crates/cli/git-ents/src/commands/mod.rs
@@ -12,6 +12,7 @@
pub mod agent;
pub mod bootstrap;
pub mod comment;
+pub mod config;
pub mod effect;
pub mod inbox;
pub mod issue;
crates/cli/git-ents/src/commands/review.rs
@@ -50,6 +50,40 @@
Ok(target)
}
+/// `git ents review withdraw`: retract the signer's own review of `target`,
+/// resolving the reviewer's member id exactly as [`new`] does — the
+/// withdrawing member is always the signing identity's own resolved
+/// member, never one named on the command line, so this can never be
+/// pointed at someone else's review (`gate.owner-mutation` refuses it even
+/// if it were).
+///
+/// # Errors
+///
+/// [`crate::error::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`])
+/// if this member has no existing review reaching `target`; otherwise as
+/// [`new`].
+pub fn withdraw(root: &LocalRoot, target: String, key: Option<std::path::PathBuf>) -> Result<String> {
+ let signer = signer(root, key)?;
+ let member = reviewer_member_id(root, &signer)?;
+ let identity = Identity {
+ actor: actor(&signer),
+ author: None,
+ sign: &|payload| signer.sign(payload),
+ };
+ let (target, outcome) = review::withdraw(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ &root.path,
+ &target,
+ &member,
+ &identity,
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(target)
+}
+
/// The member id owning the signer's key — the composite review key's
/// `<member>` segment — via the same key-to-member scan
/// [`super::members::find_by_key`] already performs for `git ents members
crates/forge/ents-forge/src/agent/cli.rs
@@ -33,7 +33,9 @@
/// The ref the run executes against as its starting point.
#[facet(args::named, default = "HEAD")]
base: String,
- /// The session's initially resolved review policy: auto or manual.
+ /// The session's initially resolved review policy. `manual` (default):
+ /// no review opens on its own; you start one yourself. `auto`: a review
+ /// of the result opens automatically once the run finishes.
#[facet(args::named, default = "manual")]
review_policy: String,
/// The genesis oid of a prior session this one retries.
crates/forge/ents-forge/src/review/cli.rs
@@ -35,6 +35,21 @@
#[facet(args::named)]
key: Option<PathBuf>,
},
+ /// Withdraw this member's own review: writes a new `Withdrawn`-state
+ /// entity onto the *same* two refs the original review occupies,
+ /// preserving its verdict and body — append-only, so the prior verdict
+ /// stays in the ref's history. Refuses when this member has no
+ /// existing review reaching `target`.
+ Withdraw {
+ /// Revision identifying the review to withdraw: resolved exactly
+ /// as `new`'s own target and re-review advance are, so a
+ /// descendant of the reviewed commit still finds it.
+ #[facet(args::named, default = "HEAD")]
+ target: String,
+ /// Key to sign with; defaults to `user.signingkey`.
+ #[facet(args::named)]
+ key: Option<PathBuf>,
+ },
/// List the reviews recorded in this repository.
List {
/// Keep only reviews of this revision.
crates/forge/ents-forge/src/review/command.rs
@@ -259,6 +259,85 @@
Ok(out)
}
+/// `git ents review withdraw`: retract `member`'s own review of `target`,
+/// leaving the prior verdict in history rather than erasing it
+/// (`model.review`). Resolves `target` (a revision) exactly as [`new`]
+/// does, then reuses [`find_review_to_advance`] to locate `member`'s
+/// *existing* review whose recorded target ([`Review::target`]) is
+/// `target` itself or one of its ancestors — the same fast-forward lookup
+/// `new` performs before a re-review, so a withdrawal reaches the review
+/// even if it has since advanced past the commit named here. That review's
+/// [`Review::withdrawn`] copy — same `target`, `verdict`, and `body`, only
+/// `state` flipped — is written back onto the *same* two refs via
+/// [`propose_entity_with_pin`], the identical advance/ref-writing path
+/// `new` uses: no parallel write path exists for withdrawal
+/// (`model.review-pin`, `receive.multi-ref-atomicity`).
+///
+/// Ownership is enforced entirely by `ents-gate`'s existing checks on the
+/// `refs/meta/reviews/<target>/<member>` namespace — `identity_binding`'s
+/// `Namespace::Review` arm (a review must be signed by the exact `member`
+/// its own refname names) and `owner_mutation`'s `Namespace::Review` arm
+/// (only that same signer may advance it) — so this function does not
+/// re-check who `member` is; it only ever builds and writes
+/// `reviews/<target>/<member>`, `member`'s own ref, and lets the gate
+/// refuse anything else the same way it already refuses a mismatched
+/// re-review (`gate.identity-binding`, `gate.owner-mutation`).
+///
+/// Withdrawing an already-withdrawn review is not an error: the found
+/// review's `withdrawn()` copy of a `Withdrawn` review is itself
+/// `Withdrawn`, so this simply re-writes the same state — a harmless
+/// no-op-ish advance, not a special case this function detects.
+///
+/// # Errors
+///
+/// [`Error::InvalidArgument`] if `target` does not resolve to a commit;
+/// [`Error::NotFound`] if `member` has no existing review reaching
+/// `target` — there is nothing to withdraw; otherwise propagates
+/// serialization or `receive` failures.
+// @relation(model.review, model.review-pin, meta-ref.identity-binding, receive.multi-ref-atomicity, lens.parity, scope=function)
+#[expect(
+ clippy::too_many_arguments,
+ reason = "one field per mutation shape, mirroring new's identically-justified shape"
+)]
+pub fn withdraw(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn ents_receive::EventSink,
+ repo_path: &std::path::Path,
+ target: &str,
+ member: &MemberId,
+ identity: &Identity<'_>,
+ mode: Mode,
+) -> Result<(String, Outcome)> {
+ let repo = gix::open(repo_path)?;
+ let reviewed = resolve_in(&repo, target)?;
+
+ let target_hex = find_review_to_advance(refs, objects, &repo, member, reviewed)?.ok_or_else(
+ || Error::NotFound {
+ what: format!("review of {reviewed} by {member}"),
+ },
+ )?;
+ let existing = review_at(refs, objects, &target_hex, member)?;
+ let withdrawn = existing.withdrawn();
+ let retained = existing.target();
+
+ let outcome = propose_entity_with_pin(
+ refs,
+ objects,
+ events,
+ ents_model::namespace::review_ref(&target_hex, member)?,
+ &withdrawn,
+ ents_model::namespace::review_pin_ref(&target_hex, member)?,
+ retained,
+ identity,
+ &format!("Withdraw review {retained}"),
+ &format!("Pin review {target_hex}/{member}"),
+ mode,
+ )?;
+
+ Ok((target_hex, outcome))
+}
+
/// `git ents review show`: `target`/`member`'s review, plus its discussion
/// thread — every [`Comment`] naming `reviews/<target>/<member>` as its
/// context (or a reply into one), reusing [`crate::comment::thread`] rather
crates/forge/ents-forge/src/review/entity.rs
@@ -6,6 +6,71 @@
use facet::Facet;
use gix_hash::ObjectId;
+/// A review's lifecycle state (`model.review`): whether the reviewer's
+/// verdict still stands (`Active`) or the reviewer has retracted it
+/// (`Withdrawn`). Withdrawal is append-only — [`super::command::withdraw`]
+/// writes a new [`Review`] entity carrying this variant onto the *same*
+/// ref chain rather than deleting anything, so a withdrawn verdict remains
+/// in `refs/meta/reviews/<target>/<member>`'s history: the chain is the
+/// audit trail. Web aggregate views (the `/reviews` list, a commit's own
+/// reviews section) filter `Withdrawn` rows out of what they render, but
+/// nothing here or in `super::command` ever removes the ref, the object,
+/// or an earlier commit naming `Active`.
+///
+/// `Active` is this type's [`Default`] and the [`Review`] field carrying it
+/// is `#[facet(default)]` for exactly one reason: every review tree written
+/// before this variant existed has no `state` entry at all, and must still
+/// read back as a plain, unretracted review rather than fail to decode
+/// (backward compatibility with every review recorded before this change).
+///
+/// Parses from and renders as its kebab-case convention names (`active`,
+/// `withdrawn`), the same convention [`Verdict`] follows.
+///
+/// # Examples
+///
+/// ```
+/// use ents_forge::review::ReviewState;
+///
+/// let state: ReviewState = "withdrawn".parse().expect("known state");
+/// assert_eq!(state, ReviewState::Withdrawn);
+/// assert_eq!(state.to_string(), "withdrawn");
+/// assert_eq!(ReviewState::default(), ReviewState::Active);
+/// ```
+// @relation(model.review, meta-ref.typed-tree, scope=type)
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Facet)]
+#[repr(u8)]
+pub enum ReviewState {
+ /// The reviewer's verdict still stands.
+ #[default]
+ Active,
+ /// The reviewer has retracted this review; the verdict and body stay
+ /// in history, unread by aggregate views.
+ Withdrawn,
+}
+
+impl std::str::FromStr for ReviewState {
+ type Err = crate::Error;
+
+ fn from_str(text: &str) -> Result<Self, Self::Err> {
+ match text {
+ "active" => Ok(Self::Active),
+ "withdrawn" => Ok(Self::Withdrawn),
+ other => Err(crate::Error::InvalidArgument(format!(
+ "unknown review state {other:?}: expected active or withdrawn"
+ ))),
+ }
+ }
+}
+
+impl std::fmt::Display for ReviewState {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(match self {
+ Self::Active => "active",
+ Self::Withdrawn => "withdrawn",
+ })
+ }
+}
+
/// A review's verdict (`model.review`): a hard enum, unlike issue and
/// comment states — a verdict gates decisions, so its vocabulary is
/// platform, not schema.
@@ -108,11 +173,21 @@
pub verdict: Verdict,
/// The review's body text.
pub body: String,
+ /// Whether this review still stands or has been withdrawn
+ /// (`model.review`). `#[facet(default)]` so a tree written before this
+ /// field existed — no `state` entry at all — deserializes to
+ /// [`ReviewState::Active`] rather than failing to decode; every
+ /// existing `refs/meta/reviews/*` history predates this field and must
+ /// keep reading.
+ #[facet(default)]
+ pub state: ReviewState,
}
impl Review {
/// Build a review of `target` carrying `verdict` and `body`
- /// (`model.review`).
+ /// (`model.review`), initially [`ReviewState::Active`] — every review
+ /// starts active; only [`super::command::withdraw`] ever writes
+ /// [`ReviewState::Withdrawn`].
#[must_use]
pub fn new(target: ObjectId, verdict: Verdict, body: impl Into<String>) -> Self {
let mut bytes = [0u8; 20];
@@ -121,6 +196,7 @@
target: bytes,
verdict,
body: body.into(),
+ state: ReviewState::Active,
}
}
@@ -132,6 +208,21 @@
pub fn target(&self) -> ObjectId {
ObjectId::from_bytes_or_panic(&self.target)
}
+
+ /// A copy of this review with its state advanced to
+ /// [`ReviewState::Withdrawn`], preserving `target`, `verdict`, and
+ /// `body` exactly (`model.review`): [`super::command::withdraw`] writes
+ /// this new entity onto the same ref chain the original review
+ /// occupies — append-only, so the prior [`Active`](ReviewState::Active)
+ /// commit stays reachable in history — rather than mutating anything
+ /// in place.
+ #[must_use]
+ pub fn withdrawn(&self) -> Self {
+ Self {
+ state: ReviewState::Withdrawn,
+ ..self.clone()
+ }
+ }
}
#[cfg(test)]
@@ -166,4 +257,78 @@
let review = Review::new(target, Verdict::Approve, "");
assert_eq!(review.target(), target);
}
+
+ /// The exact shape a `Review` tree had before [`ReviewState`] existed —
+ /// `target`/`verdict`/`body` only, no `state` entry at all. Local to
+ /// this test: it stands in for every `refs/meta/reviews/<target>/*`
+ /// tree already recorded in a real repository before this change
+ /// landed.
+ #[derive(Facet)]
+ struct PreStateReview {
+ target: [u8; 20],
+ verdict: Verdict,
+ body: String,
+ }
+
+ #[rstest]
+ // @relation(model.review, meta-ref.typed-tree, scope=function, role=Verifies)
+ fn a_review_tree_written_before_state_existed_reads_back_as_active() {
+ let target =
+ ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").expect("valid hex");
+ let mut bytes = [0u8; 20];
+ bytes.copy_from_slice(target.as_slice());
+ let legacy = PreStateReview {
+ target: bytes,
+ verdict: Verdict::RequestChanges,
+ body: "reviewed before withdrawal existed".to_owned(),
+ };
+ let (root, store) = serialize(&legacy).expect("serialize the pre-state shape");
+ let back: Review =
+ deserialize(&root, &store).expect("today's Review must still decode a tree with no \
+ state entry");
+ assert_eq!(back.state, ReviewState::Active);
+ assert_eq!(back.verdict, Verdict::RequestChanges);
+ assert_eq!(back.body, "reviewed before withdrawal existed");
+ assert_eq!(back.target(), target);
+ }
+
+ #[rstest]
+ // @relation(model.review, scope=function, role=Verifies)
+ fn withdrawn_preserves_target_verdict_and_body_and_flips_only_state() {
+ let target =
+ ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").expect("valid hex");
+ let review = Review::new(target, Verdict::Approve, "looks good");
+ let withdrawn = review.withdrawn();
+
+ assert_eq!(withdrawn.state, ReviewState::Withdrawn);
+ assert_eq!(withdrawn.verdict, review.verdict);
+ assert_eq!(withdrawn.body, review.body);
+ assert_eq!(withdrawn.target(), review.target());
+
+ // Idempotent-friendly: withdrawing an already-withdrawn review is a
+ // no-op-ish re-write, not an error or a second distinct shape.
+ let withdrawn_again = withdrawn.withdrawn();
+ assert_eq!(withdrawn_again, withdrawn);
+ }
+
+ #[rstest]
+ #[case::active("active", ReviewState::Active)]
+ #[case::withdrawn("withdrawn", ReviewState::Withdrawn)]
+ // @relation(model.review, scope=function, role=Verifies)
+ fn review_state_parses_its_own_display_strings(
+ #[case] text: &str,
+ #[case] expected: ReviewState,
+ ) {
+ let parsed: ReviewState = text.parse().expect("known state");
+ assert_eq!(parsed, expected);
+ assert_eq!(parsed.to_string(), text);
+ }
+
+ #[rstest]
+ // @relation(model.review, scope=function, role=Verifies)
+ fn review_state_rejects_an_unknown_string() {
+ "revoked"
+ .parse::<ReviewState>()
+ .expect_err("not a known review state");
+ }
}
crates/forge/ents-forge/src/review/mod.rs
@@ -9,5 +9,5 @@
mod entity;
pub use cli::ReviewAction;
-pub use command::{NewReview, list, new, show};
-pub use entity::{Review, Verdict};
+pub use command::{NewReview, list, new, show, withdraw};
+pub use entity::{Review, ReviewState, Verdict};
crates/cli/git-ents/src/commands/config.rs
@@ -1,0 +1,96 @@
+//! `git ents config`: forge-wide, non-secret agent-runtime defaults
+//! (provider name, default model) recorded in `refs/meta/config` alongside
+//! the gate's own `epoch`/`workers` fields (`ents_gate::Config`).
+//!
+//! The API token is deliberately not here and never will be: it lives only
+//! in the deployment-time credential seam (`crate::credentials`,
+//! `GIT_ENTS_CREDENTIALS_FILE`), never in a signed, replicated,
+//! multi-reader tree.
+
+use ents_gate::Config;
+use ents_model::namespace;
+use ents_receive::{Identity, propose_entity};
+use gix_ref_store::RefStoreRead;
+
+use super::{actor, signer};
+use crate::error::Result;
+use crate::mutate::outcome_to_result;
+use crate::root::LocalRoot;
+
+/// `git ents config show`: this repository's current forge-wide
+/// configuration -- [`Config::default`] (every field unset) when
+/// `refs/meta/config` has no tip yet, the same "absent means unconfigured"
+/// reading every `ents_gate::config` reader already gives.
+///
+/// # Errors
+///
+/// Propagates a ref-store or object read failure, or an unreadable config
+/// tree.
+pub fn show(root: &LocalRoot) -> Result<Config> {
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "CONFIG_REF is a fixed, compile-time-known-valid refname literal"
+ )]
+ let name: gix::refs::FullName = namespace::CONFIG_REF
+ .try_into()
+ .expect("fixed, valid refname");
+ let Some(tip) = root.refs.get(name.as_ref())? else {
+ return Ok(Config::default());
+ };
+ let tree = super::commit_tree(&root.objects, tip)?;
+ Ok(facet_git_tree::deserialize::<Config>(&tree, &root.objects)?)
+}
+
+/// `git ents config set`: narrow the agent-runtime defaults. Each argument
+/// is an independent optional narrowing -- omit one to leave whatever it
+/// currently holds untouched, rather than resetting it to `None`. Reads
+/// the current config first and writes the merged whole back, so a `set`
+/// naming only `agent_provider` cannot clobber an `agent_default_model`
+/// set earlier, or the gate's own `epoch`/`workers`.
+///
+/// # Errors
+///
+/// Propagates a ref-store/object read failure or a signing failure;
+/// otherwise see [`crate::mutate::outcome_to_result`] for how a reached
+/// refusal renders.
+pub fn set(
+ root: &LocalRoot,
+ agent_provider: Option<String>,
+ agent_default_model: Option<String>,
+ key: Option<std::path::PathBuf>,
+) -> Result<()> {
+ let signer = signer(root, key)?;
+ let mut config = show(root)?;
+ if let Some(provider) = agent_provider {
+ config.agent_provider = Some(provider);
+ }
+ if let Some(model) = agent_default_model {
+ config.agent_default_model = Some(model);
+ }
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "CONFIG_REF is a fixed, compile-time-known-valid refname literal"
+ )]
+ let name: gix::refs::FullName = namespace::CONFIG_REF
+ .try_into()
+ .expect("fixed, valid refname");
+ let identity = Identity {
+ actor: actor(&signer),
+ author: None,
+ sign: &|payload| signer.sign(payload),
+ };
+ let outcome = propose_entity(
+ &root.refs,
+ &root.objects,
+ &root.events,
+ name,
+ &config,
+ &identity,
+ "Set agent config",
+ root.mode(),
+ )?;
+ outcome_to_result(outcome, None)?;
+ Ok(())
+}
crates/cli/git-ents/tests/config.rs
@@ -1,0 +1,65 @@
+//! Integration coverage for `git ents config` against a real local
+//! composition root (`roots.local`): narrowing the agent-runtime defaults
+//! at `refs/meta/config` without disturbing fields `set` was not told to
+//! touch, and reading an unconfigured repository back as
+//! `ents_gate::Config::default()`.
+
+#![allow(clippy::expect_used, reason = "integration test")]
+
+mod common;
+
+use ents_gate::Config;
+use git_ents::commands::config;
+use git_ents::root::LocalRoot;
+
+/// A repository that has never had `refs/meta/config` written reads back
+/// as every field unset -- the same "absent means unconfigured" reading
+/// `ents_gate::config`'s own readers give, and what lets an old config
+/// (predating `agent_provider`/`agent_default_model`) keep parsing.
+// @relation(roots.local, scope=function, role=Verifies)
+#[test]
+fn absent_config_reads_as_default() {
+ let fixture = common::Fixture::new(1);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+
+ let config = config::show(&root).expect("shows");
+ assert_eq!(config, Config::default());
+}
+
+/// `set` narrows one field at a time: a later call naming only
+/// `agent_default_model` must not clobber an `agent_provider` an earlier
+/// call set, and neither call ever touches `workers`/`epoch` (unset by
+/// either call).
+// @relation(roots.local, scope=function, role=Verifies)
+#[test]
+fn set_narrows_without_clobbering_other_fields() {
+ let fixture = common::Fixture::new(1);
+ let root = LocalRoot::open(fixture.path()).expect("opens");
+
+ config::set(
+ &root,
+ Some("anthropic".to_owned()),
+ None,
+ Some(fixture.key_path.clone()),
+ )
+ .expect("sets provider");
+ let config = config::show(&root).expect("shows");
+ assert_eq!(config.agent_provider.as_deref(), Some("anthropic"));
+ assert_eq!(config.agent_default_model, None);
+ assert!(config.workers.is_empty());
+ assert_eq!(config.epoch, None);
+
+ config::set(
+ &root,
+ None,
+ Some("claude-sonnet-5".to_owned()),
+ Some(fixture.key_path.clone()),
+ )
+ .expect("sets default model");
+ let config = config::show(&root).expect("shows");
+ assert_eq!(config.agent_provider.as_deref(), Some("anthropic"));
+ assert_eq!(
+ config.agent_default_model.as_deref(),
+ Some("claude-sonnet-5")
+ );
+}