git-ents.gitmain
⌘K
foforge
commit 709c5b3
web: the agents surface — list, detail, one-tap confirm, submission (phase 3)

pages/agents.rs joins the icon rail as its own tab: every read is ents_forge::agent::{list_all,show} and every mutation is agent::{new, confirm} through the same signed web path (roots.web-signing) and CSRF check the issue pages use — the web stays another caller of the library, never a second session implementation.

  • List/sidebar renders the six derived states (planning / awaiting confirmation / queued / running / done / failed) off the entity’s own queued()/awaiting_confirmation() predicates; terminal states reuse the Checks pass/fail chips, in-flight states one neutral pill.

  • Detail walks the session ref’s single-parent chain into a status timeline, shows the sprite name verbatim while running, links the result branch when its ref resolves, and derives the canonical refs/meta/results/agent-exec/<short-oid> record ref from the confirmed-and-queued commit — the same oid agent_worker dispatches on.

  • Thread blobs are never rendered anywhere: a turn count only (opaque, redactable audit material; no raw-blob route exists to link out to).

  • The one-tap Confirm form posts only a CSRF token; the submission form is prompt + base branch + model + review policy, defaults from the served repo — complexity lives in the session doc, not the form.

No JavaScript; refresh is plain reload. 5 new router tests, including an end-to-end new → plan → confirm → claim → finish chain checking the derived result-record ref before and after the record lands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Joseph D. Carpinelli · 28 days ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/src/router.rs @@ -104,6 +104,12 @@ get(pages::issues::show::<O>).post(pages::issues::edit::<O>), ) .route("/issues/{id}/comment", post(pages::issues::comment::<O>)) + .route( + "/agents", + get(pages::agents::list::<O>).post(pages::agents::create::<O>), + ) + .route("/agents/{id}", get(pages::agents::show::<O>)) + .route("/agents/{id}/confirm", post(pages::agents::confirm::<O>)) .route("/inbox", get(pages::inbox::list::<O>)) .route("/style.css", get(style)) .route("/ents.js", get(script))
crates/cli/ents-web/tests/router.rs @@ -13,7 +13,7 @@ use axum::http::{Request, StatusCode, header}; use ents_kiln::Toolchain; use ents_model::{Account, Effect, MemberId, Provenance, Redaction, ResultRecord, Status}; -use ents_receive::{Mode, NullEventSink}; +use ents_receive::{Identity, Mode, NullEventSink}; use ents_testutil::{ CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, record_result, write_commit, write_meta_entity, @@ -997,7 +997,9 @@ ); let detail = get_body(&router, &format!("/issues/{issue_id}")).await; assert!( - detail.contains(&format!("class=\"side-row active\" href=\"/issues/{issue_id}\"")), + detail.contains(&format!( + "class=\"side-row active\" href=\"/issues/{issue_id}\"" + )), "the viewed issue highlights in the sidebar" ); } @@ -1093,6 +1095,7 @@ "/files", "/commits", "/issues", + "/agents", "/comments", "/meta", "/account", @@ -1102,7 +1105,7 @@ "the rail links {href}" ); } - for label in ["Dashboard", "Code", "Review", "Issues", "Threads"] { + for label in ["Dashboard", "Code", "Review", "Issues", "Agents", "Threads"] { assert!( overview.contains(&format!("title=\"{label}\"")), "the rail tooltips {label}" @@ -3256,3 +3259,508 @@ "a revoked member is signed out, not left holding a dead session" ); } + +// --------------------------------------------------------------------- +// `crate::pages::agents` (`docs/agent-sessions-plan.adoc`'s Phase 3). +// --------------------------------------------------------------------- + +/// The actor signature every [`Identity`] the agent tests below build +/// carries -- shaped exactly like [`FixtureIdentity::actor`]. Only this +/// half factors into a helper: [`Identity::sign`] borrows its closure +/// (`ents_web::identity`'s own doc explains why: the closure must live in +/// the caller's own stack frame), so each test still builds its own +/// `Identity` literal around this, the same shape +/// `ents_web::receive_identity!` expands to. +fn fixture_actor(name: &'static str) -> gix::actor::Signature { + gix::actor::Signature { + name: name.into(), + email: format!("{name}@ents.test").into(), + time: gix::date::Time { + seconds: 1_000, + offset: 0, + }, + } +} + +/// `POST /agents`, seeding `prompt` as a fresh session's initial thread +/// turn -- what the agent tests below start a session through, exercising +/// the actual signed-write path (`ents_forge::agent::new`) rather than +/// poking the ref store directly. Asserts the write succeeded (a redirect +/// to the new session's own page) and returns its id, read from that +/// redirect's `Location` (`/agents/<id>`). +async fn seed_agent_via_web( + router: &axum::Router, + state: &AppState<ObjectStore>, + prompt: &str, +) -> String { + let (cookie, csrf) = session_cookie_and_csrf(router, state, "/agents").await; + let form = format!( + "prompt={}&base_ref=refs%2Fheads%2Fmain&model=claude-sonnet-5&review_policy=manual&csrf={csrf}", + prompt.replace(' ', "+") + ); + let response = router + .clone() + .oneshot( + Request::post("/agents") + .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(), + "agent session create did not succeed: {:?}", + response.status() + ); + response + .headers() + .get(header::LOCATION) + .expect("a successful agent create redirects to the new session") + .to_str() + .expect("ascii") + .strip_prefix("/agents/") + .expect("redirect targets /agents/<id>") + .to_owned() +} + +/// A freshly started session (no plan yet) renders as `planning` on both +/// the index and its own detail page, offers no confirm form, and never +/// leaks its seed prompt -- a thread turn like any other, never rendered +/// (`ents_forge::agent::AgentSession`'s own "never rendered" contract). +#[tokio::test] +// @relation(lens.parity, roots.web-signing, roots.web-session, scope=function, role=Verifies) +async fn agents_index_and_detail_render_a_freshly_started_session_as_planning_with_no_thread_leak() +{ + let state = build_state(FixtureIdentity { + name: "filer", + key: Keypair::from_seed(21), + }); + let router = ents_web::router(state.clone()); + let id = seed_agent_via_web(&router, &state, "top secret task instructions").await; + + let index = get_body(&router, "/agents").await; + assert!(index.contains(ents_forge::abbreviate_id(&id))); + assert!(index.contains("planning")); + assert!( + !index.contains("top secret task instructions"), + "thread content (the seed prompt) must never render on the list page" + ); + + let detail = get_body(&router, &format!("/agents/{id}")).await; + assert!(detail.contains("planning")); + assert!(detail.contains("No plan has been drafted yet.")); + assert!( + !detail.contains("Confirm plan"), + "no confirm form before a plan exists" + ); + assert!( + !detail.contains("top secret task instructions"), + "thread content (the seed prompt) must never render on the detail page either" + ); +} + +/// `roots.web-session`: starting a session is a state-changing route, so a +/// `POST /agents` with no CSRF field at all is rejected, and one with the +/// wrong token is a bad request -- the same gate every mutation in this +/// crate runs behind. +#[tokio::test] +// @relation(roots.web-session, scope=function, role=Verifies) +async fn agent_create_is_rejected_without_a_valid_csrf_token() { + let state = build_state(FixtureIdentity { + name: "filer", + key: Keypair::from_seed(22), + }); + let router = ents_web::router(Arc::clone(&state)); + + let no_csrf = router + .clone() + .oneshot( + Request::post("/agents") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(Body::from( + "prompt=sneaky&base_ref=HEAD&model=claude-sonnet-5&review_policy=manual", + )) + .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 start a session" + ); + + let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/agents").await; + let wrong = router + .oneshot( + Request::post("/agents") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, cookie) + .body(Body::from( + "prompt=sneaky&base_ref=HEAD&model=claude-sonnet-5&review_policy=manual&csrf=not-the-token", + )) + .expect("request"), + ) + .await + .expect("in-process call"); + assert_eq!(wrong.status(), StatusCode::BAD_REQUEST); +} + +/// A plan drafted (via `ents_forge::agent::revise_plan`, standing in for +/// Phase 4's not-yet-built planning surface) puts a session in `awaiting +/// confirmation` with a one-tap Confirm form; posting it +/// (`POST /agents/{id}/confirm`) is a signed, CSRF-checked mutation that +/// binds the plan's hash and reads back as `queued`, with the confirm form +/// gone. +#[tokio::test] +// @relation(lens.parity, roots.web-signing, roots.web-session, scope=function, role=Verifies) +async fn confirming_an_awaiting_session_transitions_it_to_queued_through_a_signed_post() { + let seed = 23u8; + let state = build_state(FixtureIdentity { + name: "filer", + key: Keypair::from_seed(seed), + }); + let router = ents_web::router(state.clone()); + let id = seed_agent_via_web(&router, &state, "draft a fix").await; + + let key = Keypair::from_seed(seed); + let identity = Identity { + actor: fixture_actor("filer"), + author: None, + sign: &|payload| key.sign(payload), + }; + ents_forge::agent::revise_plan( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + &id, + "do the thing".to_owned(), + &identity, + state.mode, + ) + .expect("revise_plan reaches an outcome"); + + let detail = get_body(&router, &format!("/agents/{id}")).await; + assert!(detail.contains("awaiting confirmation")); + assert!( + detail.contains("Confirm plan"), + "a plan with no current confirm shows the one-tap confirm form" + ); + + let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/agents").await; + let response = router + .clone() + .oneshot( + Request::post(format!("/agents/{id}/confirm")) + .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!( + response.status().is_redirection(), + "{:?}", + response.status() + ); + + let detail = get_body(&router, &format!("/agents/{id}")).await; + assert!(detail.contains("queued")); + assert!( + !detail.contains("Confirm plan"), + "a queued session no longer shows the confirm form" + ); +} + +/// Every one of the six derived states +/// (`docs/agent-sessions-plan.adoc`'s Phase 3: planning, awaiting +/// confirmation, queued, running, done, failed) renders distinctly on the +/// index, the running session's own sandbox name renders verbatim, the +/// done/failed sessions show their result branch/failure detail -- and no +/// session's thread content ever appears on either the index or any +/// detail page, whatever its state. +#[tokio::test] +// @relation(lens.parity, scope=function, role=Verifies) +async fn agents_index_renders_every_derived_state_distinctly_and_never_leaks_thread_content() { + let refs = MemRefStore::default(); + let objects = ObjectStore::default(); + + let base = |member: &str, seconds: i64| { + ents_forge::agent::SessionMeta::new( + MemberId::new(member), + seconds, + "claude-sonnet-5", + vec![], + "refs/heads/main", + ents_forge::agent::ReviewPolicy::Manual, + None, + ) + }; + + let planning = ents_forge::agent::AgentSession { + meta: base("jdc", 100), + plan: None, + confirm: None, + thread: vec![b"thread marker planning".to_vec()], + }; + write_meta_entity( + &refs, + &objects, + ents_model::namespace::agent_session_ref("s-planning").expect("valid"), + &planning, + None, + 100, + ); + + let mut awaiting = ents_forge::agent::AgentSession { + meta: base("jdc", 200), + plan: Some("draft plan".to_owned()), + confirm: None, + thread: vec![b"thread marker awaiting".to_vec()], + }; + awaiting.meta.status = ents_forge::agent::Status::Ready; + write_meta_entity( + &refs, + &objects, + ents_model::namespace::agent_session_ref("s-awaiting").expect("valid"), + &awaiting, + None, + 200, + ); + + let mut queued = ents_forge::agent::AgentSession { + meta: base("jdc", 300), + plan: Some("confirmed plan".to_owned()), + confirm: None, + thread: vec![b"thread marker queued".to_vec()], + }; + queued.meta.status = ents_forge::agent::Status::Ready; + let hash = queued.plan_hash().expect("plan set"); + queued.confirm = Some(ents_forge::agent::Confirm::new( + hash, + ents_forge::agent::ReviewPolicy::Manual, + )); + write_meta_entity( + &refs, + &objects, + ents_model::namespace::agent_session_ref("s-queued").expect("valid"), + &queued, + None, + 300, + ); + + let mut running = ents_forge::agent::AgentSession { + meta: base("jdc", 400), + plan: Some("confirmed plan".to_owned()), + confirm: None, + thread: vec![b"thread marker running".to_vec()], + }; + running.meta.status = ents_forge::agent::Status::Running; + running.meta.sprite = Some("sprite-42".to_owned()); + running.meta.worker = Some(MemberId::new("worker-bot")); + running.meta.started = Some(400); + write_meta_entity( + &refs, + &objects, + ents_model::namespace::agent_session_ref("s-running").expect("valid"), + &running, + None, + 400, + ); + + let mut done = ents_forge::agent::AgentSession { + meta: base("jdc", 500), + plan: Some("confirmed plan".to_owned()), + confirm: None, + thread: vec![b"thread marker done".to_vec()], + }; + done.meta.status = ents_forge::agent::Status::Done; + done.meta.result_branch = Some("agent/jdc/deadbeef".to_owned()); + done.meta.finished = Some(500); + write_meta_entity( + &refs, + &objects, + ents_model::namespace::agent_session_ref("s-done").expect("valid"), + &done, + None, + 500, + ); + + let mut failed = ents_forge::agent::AgentSession { + meta: base("jdc", 600), + plan: Some("confirmed plan".to_owned()), + confirm: None, + thread: vec![b"thread marker failed".to_vec()], + }; + failed.meta.status = ents_forge::agent::Status::Failed(ents_forge::agent::FailureReason { + detail: "sandbox died".to_owned(), + }); + failed.meta.finished = Some(600); + write_meta_entity( + &refs, + &objects, + ents_model::namespace::agent_session_ref("s-failed").expect("valid"), + &failed, + None, + 600, + ); + + let state = build_state_with( + FixtureIdentity { + name: "filer", + key: Keypair::from_seed(24), + }, + refs, + objects, + ); + let router = ents_web::router(state.clone()); + + let index = get_body(&router, "/agents").await; + for label in [ + "planning", + "awaiting confirmation", + "queued", + "running", + "done", + "failed", + ] { + assert!(index.contains(label), "the index shows the {label} state"); + } + for marker in [ + "thread marker planning", + "thread marker awaiting", + "thread marker queued", + "thread marker running", + "thread marker done", + "thread marker failed", + ] { + assert!( + !index.contains(marker), + "thread content must never render on the list page: {marker}" + ); + } + + let running_detail = get_body(&router, "/agents/s-running").await; + assert!( + running_detail.contains("sprite-42"), + "the sandbox name renders verbatim while running" + ); + assert!(!running_detail.contains("thread marker running")); + + let done_detail = get_body(&router, "/agents/s-done").await; + assert!(done_detail.contains("agent/jdc/deadbeef")); + assert!(!done_detail.contains("thread marker done")); + + let failed_detail = get_body(&router, "/agents/s-failed").await; + assert!(failed_detail.contains("sandbox died")); + assert!(!failed_detail.contains("thread marker failed")); +} + +/// The "result record" ref [`crate::pages::agents::show`] displays for a +/// terminal session is derived from the chain's own confirmed-and-queued +/// commit -- exactly what `git-ents::agent_worker::run_agent_exec` reads +/// as its own dispatched oid -- and reads back as "not yet recorded" until +/// a result actually lands at that derived ref, then as recorded once one +/// does. +#[tokio::test] +// @relation(lens.parity, effect.results-writeback, scope=function, role=Verifies) +async fn result_record_ref_is_derived_from_the_chains_own_confirmed_commit() { + let seed = 25u8; + let state = build_state(FixtureIdentity { + name: "filer", + key: Keypair::from_seed(seed), + }); + let router = ents_web::router(state.clone()); + let id = seed_agent_via_web(&router, &state, "ship the fix").await; + + let key = Keypair::from_seed(seed); + let identity = Identity { + actor: fixture_actor("filer"), + author: None, + sign: &|payload| key.sign(payload), + }; + + ents_forge::agent::revise_plan( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + &id, + "do the thing".to_owned(), + &identity, + state.mode, + ) + .expect("revise_plan reaches an outcome"); + ents_forge::agent::confirm( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + &id, + None, + &identity, + state.mode, + ) + .expect("confirm reaches an outcome"); + + let ref_name = ents_model::namespace::agent_session_ref(&id).expect("valid"); + let confirmed_tip = state + .refs + .get(ref_name.as_ref()) + .expect("read") + .expect("confirmed tip exists"); + + ents_forge::agent::claim( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + &id, + ents_forge::agent::ClaimAgentSession { + worker: MemberId::new("worker-bot"), + sprite: "sprite-9".to_owned(), + }, + &identity, + state.mode, + ) + .expect("claim reaches an outcome"); + ents_forge::agent::finish( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + &id, + ents_forge::agent::FinishAgentSession { + outcome: ents_forge::agent::FinishOutcome::Done, + result_branch: Some("agent/filer/deadbeef".to_owned()), + thread: vec![b"final log".to_vec()], + }, + &identity, + state.mode, + ) + .expect("finish reaches an outcome"); + + let expected_short = ents_effect::run::short_oid(confirmed_tip); + let result_ref = + ents_model::namespace::result_ref("agent-exec", &expected_short).expect("valid"); + let result_ref_display = result_ref.as_bstr().to_string(); + + let before = get_body(&router, &format!("/agents/{id}")).await; + assert!(before.contains(&result_ref_display)); + assert!(before.contains("not yet recorded")); + + let record = ResultRecord::new("agent-exec", confirmed_tip, Status::Pass); + ents_receive::propose_entity( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + result_ref.clone(), + &record, + &identity, + "Record agent-exec result", + state.mode, + ) + .expect("reaches an outcome"); + + let after = get_body(&router, &format!("/agents/{id}")).await; + assert!(after.contains(&result_ref_display)); + assert!(!after.contains("not yet recorded")); +}
crates/cli/ents-web/src/assets/sprite.svg @@ -15,4 +15,5 @@ <symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h5l2 2h11v9H3z"/></symbol> <symbol id="i-warn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17h.01"/></symbol> <symbol id="i-editor" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7M9 7h8v8"/></symbol> + <symbol id="i-agent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M12 8V4"/><circle cx="12" cy="3" r="1"/><circle cx="9" cy="14" r="1.1"/><circle cx="15" cy="14" r="1.1"/></symbol> </svg>
crates/cli/ents-web/src/pages/mod.rs @@ -15,14 +15,16 @@ //! [`effects`], [`toolchains`], [`redactions`], and [`inbox`] additionally //! share one `meta` rail item and `META_SECTIONS` rail rather than each //! carrying its own top-level entry (see `Tab`'s own doc); [`meta`] is that -//! group's `GET /meta` landing page. [`commits`] and [`issues`] are rail -//! items of their own -- `Tab::Commits` (Review) and `Tab::Issues` -//! (Issues) in [`layout`]'s icon rail, alongside the dashboard, code, -//! threads, and meta items. [`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. +//! group's `GET /meta` landing page. [`commits`], [`issues`], and +//! [`agents`] are rail items of their own -- `Tab::Commits` (Review), +//! `Tab::Issues` (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. [`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. pub mod account; +pub mod agents; pub mod comments; pub mod commits; pub mod dashboard; @@ -112,7 +114,8 @@ /// the horizontal tab strip became the vertical icon rail, but the /// "handler names its own section" contract is unchanged). The rail reads, /// top to bottom: Dashboard (`Overview`), Code (`Files`), Review -/// (`Commits`), Issues, Threads (`Comments`); then, past the +/// (`Commits`), Issues, Agents (`docs/agent-sessions-plan.adoc`'s Phase 3), +/// Threads (`Comments`); then, past the /// spacer, Repo & governance (`Meta`) and Account. `Meta` covers five page /// families ([`super::members`], [`super::effects`], [`super::toolchains`], /// [`super::redactions`], [`super::inbox`]) behind one rail item and the @@ -126,6 +129,7 @@ Files, Commits, Issues, + Agents, Comments, Meta, Account, @@ -260,8 +264,8 @@ /// The workbench shell itself (the "Proposal C" chrome, /// `docs/web-workbench-plan.adoc`): a `.wb` grid pairing the sticky icon -/// `.rail` (Dashboard / Code / Review / Issues / Threads, then governance -/// and account past the spacer -- see [`Tab`]'s own doc) with a `.wb-main` +/// `.rail` (Dashboard / Code / Review / Issues / Agents / Threads, then +/// governance and account past the spacer -- see [`Tab`]'s own doc) with a `.wb-main` /// column whose sticky `.wb-bar` top bar names the served repository and /// its branch pill, carries the `.palette` search form (a plain GET to /// `/search` for now -- the `⌘K` kbd is a hint at the palette phase, not @@ -296,6 +300,7 @@ (rail_link(active, Tab::Files, "/files", "Code", "i-files")) (rail_link(active, Tab::Commits, "/commits", "Review", "i-commit")) (rail_link(active, Tab::Issues, "/issues", "Issues", "i-issue")) + (rail_link(active, Tab::Agents, "/agents", "Agents", "i-agent")) (rail_link(active, Tab::Comments, "/comments", "Threads", "i-comment")) span.spacer {} (rail_link(active, Tab::Meta, "/meta", "Repo & governance", "i-meta"))
crates/cli/ents-web/src/pages/agents.rs @@ -1,0 +1,706 @@ +//! `GET /agents`, `GET /agents/{id}`, `POST /agents`, `POST +//! /agents/{id}/confirm`: the agent-session surface +//! (`docs/agent-sessions-plan.adoc`'s Phase 3), a top-level tab of its own +//! (`crate::pages::Tab::Agents`; see [`super`]'s own doc) rather than an +//! entry in the `meta` tab's registry -- an agent session is a working +//! surface a member drives, like issues, not repository metadata. +//! +//! Every read is `ents_forge::agent::{list_all,show}` and every mutation is +//! `ents_forge::agent::{new,confirm}` -- the web is another caller of the +//! same library funcs (`lens.parity`), never a second session +//! implementation. No `model.agent-session` spec section exists yet (the +//! plan's own owner item); this module cites only requirement ids that +//! already exist, exactly as `ents_forge::agent`'s own modules do. +//! +//! Thread blobs are never rendered anywhere in this module, only counted -- +//! they are opaque, redactable audit material +//! (`ents_forge::agent::AgentSession`'s own doc), and no raw-blob serving +//! route exists in this crate to link one out to (`crate::pages::files`'s +//! raw-source view serves a working-tree path, not an arbitrary meta-ref +//! blob by oid) -- so a session's detail page names only how many turns its +//! thread carries, never their content. + +use std::sync::Arc; + +use axum::Form; +use axum::extract::{Path, State}; +use axum::response::{IntoResponse, Redirect}; +use ents_forge::agent::{ + self, AgentSession, FailureReason, NewAgentSession, ReviewPolicy, Status as SessionStatus, +}; +use gix::bstr::ByteSlice as _; +use gix_hash::ObjectId; +use gix_object::{CommitRef, Find, Kind, Write}; +use maud::{Markup, html}; +use serde::Deserialize; + +use crate::error::{Error, Result}; +use crate::session::Session; +use crate::state::AppState; + +/// `GET /agents`: the Agents split (`crate::pages::layout_split`) -- every +/// agent session recorded in this repository +/// (`ents_forge::agent::list_all`), newest first, as the sidebar (see +/// [`agents_sidebar`]), beside the start-a-session composer in the pane. +/// +/// # Errors +/// +/// Propagates a ref-store or object read failure. +// @relation(lens.parity, scope=function) +pub async fn list<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, +) -> Result<Markup> +where + O: Find + Write + Send + 'static, +{ + let (mut rows, unreadable) = agent::list_all(state.refs.as_ref(), &*state.objects())?; + rows.sort_by_key(|(_, session)| std::cmp::Reverse(session.meta.created)); + let failures: Vec<(String, String)> = unreadable + .into_iter() + .map(|entry| (entry.refname, entry.error)) + .collect(); + let default_base = default_base_ref(&state); + Ok(super::layout_split( + &super::RepoHeader::from_state(&state), + &super::identity_label(&state), + super::Tab::Agents, + "Agents", + agents_sidebar(&rows, None), + html! { + div.readable { + (crate::render::unreadable_disclosure(&failures)) + @if rows.is_empty() { + (super::blankslate( + "No agent sessions yet", + html! { "Start one with the form below." }, + )) + } + div.card { + div.card-header { "Start an Agent Session" } + (new_form(&session, &default_base)) + } + } + }, + )) +} + +/// The Agents split's `.tree` sidebar: a `.tree-head` naming the family and +/// carrying the "+ New" link into the composer, then every session as a +/// two-line `.side-row` -- its abbreviated id (`.side-title`), then a +/// `.side-meta` locator of its derived-state badge (see [`state_badge`]), +/// its owning member, and how long ago it started -- linking to its own +/// page, `.active` naming the viewed session's id. +fn agents_sidebar(rows: &[(String, AgentSession)], active: Option<&str>) -> Markup { + html! { + div.tree-head { + span { "Agents" } + a.btn.btn-sm.btn-ghost[active.is_some()] href="/agents" { "+ New" } + } + @if rows.is_empty() { + span.tree-note { "No agent sessions yet." } + } + @for (id, session) in rows { + a.side-row.active[active == Some(id.as_str())] href={ "/agents/" (id) } { + span.side-title { (ents_forge::abbreviate_id(id)) } + span.side-meta { + (state_badge(session)) + span.locator { + "@" (session.meta.member.as_str()) + " \u{b7} " + (super::ago(session.meta.created)) + } + } + } + } + } +} + +/// `GET /agents/{id}`: one agent session -- its typed metadata, its plan +/// (rendered as AsciiDoc exactly like an issue's body), a chain-derived +/// status timeline (see [`build_timeline`]), the sandbox name verbatim +/// while running, its result branch and result-record ref (see +/// [`result_branch_cell`]/[`result_record_cell`]), and a one-tap Confirm +/// form when [`ents_forge::agent::AgentSession::awaiting_confirmation`] +/// holds. +/// +/// # Errors +/// +/// [`crate::Error::Forge`] (wrapping [`ents_forge::Error::NotFound`]) if +/// `id` has no session ref at all; an existing ref whose stored tree this +/// build cannot read back degrades to [`crate::render::unreadable`]'s +/// marker card instead of erroring. Otherwise propagates a ref-store or +/// object read failure. +// @relation(lens.parity, scope=function) +pub async fn show<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, + Path(id): Path<String>, +) -> Result<Markup> +where + O: Find + Write + Send + 'static, +{ + let title = format!("Agent session {}", ents_forge::abbreviate_id(&id)); + let found = agent::show(state.refs.as_ref(), &*state.objects(), &id); + let agent_session = match found { + Ok(agent_session) => agent_session, + // No ref at all stays a real not-found; any other failure (a tree + // this build's shape cannot read back) is an existing entity this + // page degrades to the plain unreadable card for. + Err(source @ ents_forge::Error::NotFound { .. }) => return Err(source.into()), + Err(source) => { + return Ok(super::layout( + &super::RepoHeader::from_state(&state), + &super::identity_label(&state), + super::Tab::Agents, + &title, + html! { + (super::child_crumbs("agents", "/agents", ents_forge::abbreviate_id(&id))) + div.readable { (crate::render::unreadable(&source.to_string())) } + }, + )); + } + }; + + let ref_name = ents_model::namespace::agent_session_ref(&id)?; + let timeline = match state.refs.get(ref_name.as_ref())? { + Some(tip) => build_timeline(&*state.objects(), tip), + None => Timeline::default(), + }; + let plan_body = agent_session + .plan + .as_deref() + .map(|plan| crate::asciidoc::to_html(plan).unwrap_or_else(|_| html! { p { (plan) } })); + // Best-effort: the sidebar listing every session beside this one is + // navigation chrome, never a reason to fail the session's own page. + let (rows, _unreadable) = + agent::list_all(state.refs.as_ref(), &*state.objects()).unwrap_or_default(); + + Ok(super::layout_split( + &super::RepoHeader::from_state(&state), + &super::identity_label(&state), + super::Tab::Agents, + &title, + agents_sidebar(&rows, Some(&id)), + html! { + (super::child_crumbs("agents", "/agents", ents_forge::abbreviate_id(&id))) + div.readable { + div.card { + h1.commit-subject { (title) } + dl.entity-view { + dt { "state" } + dd { (state_badge(&agent_session)) } + dt { "member" } + dd { (super::avatar(agent_session.meta.member.as_str())) " @" (agent_session.meta.member.as_str()) } + dt { "model" } + dd { (agent_session.meta.model) } + dt { "base ref" } + dd { code { (agent_session.meta.base_ref) } } + dt { "review policy" } + dd { (agent_session.meta.review_policy.to_string()) } + @if agent_session.meta.status == SessionStatus::Running + && let Some(sprite) = &agent_session.meta.sprite + { + dt { "sandbox" } + dd { code { (sprite) } } + } + @if let Some(branch) = &agent_session.meta.result_branch { + dt { "result branch" } + dd { (result_branch_cell(&state, branch)) } + } + @if matches!(agent_session.meta.status, SessionStatus::Done | SessionStatus::Failed(_)) { + dt { "result record" } + dd { (result_record_cell(&state, timeline.confirmed_oid)) } + } + @if let SessionStatus::Failed(FailureReason { detail }) = &agent_session.meta.status { + dt { "failure" } + dd { (detail) } + } + dt { "thread" } + dd { (agent_session.thread.len()) " turn(s) recorded \u{2014} never rendered" } + } + h2 { "Plan" } + @match &plan_body { + Some(body) => div.doc-body { (body) }, + None => p.muted { "No plan has been drafted yet." }, + } + @if agent_session.awaiting_confirmation() { + (confirm_form(&session, &id)) + } + } + h2 { "Timeline" } + (timeline_section(&timeline)) + } + }, + )) +} + +/// A session's derived display state -- the durable +/// [`ents_forge::agent::SessionMeta::status`] refined by +/// [`AgentSession::queued`]/[`AgentSession::awaiting_confirmation`] into +/// the six distinct states `docs/agent-sessions-plan.adoc`'s Phase 3 +/// names: planning, awaiting confirmation, queued, running, done, failed. +fn session_state(session: &AgentSession) -> &'static str { + match session.meta.status { + SessionStatus::Planning => "planning", + SessionStatus::Ready if session.queued() => "queued", + SessionStatus::Ready => "awaiting confirmation", + SessionStatus::Running => "running", + SessionStatus::Done => "done", + SessionStatus::Failed(_) => "failed", + } +} + +/// The state badge [`agents_sidebar`] and [`show`] both render: a +/// checkmark/cross `.status` chip for the terminal `done`/`failed` states +/// (matching `crate::pages::commits::checks_section`'s identical result +/// taxonomy -- a session's own finish literally records a +/// [`ents_model::Status::Pass`]/`Fail`), or a neutral `.chip.state-in-progress` +/// pill labeled with [`session_state`]'s own word for every state still in +/// flight -- the four non-terminal states share one color, since the +/// label text (not the color) is what tells them apart. +fn state_badge(session: &AgentSession) -> Markup { + let label = session_state(session); + match label { + "done" => html! { span.status.status-pass { "done" } }, + "failed" => html! { span.status.status-fail { "failed" } }, + _ => html! { + span.chip.chip-pill.state-in-progress { + span.dot {} + (label) + } + }, + } +} + +/// One decoded commit of a session ref's own chain -- what [`build_timeline`] +/// walks, read whole so the timeline needs at most one object read per +/// commit rather than the three separate reads `crate::pages::commit_tree`/ +/// `commit_authorship` would together cost. +struct ChainCommit { + /// The commit's own oid. + oid: ObjectId, + /// This commit's sole parent, or `None` at genesis. + parent: Option<ObjectId>, + /// The commit author's display name. + author: String, + /// The commit author's time, in seconds since the Unix epoch. + seconds: i64, + /// The commit's tree -- the session snapshot as of this commit. + tree: ObjectId, +} + +/// Read and decode the commit at `oid`, or `None` on any read/parse +/// failure -- best-effort, since a timeline's own read failure must never +/// fail the session's page (mirrors this crate's "never a 500" degradation +/// elsewhere, e.g. [`crate::render::unreadable`]). +fn read_chain_commit(objects: &impl Find, oid: ObjectId) -> Option<ChainCommit> { + let mut buf = Vec::new(); + let data = objects.try_find(&oid, &mut buf).ok()??; + if data.kind != Kind::Commit { + return None; + } + let commit = CommitRef::from_bytes(data.data, oid.kind()).ok()?; + let author = commit.author().ok()?; + let seconds = author.time().map(|time| time.seconds).unwrap_or(0); + Some(ChainCommit { + oid, + parent: commit.parents().next(), + author: author.name.to_str_lossy().into_owned(), + seconds, + tree: commit.tree(), + }) +} + +/// Walk `tip`'s own single-parent chain back to genesis, oldest first -- +/// every entity-mutation commit this codebase writes +/// (`ents_receive::propose_entity`) carries exactly one parent, so this is +/// a plain linear walk, not a full revision graph traversal. Stops early, +/// silently, on the first commit [`read_chain_commit`] cannot decode +/// (best-effort; see that function's own doc). +fn walk_session_chain(objects: &impl Find, tip: ObjectId) -> Vec<ChainCommit> { + let mut chain = Vec::new(); + let mut cursor = Some(tip); + while let Some(oid) = cursor { + let Some(commit) = read_chain_commit(objects, oid) else { + break; + }; + cursor = commit.parent; + chain.push(commit); + } + chain.reverse(); + chain +} + +/// One status-change entry in a session's timeline: the derived state (see +/// [`session_state`]) as of the commit where it first took that value, and +/// that commit's own author/time. +struct TimelineEntry { + /// The derived state this entry announces. + label: &'static str, + /// The commit author's display name. + author: String, + /// The commit author's time, in seconds since the Unix epoch. + seconds: i64, +} + +/// A session's chain-derived status timeline (see [`TimelineEntry`]), +/// plus the one oid [`result_record_cell`] needs: the commit that made the +/// session queued immediately before the first commit that claimed it -- +/// the exact commit `git-ents::agent_worker::run_agent_exec` reads as its +/// own dispatched `oid`, which is what the `agent-exec` result ref's +/// short-oid segment is computed from. +#[derive(Default)] +struct Timeline { + /// The timeline's own entries, oldest first. + entries: Vec<TimelineEntry>, + /// The confirmed-and-queued commit that preceded the first `running` + /// entry, if the chain ever reached `running` at all. + confirmed_oid: Option<ObjectId>, +} + +/// Build `tip`'s [`Timeline`]: walk its chain (see [`walk_session_chain`]), +/// decode each commit's own [`AgentSession`] snapshot, and emit one +/// [`TimelineEntry`] per [`session_state`] change -- a run of commits that +/// keep the same derived state (a plan redraft that lands back on the same +/// word, for instance) collapses to the one entry that state's first +/// commit already opened. Stops early, silently, on the first commit whose +/// tree cannot be decoded as an [`AgentSession`] (best-effort; the +/// timeline built from what came before it is still shown). +// @relation(scope=function) +fn build_timeline(objects: &impl Find, tip: ObjectId) -> Timeline { + let mut entries = Vec::new(); + let mut confirmed_oid = None; + let mut previous_label: Option<&'static str> = None; + let mut previous_oid: Option<ObjectId> = None; + for commit in walk_session_chain(objects, tip) { + let Ok(decoded) = facet_git_tree::deserialize::<AgentSession>(&commit.tree, objects) else { + break; + }; + let label = session_state(&decoded); + if previous_label != Some(label) { + if label == "running" { + confirmed_oid = previous_oid; + } + entries.push(TimelineEntry { + label, + author: commit.author.clone(), + seconds: commit.seconds, + }); + previous_label = Some(label); + } + previous_oid = Some(commit.oid); + } + Timeline { + entries, + confirmed_oid, + } +} + +/// The "Timeline" card [`show`] renders: one row per [`TimelineEntry`], a +/// state badge, its author, and how long ago (see [`super::ago`]) -- the +/// same `.card`/`.card-row` shape +/// `crate::pages::commits::checks_section` uses for its own per-row list. +fn timeline_section(timeline: &Timeline) -> Markup { + if timeline.entries.is_empty() { + return super::blankslate( + "No timeline yet", + html! { "This session's chain could not be read." }, + ); + } + html! { + div.card { + @for entry in &timeline.entries { + div.card-row { + span.chip.chip-pill { (entry.label) } + " " + span.muted { (entry.author) } + span.entry-size { (super::ago(entry.seconds)) } + } + } + } + } +} + +/// The "result branch" cell [`show`] renders: a link into +/// `/commit/{oid}` when `branch`'s `refs/heads/<branch>` ref still +/// resolves in this ref store (the ordinary `/commit` page is the closest +/// thing this crate has to "view a branch," since neither +/// `crate::pages::files` nor `crate::pages::commits` browses anything but +/// `HEAD`), or the bare branch name when it does not -- a self-run +/// deployment topology this build's ref store does not carry, or a run +/// that failed before ever pushing one. +fn result_branch_cell<O: Find>(state: &AppState<O>, branch: &str) -> Markup { + let tip = format!("refs/heads/{branch}") + .try_into() + .ok() + .and_then(|name: gix::refs::FullName| state.refs.get(name.as_ref()).ok().flatten()); + match tip { + Some(oid) => html! { a href={ "/commit/" (oid) } { code { (branch) } } }, + None => html! { code { (branch) } }, + } +} + +/// The "result record" cell [`show`] renders for a terminal session: the +/// canonical `refs/meta/results/agent-exec/<short-oid>` ref name (no +/// dedicated results page exists in this crate to link one out to, so this +/// is the ref-name fallback `docs/agent-sessions-plan.adoc`'s Phase 3 +/// itself calls for), derived from [`Timeline::confirmed_oid`] exactly as +/// `git-ents::agent_worker::run_agent_exec` derives it, plus a live check +/// of whether that ref has actually landed yet. +fn result_record_cell<O: Find>(state: &AppState<O>, confirmed_oid: Option<ObjectId>) -> Markup { + let Some(confirmed_oid) = confirmed_oid else { + return html! { span.muted { "not derivable from this session's own chain" } }; + }; + let short = ents_effect::run::short_oid(confirmed_oid); + let Ok(name) = ents_model::namespace::result_ref("agent-exec", &short) else { + return html! { span.muted { "not derivable from this session's own chain" } }; + }; + let recorded = state.refs.get(name.as_ref()).ok().flatten().is_some(); + html! { + code { (name.as_bstr().to_string()) } + @if !recorded { + span.muted { " (not yet recorded)" } + } + } +} + +/// The one-tap Confirm form (`POST /agents/{id}/confirm`): rendered only +/// while [`AgentSession::awaiting_confirmation`] holds -- a plain button, +/// no fields but the CSRF token, so confirming from a phone is a single +/// tap (`docs/agent-sessions-plan.adoc`'s Phase 3, "mobile-critical"). +fn confirm_form(session: &Session, id: &str) -> Markup { + html! { + form method="post" action=(format!("/agents/{id}/confirm")) { + (super::csrf_input(session)) + button type="submit" { "Confirm plan" } + } + } +} + +/// `POST /agents/{id}/confirm`: confirm `id`'s current plan +/// (`ents_forge::agent::confirm`), binding its hash and queueing it for +/// execution. The review policy is never overridden from this one-tap +/// form -- it stays whatever [`ents_forge::agent::SessionMeta::review_policy`] +/// already resolved to. +/// +/// # Errors +/// +/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; otherwise +/// propagates [`ents_forge::agent::confirm`]'s own failures (including +/// [`ents_forge::Error::NotFound`] and the "not ready to confirm" +/// precondition miss). +// @relation(roots.web-signing, roots.web-session, lens.parity, scope=function) +pub async fn confirm<O>( + State(state): State<Arc<AppState<O>>>, + axum::Extension(session): axum::Extension<Session>, + Path(id): Path<String>, + Form(form): Form<ConfirmForm>, +) -> Result<impl IntoResponse> +where + O: Find + Write + Send + 'static, +{ + super::require_csrf(&session, &form.csrf)?; + let identity = state.identity.as_ref(); + let outcome = agent::confirm( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + &id, + None, + &crate::receive_identity!(identity, crate::pages::member_author(&session)), + state.mode, + )?; + crate::error::outcome_to_result(outcome)?; + Ok(Redirect::to(&format!("/agents/{id}"))) +} + +/// The form fields `POST /agents/{id}/confirm` accepts. +#[derive(Debug, Deserialize)] +pub struct ConfirmForm { + /// The per-session CSRF token (`roots.web-session`). + csrf: String, +} + +/// 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 { + 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); } + div { + p.muted { "review policy" } + div.picker { + label.opt.active { + input type="radio" name="review_policy" value="manual" checked hidden; + span.dot {} + "manual" + } + label.opt { + input type="radio" name="review_policy" value="auto" hidden; + span.dot {} + "auto" + } + } + } + div.composer-buttons { + a.composer-cancel href="/agents" { "Cancel" } + button type="submit" { "Start Session" } + } + } + } +} + +/// 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. +const DEFAULT_MODEL: &str = "claude-sonnet-5"; + +/// 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 +/// be resolved (a detached or unborn `HEAD`) -- the same fallback +/// [`default_base_ref_field`] uses for a submission that omits the field. +fn default_base_ref<O>(state: &AppState<O>) -> String { + match super::RepoHeader::from_state(state).branch { + Some(branch) => format!("refs/heads/{branch}"), + None => default_base_ref_field(), + } +} + +/// The `base_ref` form field's fallback default (see [`NewForm::base_ref`]) +/// when a submission omits it entirely -- a plain text input the browser +/// always sends filled in practice ([`new_form`] pre-fills it from +/// [`default_base_ref`]), so this only ever matters for a hand-built +/// request. +fn default_base_ref_field() -> String { + "HEAD".to_owned() +} + +/// The `review_policy` form field's fallback default (see +/// [`NewForm::review_policy`]) -- `manual`, matching [`new_form`]'s own +/// picker default and `ents_forge::agent::cli::AgentAction::New`'s +/// identical CLI default. +fn default_review_policy() -> String { + "manual".to_owned() +} + +/// The form fields `POST /agents` accepts. +#[derive(Debug, Deserialize)] +pub struct NewForm { + /// The initial task prompt, seeded verbatim as the thread's first + /// turn. + prompt: String, + /// The ref the run executes against as its starting point; defaults to + /// the served repository's own current branch (see + /// [`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 session's initially resolved review policy: `auto` or `manual`; + /// defaults to `manual` (see [`default_review_policy`]). + #[serde(default = "default_review_policy")] + review_policy: String, + /// The per-session CSRF token (`roots.web-session`). + 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 +/// (`roots.web-session`). Carries no toolchain selection -- every session +/// this form starts depends on no pinned toolchain, same as the bare +/// `git ents agent new` invocation with no `--toolchain` flags. +/// +/// # Errors +/// +/// [`crate::Error::BadCsrf`] if `form.csrf` does not match; +/// [`crate::Error::InvalidArgument`] if `form.review_policy` is not `auto` +/// or `manual`; otherwise propagates [`ents_forge::agent::new`]'s own +/// failures. +// @relation(meta-ref.identity-binding, 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<NewForm>, +) -> Result<impl IntoResponse> +where + O: Find + Write + Send + 'static, +{ + super::require_csrf(&session, &form.csrf)?; + let member = session_owner(&state); + let identity = state.identity.as_ref(); + let new = NewAgentSession { + member, + prompt: form.prompt, + model: form.model, + toolchains: Vec::new(), + base_ref: form.base_ref, + review_policy: form + .review_policy + .parse::<ReviewPolicy>() + .map_err(|_source| { + Error::InvalidArgument(format!("unknown review policy: {}", form.review_policy)) + })?, + retry_of: None, + }; + let (id, outcome) = agent::new( + state.refs.as_ref(), + &*state.objects(), + state.events.as_ref(), + new, + &crate::receive_identity!(identity, crate::pages::member_author(&session)), + state.mode, + )?; + crate::error::outcome_to_result(outcome)?; + Ok(Redirect::to(&format!("/agents/{id}"))) +} + +/// The acting session's member id -- the new session's own owning member +/// -- resolved the same way `crate::pages::commits::reviewer_member_id` +/// does, falling back to a short hash of the public key when no enrolled +/// member matches (duplicated rather than shared for the same reason that +/// copy's own doc and `git_ents::commands::agent::session_owner`'s both +/// give: this crate accepts one small per-module copy over a shared +/// helper for a lookup this conceptually distinct across call sites). +fn session_owner<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 +/// `crate::pages::commits::short_key_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 + } +}