From 32e39ff14b5bd510c800a9bb165274a801bfeb1d Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 17 Oct 2026 07:41:21 +0000 Subject: [PATCH 1/4] test(parity): cover the WI-1074 session ops in the script (WI-1074) Adds engine-down parity steps for the session grant, history, bind_work, last_reply, search_output, blocked report, changes, wait_any, commit_for, economics and digest operations, and for agent_activity search and summary. The golden is not re-recorded here: the 21c95f6 golden needs one serialized re-record before `parity.py check` passes on these steps. Co-Authored-By: Claude Haiku 5.5 Signed-off-by: sprooty --- tests/parity/script.json | 347 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) diff --git a/tests/parity/script.json b/tests/parity/script.json index fcd856c9..9f67bdf3 100644 --- a/tests/parity/script.json +++ b/tests/parity/script.json @@ -1175,6 +1175,353 @@ } } }, + { + "operation": "session.grant_request", + "cli": [ + "session", + "grant-request" + ], + "params": { + "target": "ses_00000000000000000000000000", + "kind": "credential", + "secret_name": "PARITY_TOKEN", + "project_id": "parity-project", + "uses": "once", + "reason": "parity" + }, + "expect": { + "exit": 1 + }, + "http": { + "method": "POST", + "path": "/api/sessions/grants", + "auth": "admin", + "body": { + "target": "ses_00000000000000000000000000", + "kind": "credential", + "secret_name": "PARITY_TOKEN", + "project_id": "parity-project", + "uses": "once", + "reason": "parity" + } + } + }, + { + "operation": "session.grant_decide", + "cli": [ + "session", + "grant-decide" + ], + "params": { + "id": "grt_00000000000000000000000000", + "decision": "approve", + "reason": "parity" + }, + "expect": { + "exit": 1 + }, + "http": { + "method": "POST", + "path": "/api/sessions/grants/decide", + "auth": "admin", + "body": { + "id": "grt_00000000000000000000000000", + "decision": "approve", + "reason": "parity" + } + } + }, + { + "operation": "session.grant_revoke", + "cli": [ + "session", + "grant-revoke" + ], + "params": { + "id": "grt_00000000000000000000000000", + "reason": "parity" + }, + "expect": { + "exit": 1 + }, + "http": { + "method": "POST", + "path": "/api/sessions/grants/revoke", + "auth": "admin", + "body": { + "id": "grt_00000000000000000000000000", + "reason": "parity" + } + } + }, + { + "operation": "session.grant_list", + "cli": [ + "session", + "grants" + ], + "params": { + "state": "pending" + }, + "expect": { + "exit": 0 + }, + "http": { + "method": "GET", + "path": "/api/sessions/grants?state=pending", + "auth": "admin" + } + }, + { + "operation": "session.history_list", + "cli": [ + "session", + "history" + ], + "params": { + "limit": 5 + }, + "expect": { + "exit": 0 + }, + "http": { + "method": "GET", + "path": "/api/sessions/history?limit=5", + "auth": "admin" + } + }, + { + "operation": "session.bind_work", + "cli": [ + "session", + "bind" + ], + "params": { + "id": "ses_00000000000000000000000000", + "work_item": "WI-1074", + "reason": "parity" + }, + "expect": { + "exit": 1 + }, + "http": { + "method": "POST", + "path": "/api/sessions/work-item", + "auth": "admin", + "body": { + "id": "ses_00000000000000000000000000", + "work_item": "WI-1074", + "reason": "parity" + } + } + }, + { + "operation": "session.last_reply", + "cli": [ + "session", + "last-reply" + ], + "params": { + "id": "ses_00000000000000000000000000" + }, + "expect": { + "exit": 1 + }, + "http": { + "method": "GET", + "path": "/api/sessions/last-reply?id=ses_00000000000000000000000000", + "auth": "admin" + } + }, + { + "operation": "session.search_output", + "cli": [ + "session", + "search" + ], + "params": { + "q": "parity" + }, + "expect": { + "exit": 0 + }, + "http": { + "method": "GET", + "path": "/api/sessions/history/search?q=parity", + "auth": "admin" + } + }, + { + "operation": "session.report_blocked", + "cli": [ + "session", + "blocked" + ], + "params": { + "id": "ses_00000000000000000000000000", + "blocker": "parity needs a person", + "reason": "parity" + }, + "expect": { + "exit": 1 + }, + "http": { + "method": "POST", + "path": "/api/sessions/blocked", + "auth": "admin", + "body": { + "id": "ses_00000000000000000000000000", + "blocker": "parity needs a person", + "reason": "parity" + } + } + }, + { + "operation": "session.report_unblocked", + "cli": [ + "session", + "unblocked" + ], + "params": { + "id": "ses_00000000000000000000000000", + "reason": "parity" + }, + "expect": { + "exit": 1 + }, + "http": { + "method": "POST", + "path": "/api/sessions/unblocked", + "auth": "admin", + "body": { + "id": "ses_00000000000000000000000000", + "reason": "parity" + } + } + }, + { + "operation": "session.changes", + "params": { + "since": 0, + "limit": 10 + }, + "expect": { + "exit": 1 + }, + "http": { + "method": "GET", + "path": "/api/sessions/changes?since=0&limit=10", + "auth": "admin" + } + }, + { + "operation": "session.wait_any", + "cli": [ + "session", + "wait-any" + ], + "params": { + "timeout_s": 1, + "cursor": 0 + }, + "expect": { + "exit": 1 + }, + "http": { + "method": "GET", + "path": "/api/sessions/wait-any?timeout_s=1&cursor=0", + "auth": "admin" + } + }, + { + "operation": "session.commit_for", + "cli": [ + "session", + "commit" + ], + "params": { + "id": "ses_00000000000000000000000000", + "message": "parity commit", + "reason": "parity" + }, + "expect": { + "exit": 1 + }, + "http": { + "method": "POST", + "path": "/api/sessions/commit", + "auth": "admin", + "body": { + "id": "ses_00000000000000000000000000", + "message": "parity commit", + "reason": "parity" + } + } + }, + { + "operation": "session.economics", + "params": { + "id": "ses_00000000000000000000000000" + }, + "expect": { + "exit": 1 + }, + "http": { + "method": "GET", + "path": "/api/sessions/economics?id=ses_00000000000000000000000000", + "auth": "admin" + } + }, + { + "operation": "session.digest", + "params": { + "id": "ses_00000000000000000000000000" + }, + "expect": { + "exit": 1 + }, + "http": { + "method": "GET", + "path": "/api/sessions/digest?id=ses_00000000000000000000000000", + "auth": "admin" + } + }, + { + "operation": "agent_activity.search", + "cli": [ + "agent-activity", + "search" + ], + "params": { + "limit": 5 + }, + "expect": { + "exit": 0 + }, + "http": { + "method": "GET", + "path": "/api/agent-activity?limit=5", + "auth": "admin" + } + }, + { + "operation": "agent_activity.summary", + "cli": [ + "agent-activity", + "summary" + ], + "params": { + "economics": true + }, + "expect": { + "exit": 0 + }, + "http": { + "method": "GET", + "path": "/api/agent-activity/summary?economics=true", + "auth": "admin" + } + }, { "operation": "label.create", "http": { From a3264078166125aca7579c2ab6731938111d2e72 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 17 Oct 2026 08:10:42 +0000 Subject: [PATCH 2/4] test(core): port the grant, last-reply and bind-work session tests (WI-1074) WIP checkpoint. Adds the missing grant invariant tests against a stand-in engine (refusal stays pending, overseer-only asks, requester is the principal, self-nomination refused, rollback and race on approval, the one named credential, TTL expiry, revoke, audit by name, inbox entry), a cfg(test) record seam in grants.rs, list-level last-reply excerpt and running-model tests, and the session side of bind_work (rebind/unbind, declared branch, child and self binds, refusals, engine label refused, unlinked binding). Parity step item (real grant for decide/revoke) is not done yet. Golden not re-recorded. Co-Authored-By: Claude Haiku 5.5 Signed-off-by: sprooty --- engine/core/src/application/grants.rs | 522 ++++++++++++++++++- engine/core/src/application/session_reads.rs | 115 ++++ engine/core/src/application/session_work.rs | 395 +++++++++++++- 3 files changed, 1029 insertions(+), 3 deletions(-) diff --git a/engine/core/src/application/grants.rs b/engine/core/src/application/grants.rs index 958a4641..a139e19d 100644 --- a/engine/core/src/application/grants.rs +++ b/engine/core/src/application/grants.rs @@ -427,6 +427,28 @@ fn load( .ok_or_else(|| VogtError::NotFound(format!("no grant {}", crate::core::py_repr(grant_id)))) } +#[cfg(test)] +type RecordHook = Box Result<(), VogtError>>; + +#[cfg(test)] +thread_local! { + /// Test seam: runs once, just before a decision is recorded, so a test can + /// make the record fail or let another person's decision land first. + static BEFORE_RECORD: std::cell::RefCell> = std::cell::RefCell::new(None); +} + +/// Runs the test seam, when a test has armed one. +fn before_record() -> Result<(), VogtError> { + #[cfg(test)] + { + let hook = BEFORE_RECORD.with(|slot| slot.borrow_mut().take()); + if let Some(hook) = hook { + return hook(); + } + } + Ok(()) +} + fn record_decision( ctx: &AppContext, grant: &SessionGrant, @@ -435,6 +457,7 @@ fn record_decision( decided_at: Option, expires_at: Option, ) -> Result { + before_record()?; let grant_id = grant.id.clone(); let reason = reason.to_string(); let mut write = write_of(ctx); @@ -605,14 +628,14 @@ fn clock_now(clock: &std::sync::Arc>) -> Moment { mod tests { use std::sync::{Arc, Mutex}; - use serde_json::json; + use serde_json::{json, Value}; use crate::adapters::engine::EngineClient; use crate::application::context::build_context; use crate::application::writes::audited_write; use crate::core::{ActorKind, CodingSession, Moment, Principal, Project, StepClock}; use crate::errors::VogtError; - use crate::storage::interface::WriteTxn; + use crate::storage::interface::{DeclaredStore, ReadView, WriteTxn}; /// A context whose engine answers every session lookup as alive and records /// every request it was sent. @@ -879,4 +902,499 @@ mod tests { let asked = super::grant_request_op(&built, request("ses_0001")).unwrap(); assert_eq!(asked["grant"]["target"], "eng-9"); } + + // -- the rest of the grant invariants, against a stand-in engine that keeps + // each session's role and liveness and records what it was sent -------- + + const BASE: i64 = 1_700_000_000; + const SECRET: &str = "100.109.218.11_SSH"; + const WHY: &str = "grant test"; + + #[derive(Default)] + struct StandIn { + /// Engine session id to (alive, role). + sessions: std::collections::BTreeMap, + /// Projects the engine refuses, as one not open to grants is. + closed_projects: std::collections::BTreeSet, + applied: Vec<(String, Value)>, + revoked: Vec<(String, String)>, + } + + type Shared = Arc>; + + fn engine_row(id: &str, alive: bool, role: &str) -> Value { + json!({ + "id": id, + "name": id, + "activity": if alive { "idle" } else { "exited" }, + "alive": alive, + "exit_code": if alive { Value::Null } else { json!(0) }, + "role": role, + "cwd": "/srv", + }) + } + + fn stand_in(shared: &Shared) -> EngineClient { + let shared = Arc::clone(shared); + EngineClient::new( + "http://engine", + None, + Some(Arc::new(move |path, _, body, method| { + let route = path.split("/api/").nth(1).unwrap_or(path).to_string(); + let payload: Value = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(body).unwrap_or(Value::Null) + }; + let mut state = shared.lock().unwrap(); + let parts: Vec<&str> = route.split('/').collect(); + match (method, parts.as_slice()) { + ("GET", ["sessions"]) => { + let rows: Vec = state + .sessions + .iter() + .map(|(id, (alive, role))| engine_row(id, *alive, role)) + .collect(); + (200, serde_json::to_vec(&rows).unwrap()) + } + ("GET", ["sessions", id]) => match state.sessions.get(*id) { + Some((alive, role)) => ( + 200, + serde_json::to_vec(&engine_row(id, *alive, role)).unwrap(), + ), + None => (404, Vec::new()), + }, + ("POST", ["sessions", id, "grants"]) => { + let closed = payload["project_id"] + .as_str() + .is_some_and(|project| state.closed_projects.contains(project)); + if closed { + let refusal = + json!({"error": "forbidden: project is not open to grants"}); + return (403, serde_json::to_vec(&refusal).unwrap()); + } + state.applied.push((id.to_string(), payload)); + (200, body.to_vec()) + } + ("DELETE", ["sessions", id, "grants", grant]) => { + state.revoked.push((id.to_string(), grant.to_string())); + (200, br#"{"revoked":true}"#.to_vec()) + } + _ => (404, Vec::new()), + } + })), + ) + } + + /// A Vogt instance with the four engine sessions the invariants name, and + /// the person's context over it. `over` makes another principal's. + fn estate( + name: &str, + ) -> ( + std::path::PathBuf, + Shared, + crate::application::context::Built, + ) { + let dir = std::env::temp_dir().join(format!("vogt-grant-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut clock = None; + let mut ids = None; + crate::application::instance::init(&dir, &mut clock, &mut ids).unwrap(); + let mut sessions = std::collections::BTreeMap::new(); + sessions.insert("eng-worker".to_string(), (true, "worker".to_string())); + sessions.insert("eng-other".to_string(), (true, "worker".to_string())); + sessions.insert("eng-overseer".to_string(), (true, "oversight".to_string())); + sessions.insert("eng-gone".to_string(), (false, "worker".to_string())); + let shared: Shared = Arc::new(Mutex::new(StandIn { + sessions, + ..StandIn::default() + })); + let built = over(&dir, person(), &shared, BASE); + (dir, shared, built) + } + + fn over( + dir: &std::path::Path, + principal: Principal, + shared: &Shared, + start: i64, + ) -> crate::application::context::Built { + build_context( + crate::config::VogtConfig { + data_dir: dir.to_path_buf(), + ..crate::config::VogtConfig::default() + }, + Some(principal), + Some(StepClock::new(Moment::from_unix(start, 0))), + None, + None, + Some(stand_in(shared)), + None, + None, + ) + .unwrap() + } + + fn try_ask( + built: &crate::application::context::Built, + target: &str, + overrides: Value, + ) -> Result { + let mut params = json!({ + "reason": "the worker needs the emulator key", + "target": target, + "kind": "credential", + "uses": "once", + "ttl_seconds": 60, + "secret_name": SECRET, + "project_id": "infra", + }); + for (key, value) in overrides.as_object().unwrap() { + params[key] = value.clone(); + } + super::grant_request_op(built, params) + } + + fn ask(built: &crate::application::context::Built, target: &str, overrides: Value) -> String { + try_ask(built, target, overrides).unwrap()["grant"]["id"] + .as_str() + .unwrap() + .to_string() + } + + fn decide( + built: &crate::application::context::Built, + id: &str, + decision: &str, + ) -> Result { + super::grant_decide_op( + built, + json!({"id": id, "decision": decision, "reason": WHY}), + ) + } + + fn revoke(built: &crate::application::context::Built, id: &str) -> Result { + super::grant_revoke_op(built, json!({"id": id, "reason": WHY})) + } + + fn list(built: &crate::application::context::Built, state: Option<&str>) -> Value { + let mut params = json!({"limit": 50}); + if let Some(state) = state { + params["state"] = json!(state); + } + super::grant_list_op(built, params).unwrap() + } + + fn agent_named(identity: &str) -> Principal { + Principal::new(identity, ActorKind::Agent, "agent").unwrap() + } + + fn overseer_of(dir: &std::path::Path, shared: &Shared) -> crate::application::context::Built { + over(dir, agent_named("agent:engine:eng-overseer"), shared, BASE) + } + + #[test] + fn an_engine_refusal_leaves_the_request_pending_with_its_reason() { + let (dir, shared, built) = estate("refusal"); + shared + .lock() + .unwrap() + .closed_projects + .insert("infra".to_string()); + let id = ask(&overseer_of(&dir, &shared), "eng-worker", json!({})); + let refused = decide(&built, &id, "approve").unwrap_err(); + assert!( + matches!(refused, VogtError::GrantRefused(ref message) if message.contains("not open to grants")), + "{refused:?}" + ); + assert_eq!(list(&built, None)["grants"][0]["state"], "pending"); + let denied = decide(&built, &id, "deny").unwrap(); + assert_eq!(denied["grant"]["state"], "denied"); + assert!(shared.lock().unwrap().applied.is_empty()); + } + + #[test] + fn only_an_overseer_asks_on_another_sessions_behalf() { + let (dir, shared, built) = estate("behalf"); + let worker_other = over(&dir, agent_named("agent:engine:eng-other"), &shared, BASE); + let refused = try_ask(&worker_other, "eng-worker", json!({})).unwrap_err(); + assert!( + matches!(refused, VogtError::GrantRefused(ref message) if message.contains("only an oversight session")), + "{refused:?}" + ); + // Its own session is fine; so is an overseer for a worker, and a person. + let worker = over(&dir, agent_named("agent:engine:eng-worker"), &shared, BASE); + ask(&worker, "eng-worker", json!({})); + ask(&overseer_of(&dir, &shared), "eng-worker", json!({})); + ask(&built, "eng-other", json!({})); + // An agent that is not a session at all, the pod's token, cannot ask. + let pod = over(&dir, agent_named("agent:pod:vogt"), &shared, BASE); + let not_a_session = try_ask(&pod, "eng-worker", json!({})).unwrap_err(); + assert!( + matches!(not_a_session, VogtError::GrantRefused(ref message) if message.contains("not a session")), + "{not_a_session:?}" + ); + assert!(shared.lock().unwrap().applied.is_empty()); + } + + #[test] + fn the_requester_is_the_principal_not_a_parameter() { + let (dir, shared, built) = estate("requester"); + ask(&overseer_of(&dir, &shared), "eng-worker", json!({})); + let rows = list(&built, None); + assert_eq!( + rows["grants"][0]["requested_by"], + "agent:engine:eng-overseer" + ); + } + + #[test] + fn an_agent_cannot_make_itself_an_overseer() { + let (dir, shared, _) = estate("self-overseer"); + for who in ["eng-other", "eng-worker"] { + let agent_ctx = over( + &dir, + agent_named(&format!("agent:engine:{who}")), + &shared, + BASE, + ); + let refused = crate::registry::default_registry() + .get("session.set_role") + .expect("session.set_role is registered") + .run( + Some(&agent_ctx), + json!({"id": who, "role": "oversight", "reason": WHY}), + ) + .unwrap_err(); + assert!( + matches!(refused, VogtError::RoleRefused(ref message) if message.contains("only a person nominates")), + "{refused:?}" + ); + } + let agent_ctx = over(&dir, agent_named("agent:engine:eng-other"), &shared, BASE); + let refused = try_ask(&agent_ctx, "eng-worker", json!({})).unwrap_err(); + assert!( + matches!(refused, VogtError::GrantRefused(ref message) if message.contains("only an oversight session")), + "{refused:?}" + ); + } + + #[test] + fn a_failed_approval_takes_the_grant_back_unless_another_person_approved() { + let (dir, shared, built) = estate("rollback"); + let id = ask(&overseer_of(&dir, &shared), "eng-worker", json!({})); + + // The record fails after the engine has applied the grant. + super::BEFORE_RECORD.with(|slot| { + *slot.borrow_mut() = Some(Box::new(|| { + Err(VogtError::InvalidRequest( + "the record could not be written".to_string(), + )) + })) + }); + let failed = decide(&built, &id, "approve").unwrap_err(); + assert!(matches!(failed, VogtError::InvalidRequest(_)), "{failed:?}"); + assert_eq!( + shared.lock().unwrap().revoked, + vec![("eng-worker".to_string(), id.clone())] + ); + assert_eq!(list(&built, None)["grants"][0]["state"], "pending"); + + // Two people approve at once: the other's approval lands first and + // stands at the engine, so nothing is taken back. + shared.lock().unwrap().revoked.clear(); + let grace = over( + &dir, + Principal::new("local:grace", ActorKind::Human, "Grace").unwrap(), + &shared, + BASE, + ); + let racing = id.clone(); + super::BEFORE_RECORD.with(|slot| { + *slot.borrow_mut() = Some(Box::new(move || { + decide(&grace, &racing, "approve").map(|_| ()) + })) + }); + let lost = decide(&built, &id, "approve").unwrap_err(); + assert!(matches!(lost, VogtError::Conflict(_)), "{lost:?}"); + assert!( + shared.lock().unwrap().revoked.is_empty(), + "the other person's approval was not taken back" + ); + assert_eq!(list(&built, None)["grants"][0]["state"], "approved"); + } + + #[test] + fn a_grant_is_one_named_credential_for_a_live_session() { + let (_, _, built) = estate("one-credential"); + let capability = try_ask( + &built, + "eng-worker", + json!({"kind": "capability", "capability": "bypass"}), + ) + .unwrap_err(); + assert!( + matches!(capability, VogtError::InvalidRequest(ref message) if message.contains("capability grants are not available")), + "{capability:?}" + ); + let no_project = try_ask(&built, "eng-worker", json!({"project_id": null})).unwrap_err(); + assert!( + matches!(no_project, VogtError::InvalidRequest(ref message) if message.contains("names secret_name and project_id")), + "{no_project:?}" + ); + let flag_as_name = + try_ask(&built, "eng-worker", json!({"secret_name": "--all"})).unwrap_err(); + assert!( + matches!(flag_as_name, VogtError::InvalidRequest(ref message) if message.contains("plain names")), + "{flag_as_name:?}" + ); + let bad_var = try_ask(&built, "eng-worker", json!({"var": "not a var"})).unwrap_err(); + assert!( + matches!(bad_var, VogtError::InvalidRequest(ref message) if message.contains("environment variable")), + "{bad_var:?}" + ); + let gone = try_ask(&built, "eng-gone", json!({})).unwrap_err(); + assert!( + matches!(gone, VogtError::Conflict(ref message) if message.contains("not running")), + "{gone:?}" + ); + assert_eq!(super::default_var(SECRET), "GRANT_100_109_218_11_SSH"); + } + + #[test] + fn an_approved_grant_reads_expired_after_its_ttl() { + let (dir, shared, built) = estate("ttl"); + let id = ask(&built, "eng-worker", json!({"ttl_seconds": 60})); + decide(&built, &id, "approve").unwrap(); + let later = over(&dir, person(), &shared, BASE + 120); + let expired = list(&later, Some("expired")); + assert_eq!(expired["grants"].as_array().unwrap().len(), 1); + assert_eq!(expired["grants"][0]["id"], id); + assert!(list(&later, Some("approved"))["grants"] + .as_array() + .unwrap() + .is_empty()); + } + + #[test] + fn revoking_reaches_the_engine_and_only_a_person_or_the_asker_may() { + let (dir, shared, built) = estate("revoke"); + let overseer = overseer_of(&dir, &shared); + let id = ask(&overseer, "eng-worker", json!({"uses": "ttl"})); + decide(&built, &id, "approve").unwrap(); + + let worker = over(&dir, agent_named("agent:engine:eng-worker"), &shared, BASE); + let refused = revoke(&worker, &id).unwrap_err(); + assert!( + matches!(refused, VogtError::GrantRefused(ref message) if message.contains("only a person, or the session that asked")), + "{refused:?}" + ); + let revoked = revoke(&overseer, &id).unwrap(); + assert_eq!(revoked["grant"]["state"], "revoked"); + assert_eq!( + shared.lock().unwrap().revoked, + vec![("eng-worker".to_string(), id.clone())] + ); + + // A pending one is withdrawn without troubling the engine. + let pending = ask(&built, "eng-worker", json!({})); + revoke(&built, &pending).unwrap(); + assert_eq!(shared.lock().unwrap().revoked.len(), 1); + } + + #[test] + fn every_step_is_audited_by_name() { + let (dir, shared, built) = estate("audit"); + let id = ask(&overseer_of(&dir, &shared), "eng-worker", json!({})); + decide(&built, &id, "approve").unwrap(); + revoke(&built, &id).unwrap(); + let mut operations: Vec = crate::with_ctx!(&built, |ctx| { + ctx.declared + .read() + .unwrap() + .list_audit(&crate::storage::interface::AuditQuery { + limit: 20, + offset: 0, + actor_id: None, + operation: None, + entity_id: Some(id.clone()), + project_id: None, + since: None, + until: None, + }) + }) + .unwrap() + .into_iter() + .map(|row| row.operation) + .collect(); + operations.sort(); + assert_eq!( + operations, + [ + "session.grant_decide", + "session.grant_request", + "session.grant_revoke" + ] + ); + } + + #[test] + fn a_pending_grant_is_an_inbox_entry_until_it_is_decided() { + let (dir, shared, built) = estate("inbox"); + let id = ask(&overseer_of(&dir, &shared), "eng-worker", json!({})); + let inbox = |built: &crate::application::context::Built| -> Vec { + crate::registry::default_registry() + .get("inbox.list") + .expect("inbox.list is registered") + .run(Some(built), json!({})) + .unwrap()["entries"] + .as_array() + .unwrap() + .iter() + .filter(|entry| entry["kind"] == "session.grant_request") + .cloned() + .collect() + }; + let entries = inbox(&built); + assert_eq!(entries.len(), 1); + let entry = &entries[0]; + assert_eq!(entry["action"]["kind"], "grant"); + assert_eq!(entry["action"]["grant_id"], id); + assert!(entry["title"].as_str().unwrap().contains(SECRET)); + // The approver sees the target as they know it, and who asks for whom. + assert!(entry["title"] + .as_str() + .unwrap() + .contains("for session eng-worker")); + let summary = entry["summary"].as_str().unwrap(); + assert!( + summary.contains("for session eng-worker (worker)"), + "{summary}" + ); + assert!(summary.contains("agent:engine:eng-overseer"), "{summary}"); + assert!(summary.contains("infra"), "{summary}"); + assert_eq!(entry["actor_login"], "agent:engine:eng-overseer"); + assert_eq!(entry["actor_kind"], "bot"); + assert_eq!(entry["evidence_snapshot"]["target_role"], "worker"); + assert_eq!(entry["evidence_snapshot"]["requester_is_target"], false); + + // A session asking for itself reads as such. + let own = ask( + &over(&dir, agent_named("agent:engine:eng-other"), &shared, BASE), + "eng-other", + json!({}), + ); + let self_entry = inbox(&built) + .into_iter() + .find(|entry| entry["action"]["grant_id"] == own) + .expect("the self-request is an entry"); + let self_summary = self_entry["summary"].as_str().unwrap(); + assert!(self_summary.contains("asks for") && self_summary.contains("for itself")); + assert_eq!(self_entry["evidence_snapshot"]["requester_is_target"], true); + + decide(&built, &own, "deny").unwrap(); + decide(&built, &id, "deny").unwrap(); + assert!(inbox(&built).is_empty()); + } } diff --git a/engine/core/src/application/session_reads.rs b/engine/core/src/application/session_reads.rs index c5478dee..b9be24f9 100644 --- a/engine/core/src/application/session_reads.rs +++ b/engine/core/src/application/session_reads.rs @@ -722,4 +722,119 @@ mod tests { .starts_with("no agent transcript found")); let _ = fs::remove_dir_all(&dir); } + + /// One Claude Code assistant line, with the model the turn ran on when given. + fn claude_line(message_id: &str, text: &str, model: Option<&str>) -> String { + let mut message = json!({ + "id": message_id, + "content": [{"type": "text", "text": text}], + }); + if let Some(model) = model { + message["model"] = json!(model); + } + json!({ + "type": "assistant", + "timestamp": "2024-01-02T03:04:05Z", + "message": message, + }) + .to_string() + } + + /// The engine as the list needs it: one live Claude session in `/work`, + /// answering both the list and the single-session reads. + fn live_claude_engine() -> EngineClient { + let summary = json!({ + "id": "eng-1", + "name": "agent", + "activity": "idle", + "alive": true, + "cwd": "/work", + "command": "claude", + "conversation_agent": "claude", + }); + engine(move |path, _, _| { + let body = if path.ends_with("/api/sessions") { + json!([summary]) + } else { + summary.clone() + }; + (200, serde_json::to_vec(&body).unwrap()) + }) + } + + /// `eng-1.jsonl` under a Claude root, the way a session's transcript is kept. + fn write_transcript(dir: &std::path::Path, lines: &[String]) { + let root = dir.join("claude").join("proj"); + fs::create_dir_all(&root).unwrap(); + fs::write(root.join("eng-1.jsonl"), lines.join("\n") + "\n").unwrap(); + } + + fn list(built: &crate::application::context::Built) -> Value { + default_registry() + .get("session.list") + .expect("session.list is registered") + .run(Some(built), json!({"mode": "full"})) + .unwrap() + } + + #[test] + fn the_list_carries_the_latest_reply_excerpt() { + let dir = + std::env::temp_dir().join(format!("vogt-reply-excerpt-files-{}", std::process::id())); + write_transcript( + &dir, + &[ + claude_line("a", "older", None), + claude_line("b", "The operator list: token, HAR.", None), + ], + ); + let built = built( + "excerpt", + vec![("claude".to_string(), dir.join("claude"))], + Some(live_claude_engine()), + ); + seed(&built); + let rows = list(&built); + let row = rows["sessions"] + .as_array() + .unwrap() + .iter() + .find(|row| row["id"] == "ses_0001") + .expect("the seeded session is listed"); + assert_eq!(row["last_reply_excerpt"], "The operator list: token, HAR."); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn the_list_says_which_model_the_conversation_ran() { + let dir = + std::env::temp_dir().join(format!("vogt-reply-model-files-{}", std::process::id())); + write_transcript( + &dir, + &[ + claude_line("a", "first", None), + claude_line("b", "second", Some("claude-opus-5-5")), + claude_line("c", "", Some("")), + ], + ); + let built = built( + "model", + vec![("claude".to_string(), dir.join("claude"))], + Some(live_claude_engine()), + ); + seed(&built); + let rows = list(&built); + let row = rows["sessions"] + .as_array() + .unwrap() + .iter() + .find(|row| row["id"] == "ses_0001") + .expect("the seeded session is listed"); + assert!(row["model"].is_null(), "nothing was asked for"); + assert_eq!(row["running"]["agent"], "claude"); + assert_eq!(row["running"]["model"], "claude-opus-5-5"); + assert_eq!(row["running"]["model_basis"], "transcript"); + assert_eq!(row["template"], "claude"); + let _ = fs::remove_dir_all(&dir); + } } diff --git a/engine/core/src/application/session_work.rs b/engine/core/src/application/session_work.rs index ef501c2c..f9072897 100644 --- a/engine/core/src/application/session_work.rs +++ b/engine/core/src/application/session_work.rs @@ -547,7 +547,7 @@ fn text(params: &Value, name: &str) -> Option { #[cfg(test)] mod tests { - use serde_json::json; + use serde_json::{json, Value}; use crate::adapters::engine::EngineClient; use crate::application::context::build_context; @@ -767,4 +767,397 @@ mod tests { .unwrap_err(); assert!(matches!(error, VogtError::InvalidRequest(_)), "{error:?}"); } + + /// What the stand-in engine was asked, and the work-item label it holds + /// for each session. `knows_work_items` false answers the label route with + /// a 404, as an engine that predates session work items does. + #[derive(Default)] + struct StandIn { + asked: Vec<(String, String, Value)>, + labels: std::collections::BTreeMap, + knows_work_items: bool, + } + + type Shared = std::sync::Arc>; + + fn stand_in(shared: &Shared) -> EngineClient { + let shared = std::sync::Arc::clone(shared); + EngineClient::new( + "http://engine", + None, + Some(std::sync::Arc::new(move |path, _, body, method| { + let route = path.split("/api/").nth(1).unwrap_or(path).to_string(); + let payload: Value = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(body).unwrap_or(Value::Null) + }; + let mut state = shared.lock().unwrap(); + state + .asked + .push((method.to_string(), route.clone(), payload.clone())); + let parts: Vec<&str> = route.split('/').collect(); + let Some(engine_id) = parts.get(1).filter(|_| parts[0] == "sessions") else { + return (404, Vec::new()); + }; + let engine_id = engine_id.to_string(); + let summary = |state: &StandIn| { + let mut row = json!({"id": engine_id, "name": engine_id, "alive": true, + "activity": "idle", "cwd": "/work", "role": "worker"}); + if let Some(label) = state.labels.get(&engine_id) { + row["work_item"] = json!(label); + } + serde_json::to_vec(&row).unwrap() + }; + match (method, parts.get(2).copied()) { + ("GET", None) => (200, summary(&state)), + ("POST", Some("work-item")) if state.knows_work_items => { + match payload["work_item"].as_str() { + Some(label) => { + state.labels.insert(engine_id.clone(), label.to_string()); + } + None => { + state.labels.remove(&engine_id); + } + } + (200, summary(&state)) + } + _ => (404, Vec::new()), + } + })), + ) + } + + fn work_item(id: &str, reference: &str, project_id: &str) -> WorkItem { + WorkItem { + id: id.to_string(), + reference: reference.to_string(), + kind: WorkKind::Feature, + title: "The thing".to_string(), + body: String::new(), + state: "open".to_string(), + priority: Priority::P2, + effort: None, + project_id: Some(project_id.to_string()), + project_slug: Some("proj".to_string()), + initiative_id: None, + origin: Origin::Created, + trust_state: TrustState::Unverified, + assignee_actor_id: None, + assignee_identity_ref: None, + labels: Vec::new(), + relations: Vec::new(), + superseded_by: None, + created_at: Moment::from_unix(1_700_000_000, 0), + updated_at: Moment::from_unix(1_700_000_000, 0), + } + } + + /// A Vogt instance over `dir` for one principal, asking `shared`'s engine. + fn as_principal( + dir: &std::path::Path, + principal: Principal, + shared: &Shared, + engine: bool, + ) -> crate::application::context::Built { + build_context( + crate::config::VogtConfig { + data_dir: dir.to_path_buf(), + ..crate::config::VogtConfig::default() + }, + Some(principal), + Some(StepClock::new(Moment::from_unix(1_700_000_000, 0))), + None, + None, + engine.then(|| stand_in(shared)), + None, + None, + ) + .unwrap() + } + + /// An instance with two projects, two work items (WI-7 in `proj`, WI-8 in + /// `other`), a live session `ses_0001` for `eng-1`, a live child `ses_0002` + /// for `eng-2`, and a stopped `ses_0003` for `eng-3`. + fn estate( + name: &str, + ) -> ( + std::path::PathBuf, + Shared, + crate::application::context::Built, + ) { + let dir = std::env::temp_dir().join(format!("vogt-bind-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut clock = None; + let mut ids = None; + crate::application::instance::init(&dir, &mut clock, &mut ids).unwrap(); + let shared: Shared = std::sync::Arc::new(std::sync::Mutex::new(StandIn { + knows_work_items: true, + ..StandIn::default() + })); + let built = as_principal(&dir, person(), &shared, true); + crate::with_ctx!(&built, |ctx| { + let mut write = crate::application::context::write_of(ctx); + audited_write(&mut write, "seed", "test", |txn, actor| { + let at = Moment::from_unix(1_700_000_000, 0); + txn.insert_project(&Project::new("prj_1", "proj", "Proj", "/work", at))?; + txn.insert_project(&Project::new("prj_other", "other", "Other", "/other", at))?; + txn.insert_work_item(&work_item("wrk_7", "WI-7", "prj_1"))?; + let mut second = work_item("wrk_8", "WI-8", "prj_other"); + second.title = "the ops item".to_string(); + txn.insert_work_item(&second)?; + for (id, engine_id, stopped) in [ + ("ses_0001", "eng-1", None), + ("ses_0002", "eng-2", None), + ("ses_0003", "eng-3", Some(at)), + ] { + txn.insert_session(&CodingSession { + id: id.to_string(), + engine_session_id: engine_id.to_string(), + project_id: "prj_1".to_string(), + work_item_id: None, + actor_id: actor.id.clone(), + cwd: "/work".to_string(), + template: None, + model: None, + effort: None, + reason: "test".to_string(), + started_at: at, + stopped_at: stopped, + })?; + } + Ok(crate::application::writes::WriteOutcome { + result: json!({}), + entity_kind: "session".to_string(), + entity_id: "ses_0001".to_string(), + payload: json!({}), + event_kind: "session.started".to_string(), + summary: json!({}), + }) + }) + }) + .unwrap(); + (dir, shared, built) + } + + fn person() -> Principal { + Principal::new("local:ada", ActorKind::Human, "Ada").unwrap() + } + + /// The label writes the engine was sent for one session, in order. + fn label_writes(shared: &Shared, engine_id: &str) -> Vec { + let route = format!("sessions/{engine_id}/work-item"); + shared + .lock() + .unwrap() + .asked + .iter() + .filter(|(method, path, _)| method.as_str() == "POST" && *path == route) + .map(|(_, _, body)| body["work_item"].clone()) + .collect() + } + + #[test] + fn rebinding_and_unbinding_are_audited_and_labelled() { + let (_, shared, built) = estate("rebind"); + let bound = super::bind_work_op( + &built, + json!({"id": "ses_0001", "work_item": "WI-7", "reason": "start"}), + ) + .unwrap(); + assert_eq!(bound["previous_work_item"], json!(null)); + assert_eq!(bound["engine_label"], "written"); + assert_eq!(bound["session"]["work_item_state"], "open"); + + // Rebinding by the engine's id reaches the same row; WI-8 is filed + // under another project, so the answer says so, and the terminal stays + // where it was. + let rebound = super::bind_work_op( + &built, + json!({"id": "eng-1", "work_item": "WI-8", "reason": "move"}), + ) + .unwrap(); + assert_eq!(rebound["previous_work_item"], "WI-7"); + assert_eq!(rebound["project_mismatch"], json!(true)); + assert_eq!(rebound["session"]["cwd"], "/work"); + + let cleared = super::bind_work_op( + &built, + json!({"id": "ses_0001", "work_item": null, "reason": "done"}), + ) + .unwrap(); + assert_eq!(cleared["session"]["work_item"], json!(null)); + assert_eq!(cleared["previous_work_item"], "WI-8"); + assert_eq!( + label_writes(&shared, "eng-1"), + vec![json!("WI-7"), json!("WI-8"), json!(null)] + ); + assert!(!shared.lock().unwrap().labels.contains_key("eng-1")); + + let ledger = crate::with_ctx!(&built, |ctx| { + ctx.declared + .read() + .unwrap() + .list_audit(&crate::storage::interface::AuditQuery { + limit: 20, + offset: 0, + actor_id: None, + operation: Some("session.bind_work".to_string()), + entity_id: None, + project_id: None, + since: None, + until: None, + }) + }) + .unwrap(); + assert_eq!(ledger.len(), 3, "one audit row per bind"); + assert!(ledger.iter().all(|row| row.entity_id == "ses_0001")); + } + + #[test] + fn the_declared_branch_is_recorded_and_the_item_names_the_session() { + let (_, _, built) = estate("branch"); + super::bind_work_op( + &built, + json!({"id": "ses_0001", "work_item": "WI-7", "reason": "start"}), + ) + .unwrap(); + let detail = crate::registry::default_registry() + .get("work.get") + .expect("work.get is registered") + .run(Some(&built), json!({"ref": "WI-7"})) + .unwrap(); + assert_eq!(detail["sessions"][0]["id"], "ses_0001"); + assert!( + detail["branches"] + .as_array() + .unwrap() + .iter() + .any(|branch| matches!(branch["source"].as_str(), Some("declared" | "both"))), + "{detail}" + ); + } + + #[test] + fn another_session_may_bind_a_child() { + let (dir, shared, _) = estate("child"); + let overseer = as_principal( + &dir, + Principal::new("agent:session:ses_0001", ActorKind::Agent, "Session").unwrap(), + &shared, + true, + ); + let result = super::bind_work_op( + &overseer, + json!({"id": "ses_0002", "work_item": "WI-7", "reason": "child"}), + ) + .unwrap(); + assert_eq!(result["session"]["id"], "ses_0002"); + assert_eq!(result["session"]["work_item"], "WI-7"); + } + + #[test] + fn a_session_binds_itself_without_an_id() { + let (dir, shared, _) = estate("self"); + let own = as_principal( + &dir, + Principal::new("agent:session:ses_0001", ActorKind::Agent, "Session").unwrap(), + &shared, + true, + ); + let result = + super::bind_work_op(&own, json!({"work_item": "WI-7", "reason": "mine"})).unwrap(); + assert_eq!(result["session"]["id"], "ses_0001"); + assert_eq!(result["session"]["work_item"], "WI-7"); + } + + #[test] + fn an_unknown_item_or_a_stopped_session_is_refused() { + let (_, _, built) = estate("refused"); + let unknown = super::bind_work_op( + &built, + json!({"id": "ses_0001", "work_item": "WI-99", "reason": "typo"}), + ) + .unwrap_err(); + assert!(matches!(unknown, VogtError::NotFound(_)), "{unknown:?}"); + let stopped = super::bind_work_op( + &built, + json!({"id": "ses_0003", "work_item": "WI-7", "reason": "late"}), + ) + .unwrap_err(); + assert!(matches!(stopped, VogtError::Conflict(_)), "{stopped:?}"); + } + + #[test] + fn a_label_the_engine_refuses_is_reported_not_fatal() { + let (dir, shared, built) = estate("label"); + shared.lock().unwrap().knows_work_items = false; + let bound = super::bind_work_op( + &built, + json!({"id": "ses_0001", "work_item": "WI-7", "reason": "start"}), + ) + .unwrap(); + assert_eq!(bound["engine_label"], "not_found"); + assert!(bound["engine"].is_string(), "{bound}"); + assert_eq!( + bound["session"]["work_item"], "WI-7", + "the core row is the truth" + ); + + // With no engine at all the label is unavailable, not dead. + let offline = as_principal(&dir, person(), &shared, false); + let unbound = super::bind_work_op( + &offline, + json!({"id": "ses_0001", "work_item": null, "reason": "done"}), + ) + .unwrap(); + assert_eq!(unbound["engine_label"], "unavailable"); + assert_eq!(unbound["session"]["work_item"], json!(null)); + assert_eq!( + unbound["session"]["alive"], + json!(null), + "not asked is not dead" + ); + } + + #[test] + fn an_unlinked_session_is_bound_by_its_engine_label_only() { + let (_, shared, built) = estate("unlinked"); + let bound = super::bind_work_op( + &built, + json!({"id": "eng-9", "work_item": "WI-7", "reason": "gui-started"}), + ) + .unwrap(); + assert_eq!(bound["session"]["linked"], json!(false)); + assert_eq!(bound["session"]["work_item"], "WI-7"); + assert_eq!(bound["session"]["work_item_title"], "The thing"); + assert_eq!(shared.lock().unwrap().labels["eng-9"], "WI-7"); + + let ledger = crate::with_ctx!(&built, |ctx| { + ctx.declared + .read() + .unwrap() + .list_audit(&crate::storage::interface::AuditQuery { + limit: 20, + offset: 0, + actor_id: None, + operation: Some("session.bind_work".to_string()), + entity_id: None, + project_id: None, + since: None, + until: None, + }) + }) + .unwrap(); + assert!(ledger.iter().any(|row| row.entity_id == "eng-9")); + + shared.lock().unwrap().knows_work_items = false; + let error = super::bind_work_op( + &built, + json!({"id": "eng-9", "work_item": null, "reason": "done"}), + ) + .unwrap_err(); + assert!(matches!(error, VogtError::NotFound(_)), "{error:?}"); + } } From 35957873b5b41ad203f60f89abde4eee7b43ce35 Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 17 Oct 2026 08:29:18 +0000 Subject: [PATCH 3/4] test(parity): decide and revoke a real grant on a stand-in engine (WI-1074) The grant decide and revoke steps used a made-up id, so they failed as not_found before the person gate ran. parity.py now serves a loopback stand-in engine for steps marked "engine": true, and a param can name an earlier step's answer with {seen:#}. The grant request creates a real grant for a live worker, then the person decides and the person revokes it. The CLI harness runs as the local person, so the agent side of WI-983 is still covered only by the Rust unit tests. The 21c95f6 golden is not re-recorded. Also: health.rs no longer sets VOGT_TEST_IDS process-wide, which made every later context in the test process use sequential ids and collide when a second context opened the same store. Co-Authored-By: Claude Haiku 5.5 Signed-off-by: sprooty --- engine/core/src/adapters/http/health.rs | 4 +- scripts/parity.py | 91 ++++++++++++++++++++++++- tests/parity/script.json | 41 ++++------- 3 files changed, 108 insertions(+), 28 deletions(-) diff --git a/engine/core/src/adapters/http/health.rs b/engine/core/src/adapters/http/health.rs index 1b0bb344..1e1dc5cd 100644 --- a/engine/core/src/adapters/http/health.rs +++ b/engine/core/src/adapters/http/health.rs @@ -245,7 +245,9 @@ mod tests { let _guard = ENV.lock().unwrap(); let dir = std::env::temp_dir().join(format!("vogt-health-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - unsafe { std::env::set_var("VOGT_TEST_IDS", "sequential") }; + // The ids are passed in, not set through VOGT_TEST_IDS: an env write + // here would outlive this test and make every other test's context + // sequential, so a second context over a store collides on actor ids. let mut clock = None; let mut ids = crate::core::ids_from_env(Some("sequential"), None).unwrap(); crate::application::instance::init(&dir, &mut clock, &mut ids).unwrap(); diff --git a/scripts/parity.py b/scripts/parity.py index b30efaf4..6fe0d318 100644 --- a/scripts/parity.py +++ b/scripts/parity.py @@ -23,12 +23,15 @@ import difflib import json import os +import re import shutil import subprocess import sys import tempfile +import threading import time from datetime import UTC, datetime +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any @@ -317,12 +320,86 @@ def _child_env() -> dict[str, str]: return env +class _StandInHandler(BaseHTTPRequestHandler): + """The engine routes the grant steps reach: every session answers alive as + a worker, a grant apply is echoed back, and a revoke is acknowledged.""" + + def _reply(self, status: int, payload: Any) -> None: + body = json.dumps(payload).encode() if payload is not None else b"" + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _parts(self) -> list[str]: + return self.path.split("?", 1)[0].strip("/").split("/") + + def do_GET(self) -> None: + parts = self._parts() + if parts[:2] == ["api", "sessions"] and len(parts) == 3: + return self._reply( + 200, + { + "id": parts[2], + "name": parts[2], + "activity": "idle", + "alive": True, + "exit_code": None, + "role": "worker", + "cwd": "/work", + }, + ) + self._reply(404, None) + + def do_POST(self) -> None: + parts = self._parts() + length = int(self.headers.get("Content-Length") or 0) + payload = json.loads(self.rfile.read(length) or b"{}") + if ( + len(parts) == 4 + and parts[:2] == ["api", "sessions"] + and parts[3] == "grants" + ): + return self._reply(200, payload) + self._reply(404, None) + + def do_DELETE(self) -> None: + parts = self._parts() + if ( + len(parts) == 5 + and parts[:2] == ["api", "sessions"] + and parts[3] == "grants" + ): + return self._reply(200, {"revoked": True}) + self._reply(404, None) + + def log_message(self, *args: Any) -> None: + return + + +class _StandInEngine: + """A loopback stand-in for the session engine, for the steps that set + `"engine": true`. Every other step runs with VOGT_ENGINE_URL unset.""" + + def __enter__(self) -> str: + self.server = ThreadingHTTPServer(("127.0.0.1", 0), _StandInHandler) + threading.Thread(target=self.server.serve_forever, daemon=True).start() + return f"http://127.0.0.1:{self.server.server_address[1]}" + + def __exit__(self, *exc: object) -> None: + self.server.shutdown() + self.server.server_close() + + def _run_cli(binary: str, steps: list[dict[str, Any]]) -> list[dict[str, Any]]: root = Path(tempfile.mkdtemp(prefix="vogt-parity-")) data = root / "instance" env = _child_env() answers: list[dict[str, Any]] = [] seen: dict[str, Any] = {} + engine = _StandInEngine() + engine_url = engine.__enter__() try: root.mkdir(parents=True, exist_ok=True) init = subprocess.run( @@ -362,8 +439,11 @@ def _run_cli(binary: str, steps: list[dict[str, Any]]) -> list[dict[str, Any]]: # is the `session log` subcommand. words = step["cli"] if "cli" in step else step["operation"].split(".") argv = [binary, "--json", "--data-dir", str(data), *words, *_flags(params)] + step_env = ( + {**env, "VOGT_ENGINE_URL": engine_url} if step.get("engine") else env + ) completed = subprocess.run( - argv, env=env, capture_output=True, text=True, check=False + argv, env=step_env, capture_output=True, text=True, check=False ) expect = step["expect"] if completed.returncode != expect.get("exit", 0): @@ -395,6 +475,7 @@ def _run_cli(binary: str, steps: list[dict[str, Any]]) -> list[dict[str, Any]]: answer["error"] = _normalise(line, root, data, step["operation"]) answers.append(answer) finally: + engine.__exit__(None, None, None) shutil.rmtree(root, ignore_errors=True) return answers @@ -485,6 +566,14 @@ def _substitute( def one(value: Any) -> Any: if not isinstance(value, str): return value + # `{seen:#}` is a field of an earlier step's + # answer, so a step can name what an earlier one created. + match = re.fullmatch(r"\{seen:([^#}]+)#([^}]+)\}", value) + if match: + found: Any = seen[match.group(1)] + for key in match.group(2).split("."): + found = found[key] + return found value = value.replace("{root}", str(root)) return value diff --git a/tests/parity/script.json b/tests/parity/script.json index 9f67bdf3..ac98f94b 100644 --- a/tests/parity/script.json +++ b/tests/parity/script.json @@ -1176,82 +1176,71 @@ } }, { + "why": "a person asks for a grant on a live engine session; the stand-in engine answers the session as alive", "operation": "session.grant_request", "cli": [ "session", "grant-request" ], + "engine": true, "params": { - "target": "ses_00000000000000000000000000", + "target": "parity-worker", "kind": "credential", "secret_name": "PARITY_TOKEN", "project_id": "parity-project", "uses": "once", + "ttl_seconds": 60, "reason": "parity" }, "expect": { - "exit": 1 + "exit": 0 }, "http": { "method": "POST", "path": "/api/sessions/grants", "auth": "admin", "body": { - "target": "ses_00000000000000000000000000", + "target": "parity-worker", "kind": "credential", "secret_name": "PARITY_TOKEN", "project_id": "parity-project", "uses": "once", + "ttl_seconds": 60, "reason": "parity" } } }, { + "why": "WI-983: the decision is a person's, and it reads the grant the request step made (a real grant, not a made-up id); the engine applies it before the record says approved", "operation": "session.grant_decide", "cli": [ "session", "grant-decide" ], + "engine": true, "params": { - "id": "grt_00000000000000000000000000", + "id": "{seen:session.grant_request#grant.id}", "decision": "approve", "reason": "parity" }, "expect": { - "exit": 1 - }, - "http": { - "method": "POST", - "path": "/api/sessions/grants/decide", - "auth": "admin", - "body": { - "id": "grt_00000000000000000000000000", - "decision": "approve", - "reason": "parity" - } + "exit": 0 } }, { + "why": "the same real grant, now approved, is revoked: the engine takes it back and the record says revoked", "operation": "session.grant_revoke", "cli": [ "session", "grant-revoke" ], + "engine": true, "params": { - "id": "grt_00000000000000000000000000", + "id": "{seen:session.grant_request#grant.id}", "reason": "parity" }, "expect": { - "exit": 1 - }, - "http": { - "method": "POST", - "path": "/api/sessions/grants/revoke", - "auth": "admin", - "body": { - "id": "grt_00000000000000000000000000", - "reason": "parity" - } + "exit": 0 } }, { From 1286273662e72e43355d8c632fad06320c77a86d Mon Sep 17 00:00:00 2001 From: sprooty Date: Sat, 17 Oct 2026 08:58:27 +0000 Subject: [PATCH 4/4] test(parity): re-record the 21c95f6 cli/http golden with the WI-1074 session steps (WI-1074) mcp.json is left as recorded on rust-core: the MCP runner does not resolve the ${seen} references the new grant steps use. Co-Authored-By: Claude Opus 5.5 Signed-off-by: sprooty --- tests/parity/golden/21c95f6/cli.json | 217 +++++++++++++-- tests/parity/golden/21c95f6/http.json | 376 ++++++++++++++++++++++++-- tests/parity/golden/21c95f6/meta.json | 2 +- 3 files changed, 553 insertions(+), 42 deletions(-) diff --git a/tests/parity/golden/21c95f6/cli.json b/tests/parity/golden/21c95f6/cli.json index df664d98..24d8912a 100644 --- a/tests/parity/golden/21c95f6/cli.json +++ b/tests/parity/golden/21c95f6/cli.json @@ -3464,6 +3464,177 @@ "exit": 1, "operation": "session.stop" }, + { + "body": { + "grant": { + "capability": null, + "decided_at": null, + "decided_by": null, + "decision_reason": null, + "expires_at": null, + "id": "grt_0001", + "kind": "credential", + "project_id": "parity-project", + "reason": "parity", + "requested_at": "2026-01-02T03:04:05Z", + "requested_by": "local:parity", + "revoked_at": null, + "revoked_by": null, + "secret_name": "PARITY_TOKEN", + "state": "pending", + "target": "parity-worker", + "ttl_seconds": 60, + "uses": "once", + "var": "GRANT_PARITY_TOKEN" + } + }, + "exit": 0, + "operation": "session.grant_request" + }, + { + "body": { + "grant": { + "capability": null, + "decided_at": "2026-01-02T03:04:05Z", + "decided_by": "local:parity", + "decision_reason": "parity", + "expires_at": "2026-01-02T03:05:05Z", + "id": "grt_0001", + "kind": "credential", + "project_id": "parity-project", + "reason": "parity", + "requested_at": "2026-01-02T03:04:05Z", + "requested_by": "local:parity", + "revoked_at": null, + "revoked_by": null, + "secret_name": "PARITY_TOKEN", + "state": "approved", + "target": "parity-worker", + "ttl_seconds": 60, + "uses": "once", + "var": "GRANT_PARITY_TOKEN" + } + }, + "exit": 0, + "operation": "session.grant_decide" + }, + { + "body": { + "grant": { + "capability": null, + "decided_at": "2026-01-02T03:04:05Z", + "decided_by": "local:parity", + "decision_reason": "parity", + "expires_at": "2026-01-02T03:05:05Z", + "id": "grt_0001", + "kind": "credential", + "project_id": "parity-project", + "reason": "parity", + "requested_at": "2026-01-02T03:04:05Z", + "requested_by": "local:parity", + "revoked_at": "2026-01-02T03:04:05Z", + "revoked_by": "local:parity", + "secret_name": "PARITY_TOKEN", + "state": "revoked", + "target": "parity-worker", + "ttl_seconds": 60, + "uses": "once", + "var": "GRANT_PARITY_TOKEN" + } + }, + "exit": 0, + "operation": "session.grant_revoke" + }, + { + "body": { + "grants": [] + }, + "exit": 0, + "operation": "session.grant_list" + }, + { + "body": { + "engine": "no session engine is configured (VOGT_ENGINE_URL is unset)", + "sessions": [] + }, + "exit": 0, + "operation": "session.history_list" + }, + { + "body": null, + "exit": 1, + "operation": "session.bind_work" + }, + { + "body": null, + "exit": 1, + "operation": "session.last_reply" + }, + { + "body": { + "engine": "no session engine is configured (VOGT_ENGINE_URL is unset)", + "matches": [] + }, + "exit": 0, + "operation": "session.search_output" + }, + { + "body": null, + "exit": 1, + "operation": "session.report_blocked" + }, + { + "body": null, + "exit": 1, + "operation": "session.report_unblocked" + }, + { + "body": null, + "exit": 1, + "operation": "session.changes" + }, + { + "body": null, + "exit": 1, + "operation": "session.wait_any" + }, + { + "body": null, + "exit": 1, + "operation": "session.commit_for" + }, + { + "body": null, + "exit": 1, + "operation": "session.economics" + }, + { + "body": null, + "exit": 1, + "operation": "session.digest" + }, + { + "body": { + "detail": "agent activity is not collected: no transcript roots are configured (set `agent_activity_roots`)", + "events": [], + "indexed_at": null, + "next_offset": null, + "total": 0 + }, + "exit": 0, + "operation": "agent_activity.search" + }, + { + "body": { + "detail": "agent activity is not collected: no transcript roots are configured (set `agent_activity_roots`)", + "indexed_at": null, + "next_offset": null, + "sessions": [], + "total": 0 + }, + "exit": 0, + "operation": "agent_activity.summary" + }, { "body": null, "error": "error: engine_unavailable: no session engine is configured, and chats run on it (set VOGT_ENGINE_URL)", @@ -3840,12 +4011,12 @@ "at": "2026-01-02T03:04:06Z", "entity_id": "act_0005", "entity_kind": "user", - "id": "aud_0029", + "id": "aud_0032", "operation": "user.remove", "payload_digest": "sha256:6cb1b570d1a756565af0191ec35d1fed1dc103ed9b82afa000b68a0172b6f711", "reason": "parity harness", - "revision": 28, - "txn_id": "txn_0037" + "revision": 31, + "txn_id": "txn_0040" }, { "actor_id": "act_0001", @@ -3853,12 +4024,12 @@ "at": "2026-01-02T03:04:06Z", "entity_id": "tok_0002", "entity_kind": "token", - "id": "aud_0028", + "id": "aud_0031", "operation": "token.revoke", "payload_digest": "sha256:5b5962b852bf72720c110fabfda9bea2589305bb33f47f25d1361ce815fa78c9", "reason": "parity harness", - "revision": 27, - "txn_id": "txn_0035" + "revision": 30, + "txn_id": "txn_0038" }, { "actor_id": "act_0001", @@ -3866,12 +4037,12 @@ "at": "2026-01-02T03:04:06Z", "entity_id": "tok_0003", "entity_kind": "token", - "id": "aud_0027", + "id": "aud_0030", "operation": "token.issue", "payload_digest": "sha256:095bcb2a75c36880ea4d27397304f88e0afd90440f45bee38fabb4431e383f55", "reason": "parity harness", - "revision": 26, - "txn_id": "txn_0034" + "revision": 29, + "txn_id": "txn_0037" }, { "actor_id": "act_0001", @@ -3879,12 +4050,12 @@ "at": "2026-01-02T03:04:06Z", "entity_id": "act_0005", "entity_kind": "user", - "id": "aud_0026", + "id": "aud_0029", "operation": "user.set_password", "payload_digest": "sha256:6782e2b5fa4d26772e8b2eeacbf82bb2c48960ddb25c736b28001d387114acf5", "reason": "parity harness", - "revision": 25, - "txn_id": "txn_0033" + "revision": 28, + "txn_id": "txn_0036" }, { "actor_id": "act_0001", @@ -3892,15 +4063,15 @@ "at": "2026-01-02T03:04:06Z", "entity_id": "act_0005", "entity_kind": "user", - "id": "aud_0025", + "id": "aud_0028", "operation": "user.create", "payload_digest": "sha256:67e02676f1f6d286e8b3624b34c5496ed548e0ac92c3485f85c350f4c54dcce7", "reason": "parity harness", - "revision": 24, - "txn_id": "txn_0032" + "revision": 27, + "txn_id": "txn_0035" } ], - "total": 29 + "total": 32 }, "exit": 0, "operation": "audit.list" @@ -3914,12 +4085,12 @@ "at": "2026-01-02T03:04:06Z", "entity_id": "tok_0002", "entity_kind": "token", - "id": "aud_0028", + "id": "aud_0031", "operation": "token.revoke", "payload_digest": "sha256:5b5962b852bf72720c110fabfda9bea2589305bb33f47f25d1361ce815fa78c9", "reason": "parity harness", - "revision": 27, - "txn_id": "txn_0035" + "revision": 30, + "txn_id": "txn_0038" }, { "actor_id": "act_0001", @@ -3927,15 +4098,15 @@ "at": "2026-01-02T03:04:06Z", "entity_id": "tok_0003", "entity_kind": "token", - "id": "aud_0027", + "id": "aud_0030", "operation": "token.issue", "payload_digest": "sha256:095bcb2a75c36880ea4d27397304f88e0afd90440f45bee38fabb4431e383f55", "reason": "parity harness", - "revision": 26, - "txn_id": "txn_0034" + "revision": 29, + "txn_id": "txn_0037" } ], - "total": 29 + "total": 32 }, "exit": 0, "operation": "audit.list" diff --git a/tests/parity/golden/21c95f6/http.json b/tests/parity/golden/21c95f6/http.json index 0968373f..c1b1c9cc 100644 --- a/tests/parity/golden/21c95f6/http.json +++ b/tests/parity/golden/21c95f6/http.json @@ -1135,6 +1135,151 @@ }, "status": 404 }, + { + "operation": "session.grant_request", + "result": { + "error": { + "code": "engine_unavailable", + "message": "no session engine is configured, so there is nothing to open a terminal on (set VOGT_ENGINE_URL)" + } + }, + "status": 502 + }, + { + "operation": "session.grant_list", + "result": { + "grants": [] + }, + "status": 200 + }, + { + "operation": "session.history_list", + "result": { + "engine": "no session engine is configured (VOGT_ENGINE_URL is unset)", + "sessions": [] + }, + "status": 200 + }, + { + "operation": "session.bind_work", + "result": { + "error": { + "code": "not_found", + "message": "no session 'ses_00000000000000000000000000'" + } + }, + "status": 404 + }, + { + "operation": "session.last_reply", + "result": { + "error": { + "code": "not_found", + "message": "no session 'ses_00000000000000000000000000'" + } + }, + "status": 404 + }, + { + "operation": "session.search_output", + "result": { + "engine": "no session engine is configured (VOGT_ENGINE_URL is unset)", + "matches": [] + }, + "status": 200 + }, + { + "operation": "session.report_blocked", + "result": { + "error": { + "code": "engine_unavailable", + "message": "no session engine is configured, so there is nothing to open a terminal on (set VOGT_ENGINE_URL)" + } + }, + "status": 502 + }, + { + "operation": "session.report_unblocked", + "result": { + "error": { + "code": "engine_unavailable", + "message": "no session engine is configured, so there is nothing to open a terminal on (set VOGT_ENGINE_URL)" + } + }, + "status": 502 + }, + { + "operation": "session.changes", + "result": { + "error": { + "code": "engine_unavailable", + "message": "no session engine is configured, so there is nothing to open a terminal on (set VOGT_ENGINE_URL)" + } + }, + "status": 502 + }, + { + "operation": "session.wait_any", + "result": { + "error": { + "code": "engine_unavailable", + "message": "no session engine is configured, so there is nothing to open a terminal on (set VOGT_ENGINE_URL)" + } + }, + "status": 502 + }, + { + "operation": "session.commit_for", + "result": { + "error": { + "code": "engine_unavailable", + "message": "no session engine is configured, so there is nothing to open a terminal on (set VOGT_ENGINE_URL)" + } + }, + "status": 502 + }, + { + "operation": "session.economics", + "result": { + "error": { + "code": "not_found", + "message": "no session 'ses_00000000000000000000000000'" + } + }, + "status": 404 + }, + { + "operation": "session.digest", + "result": { + "error": { + "code": "not_found", + "message": "no session 'ses_00000000000000000000000000'" + } + }, + "status": 404 + }, + { + "operation": "agent_activity.search", + "result": { + "detail": "agent activity is not collected: no transcript roots are configured (set `agent_activity_roots`)", + "events": [], + "indexed_at": null, + "next_offset": null, + "total": 0 + }, + "status": 200 + }, + { + "operation": "agent_activity.summary", + "result": { + "detail": "agent activity is not collected: no transcript roots are configured (set `agent_activity_roots`)", + "indexed_at": null, + "next_offset": null, + "sessions": [], + "total": 0 + }, + "status": 200 + }, { "operation": "label.create", "result": { @@ -2284,7 +2429,7 @@ "detail": null, "id": "aut_0027", "identity_ref": "human:ada-lovelace", - "operation": "label.create", + "operation": "session.grant_request", "reason_code": "token_valid", "scope": "work.write", "token_id": "tok_0003", @@ -2297,6 +2442,201 @@ "detail": null, "id": "aut_0028", "identity_ref": "human:ada-lovelace", + "operation": "session.grant_list", + "reason_code": "token_valid", + "scope": "read", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0029", + "identity_ref": "human:ada-lovelace", + "operation": "session.history_list", + "reason_code": "token_valid", + "scope": "read", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0030", + "identity_ref": "human:ada-lovelace", + "operation": "session.bind_work", + "reason_code": "token_valid", + "scope": "work.write", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0031", + "identity_ref": "human:ada-lovelace", + "operation": "session.last_reply", + "reason_code": "token_valid", + "scope": "read", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0032", + "identity_ref": "human:ada-lovelace", + "operation": "session.search_output", + "reason_code": "token_valid", + "scope": "read", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0033", + "identity_ref": "human:ada-lovelace", + "operation": "session.report_blocked", + "reason_code": "token_valid", + "scope": "work.write", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0034", + "identity_ref": "human:ada-lovelace", + "operation": "session.report_unblocked", + "reason_code": "token_valid", + "scope": "work.write", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0035", + "identity_ref": "human:ada-lovelace", + "operation": "session.changes", + "reason_code": "token_valid", + "scope": "read", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0036", + "identity_ref": "human:ada-lovelace", + "operation": "session.wait_any", + "reason_code": "token_valid", + "scope": "read", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0037", + "identity_ref": "human:ada-lovelace", + "operation": "session.commit_for", + "reason_code": "token_valid", + "scope": "work.write", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0038", + "identity_ref": "human:ada-lovelace", + "operation": "session.economics", + "reason_code": "token_valid", + "scope": "read", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0039", + "identity_ref": "human:ada-lovelace", + "operation": "session.digest", + "reason_code": "token_valid", + "scope": "read", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0040", + "identity_ref": "human:ada-lovelace", + "operation": "agent_activity.search", + "reason_code": "token_valid", + "scope": "read", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0041", + "identity_ref": "human:ada-lovelace", + "operation": "agent_activity.summary", + "reason_code": "token_valid", + "scope": "read", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0042", + "identity_ref": "human:ada-lovelace", + "operation": "label.create", + "reason_code": "token_valid", + "scope": "work.write", + "token_id": "tok_0003", + "transport": "http" + }, + { + "actor_id": "act_0004", + "at": "2026-01-02T03:04:05+00:00", + "decision": "allow", + "detail": null, + "id": "aut_0043", + "identity_ref": "human:ada-lovelace", "operation": "token.issue", "reason_code": "token_valid", "scope": "admin", @@ -2308,7 +2648,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "deny", "detail": null, - "id": "aut_0029", + "id": "aut_0044", "identity_ref": "human:ada-lovelace", "operation": "label.create", "reason_code": "missing_scope", @@ -2321,7 +2661,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0030", + "id": "aut_0045", "identity_ref": "human:ada-lovelace", "operation": "auth.logout", "reason_code": "token_valid", @@ -2334,7 +2674,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0031", + "id": "aut_0046", "identity_ref": "human:ada-lovelace", "operation": "user.create", "reason_code": "token_valid", @@ -2347,7 +2687,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0032", + "id": "aut_0047", "identity_ref": "human:ada-lovelace", "operation": "user.list", "reason_code": "token_valid", @@ -2360,7 +2700,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0033", + "id": "aut_0048", "identity_ref": "human:ada-lovelace", "operation": "user.set_password", "reason_code": "token_valid", @@ -2373,7 +2713,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0034", + "id": "aut_0049", "identity_ref": "human:ada-lovelace", "operation": "token.list", "reason_code": "token_valid", @@ -2386,7 +2726,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0035", + "id": "aut_0050", "identity_ref": "human:ada-lovelace", "operation": "token.revoke", "reason_code": "token_valid", @@ -2399,7 +2739,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0036", + "id": "aut_0051", "identity_ref": "human:ada-lovelace", "operation": "auth.whoami", "reason_code": "token_valid", @@ -2412,7 +2752,7 @@ "at": "2026-01-02T03:04:07+00:00", "decision": "allow", "detail": null, - "id": "aut_0037", + "id": "aut_0052", "identity_ref": "human:ada", "operation": "auth.login", "reason_code": "login_ok", @@ -2425,7 +2765,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0038", + "id": "aut_0053", "identity_ref": "human:ada", "operation": "auth.whoami", "reason_code": "token_valid", @@ -2438,7 +2778,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0039", + "id": "aut_0054", "identity_ref": "human:ada-lovelace", "operation": "user.remove", "reason_code": "token_valid", @@ -2451,7 +2791,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0040", + "id": "aut_0055", "identity_ref": "human:ada-lovelace", "operation": "events.list", "reason_code": "token_valid", @@ -2464,7 +2804,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0041", + "id": "aut_0056", "identity_ref": "human:ada-lovelace", "operation": "events.list", "reason_code": "token_valid", @@ -2477,7 +2817,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0042", + "id": "aut_0057", "identity_ref": "human:ada-lovelace", "operation": "audit.list", "reason_code": "token_valid", @@ -2490,7 +2830,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0043", + "id": "aut_0058", "identity_ref": "human:ada-lovelace", "operation": "audit.list", "reason_code": "token_valid", @@ -2503,7 +2843,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0044", + "id": "aut_0059", "identity_ref": "human:ada-lovelace", "operation": "project.import", "reason_code": "token_valid", @@ -2516,7 +2856,7 @@ "at": "2026-01-02T03:04:05+00:00", "decision": "allow", "detail": null, - "id": "aut_0045", + "id": "aut_0060", "identity_ref": "human:ada-lovelace", "operation": "project.import", "reason_code": "token_valid", diff --git a/tests/parity/golden/21c95f6/meta.json b/tests/parity/golden/21c95f6/meta.json index 37a35530..140e00c0 100644 --- a/tests/parity/golden/21c95f6/meta.json +++ b/tests/parity/golden/21c95f6/meta.json @@ -1,5 +1,5 @@ { "main_sha": "21c95f674e1b03ee177394f605abb1abfac41cdd", - "recorded_at": "2026-10-17T08:38:48Z", + "recorded_at": "2026-10-17T08:58:08Z", "recorder": "scripts/parity.py" }