From de911a86813d63b3c1f723b05666e30aa8224d65 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:50:22 +0000 Subject: [PATCH 1/2] Initial plan From 03bf0019b13b0caf6c6384f188869ab5f1d88d27 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:12:47 +0000 Subject: [PATCH 2/2] feat: Milestone 13 - deterministic run archiving and retention controls Co-authored-by: anschmieg <6830368+anschmieg@users.noreply.github.com> --- apps/chatgpt-mcp/src/invariants.test.ts | 126 +++++++ apps/chatgpt-mcp/src/schemas.ts | 38 +- apps/chatgpt-mcp/src/tools.ts | 25 +- codex-rs/deterministic-core/src/approval.rs | 1 + codex-rs/deterministic-core/src/lib.rs | 1 + .../deterministic-core/src/run_archive.rs | 329 ++++++++++++++++++ .../deterministic-core/src/run_finalize.rs | 1 + .../deterministic-core/src/run_prepare.rs | 1 + .../deterministic-core/src/run_refresh.rs | 1 + codex-rs/deterministic-core/src/run_reopen.rs | 1 + codex-rs/deterministic-core/src/run_replan.rs | 1 + .../deterministic-core/src/run_supersede.rs | 2 + codex-rs/deterministic-daemon/src/handlers.rs | 228 ++++++++++++ .../deterministic-daemon/src/persistence.rs | 328 +++++++++++++++-- .../deterministic-protocol/src/methods.rs | 8 + codex-rs/deterministic-protocol/src/types.rs | 70 ++++ docs/IMPLEMENTATION_PLAN.md | 41 +++ docs/INTERNAL_RPC.md | 43 +++ docs/MCP_TOOL_CONTRACTS.md | 37 ++ docs/PROJECT_STATUS.md | 45 ++- 20 files changed, 1286 insertions(+), 41 deletions(-) create mode 100644 codex-rs/deterministic-core/src/run_archive.rs diff --git a/apps/chatgpt-mcp/src/invariants.test.ts b/apps/chatgpt-mcp/src/invariants.test.ts index fd77a3d157f8..66d69fe17633 100644 --- a/apps/chatgpt-mcp/src/invariants.test.ts +++ b/apps/chatgpt-mcp/src/invariants.test.ts @@ -19,6 +19,8 @@ import { FinalizeRunInput, ReopenRunInput, SupersedeRunInput, + ArchiveRunInput, + ListRunsInput, } from "./schemas.js"; describe("MCP tool registry invariants", () => { @@ -57,6 +59,8 @@ describe("MCP tool registry invariants", () => { "reopen_run", // Milestone 12: deterministic run supersession "supersede_run", + // Milestone 13: deterministic run archiving + "archive_run", ]); const actual = new Set(REGISTERED_TOOL_NAMES); assert.deepStrictEqual(actual, expected); @@ -528,3 +532,125 @@ describe("No-hidden-agent regression (Milestone 12)", () => { } }); }); + +// --------------------------------------------------------------- +// Milestone 13: ArchiveRunInput schema validation +// --------------------------------------------------------------- +describe("ArchiveRunInput schema (Milestone 13)", () => { + it("should accept a valid archive request", () => { + const schema = z.object(ArchiveRunInput); + const result = schema.safeParse({ runId: "run-abc", reason: "Archiving completed run" }); + assert.ok(result.success, "Valid archive request should pass validation"); + }); + + it("should reject missing runId", () => { + const schema = z.object(ArchiveRunInput); + const result = schema.safeParse({ reason: "reason" }); + assert.ok(!result.success, "Missing runId should fail validation"); + }); + + it("should reject missing reason", () => { + const schema = z.object(ArchiveRunInput); + const result = schema.safeParse({ runId: "run-abc" }); + assert.ok(!result.success, "Missing reason should fail validation"); + }); + + it("should reject an empty reason", () => { + const schema = z.object(ArchiveRunInput); + const result = schema.safeParse({ runId: "run-abc", reason: "" }); + assert.ok(!result.success, "Empty reason should fail validation (min 1)"); + }); + + it("should reject a reason exceeding 500 characters", () => { + const schema = z.object(ArchiveRunInput); + const result = schema.safeParse({ runId: "run-abc", reason: "x".repeat(501) }); + assert.ok(!result.success, "Reason exceeding 500 chars should fail validation"); + }); + + it("should accept a reason of exactly 500 characters", () => { + const schema = z.object(ArchiveRunInput); + const result = schema.safeParse({ runId: "run-abc", reason: "x".repeat(500) }); + assert.ok(result.success, "Reason of exactly 500 chars should be valid"); + }); +}); + +// --------------------------------------------------------------- +// Milestone 13: ListRunsInput archive filtering schema +// --------------------------------------------------------------- +describe("ListRunsInput archive filtering (Milestone 13)", () => { + it("should accept includeArchived=true", () => { + const schema = z.object(ListRunsInput); + const result = schema.safeParse({ includeArchived: true }); + assert.ok(result.success, "includeArchived=true should be valid"); + assert.strictEqual(result.data?.includeArchived, true); + }); + + it("should accept archivedOnly=true", () => { + const schema = z.object(ListRunsInput); + const result = schema.safeParse({ archivedOnly: true }); + assert.ok(result.success, "archivedOnly=true should be valid"); + assert.strictEqual(result.data?.archivedOnly, true); + }); + + it("should accept both flags together", () => { + const schema = z.object(ListRunsInput); + const result = schema.safeParse({ includeArchived: true, archivedOnly: true }); + assert.ok(result.success, "Both flags together should be valid schema-wise"); + }); + + it("should default both flags to undefined when omitted", () => { + const schema = z.object(ListRunsInput); + const result = schema.safeParse({}); + assert.ok(result.success, "Empty input should be valid"); + assert.strictEqual(result.data?.includeArchived, undefined); + assert.strictEqual(result.data?.archivedOnly, undefined); + }); + + it("should reject non-boolean includeArchived", () => { + const schema = z.object(ListRunsInput); + const result = schema.safeParse({ includeArchived: "yes" }); + assert.ok(!result.success, "Non-boolean includeArchived should fail validation"); + }); +}); + +// --------------------------------------------------------------- +// Milestone 13: No-hidden-agent regression for archive_run +// --------------------------------------------------------------- +describe("No-hidden-agent regression (Milestone 13)", () => { + it("archive_run should be registered as a lifecycle tool", () => { + assert.ok( + REGISTERED_TOOL_NAMES.includes("archive_run" as (typeof REGISTERED_TOOL_NAMES)[number]), + "archive_run must be in the tool registry", + ); + }); + + it("archive_run is not an autonomous continuation tool", () => { + const coarsePatterns = ["continue", "resume", "agent", "turn", "codex_reply", "fix_end"]; + for (const pattern of coarsePatterns) { + assert.ok( + !"archive_run".includes(pattern), + `"archive_run" must not contain autonomous pattern "${pattern}"`, + ); + } + }); + + it("daemon method run.archive is not a forbidden agent-runtime method", () => { + const forbiddenMethods = [ + "turn/start", + "turn/steer", + "review/start", + "codex", + "codex-reply", + "continue_run", + "resume_thread", + "agent_step", + "fix_end_to_end", + ]; + for (const method of forbiddenMethods) { + assert.ok( + method !== "run.archive", + `run.archive must not be a forbidden agent-runtime method`, + ); + } + }); +}); diff --git a/apps/chatgpt-mcp/src/schemas.ts b/apps/chatgpt-mcp/src/schemas.ts index 6e6a87600031..800072a9b56a 100644 --- a/apps/chatgpt-mcp/src/schemas.ts +++ b/apps/chatgpt-mcp/src/schemas.ts @@ -210,7 +210,7 @@ export const ApproveActionInput = { }; // --------------------------------------------------------------- -// list_runs (Milestone 7) +// list_runs (Milestone 7; extended in Milestone 13) // --------------------------------------------------------------- export const ListRunsInput = { limit: z @@ -228,6 +228,19 @@ export const ListRunsInput = { .string() .optional() .describe("Filter by run status (e.g. active, done, blocked)"), + // Milestone 13: archive filtering + includeArchived: z + .boolean() + .optional() + .describe( + "When true, include archived runs alongside non-archived runs in the results. Default: false (archived runs are excluded).", + ), + archivedOnly: z + .boolean() + .optional() + .describe( + "When true, return only archived runs. Takes precedence over includeArchived.", + ), }; // --------------------------------------------------------------- @@ -351,3 +364,26 @@ export const SupersedeRunInput = { "Human-readable reason for supersession (required for auditability)", ), }; + +// --------------------------------------------------------------- +// archive_run (Milestone 13) +// +// Explicitly archives a finalized run so it remains preserved and +// inspectable but is excluded from the default active run listing. +// Only finalized runs (completed, failed, abandoned) may be archived. +// Archiving is deterministic and audited; it does not execute work. +// --------------------------------------------------------------- +export const ArchiveRunInput = { + runId: z + .string() + .describe( + "Run ID of the finalized run to archive (must be finalized: completed, failed, or abandoned)", + ), + reason: z + .string() + .min(1) + .max(500) + .describe( + "Human-readable reason for archiving (required for auditability)", + ), +}; diff --git a/apps/chatgpt-mcp/src/tools.ts b/apps/chatgpt-mcp/src/tools.ts index 5e9d73ae337b..8124f9a003cc 100644 --- a/apps/chatgpt-mcp/src/tools.ts +++ b/apps/chatgpt-mcp/src/tools.ts @@ -31,6 +31,7 @@ import { FinalizeRunInput, ReopenRunInput, SupersedeRunInput, + ArchiveRunInput, } from "./schemas.js"; /** @@ -77,6 +78,8 @@ export const REGISTERED_TOOL_NAMES = [ "reopen_run", // Milestone 12: deterministic run supersession "supersede_run", + // Milestone 13: deterministic run archiving + "archive_run", ] as const; export function registerTools(server: McpServer, client: DaemonClient): void { @@ -269,16 +272,18 @@ export function registerTools(server: McpServer, client: DaemonClient): void { }, ); - // ---- list_runs (Milestone 7) ---- + // ---- list_runs (Milestone 7; extended in Milestone 13) ---- server.tool( "list_runs", - "List known runs with their status and metadata (read-only)", + "List known runs with status and metadata (read-only). Supports archive filtering via optional parameters: set includeArchived=true to include archived runs, or archivedOnly=true to return only archived runs.", ListRunsInput, async (params) => { const result = await client.call("runs.list", { limit: params.limit, workspaceId: params.workspaceId, status: params.status, + includeArchived: params.includeArchived, + archivedOnly: params.archivedOnly, }); return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], @@ -401,4 +406,20 @@ export function registerTools(server: McpServer, client: DaemonClient): void { }; }, ); + + // ---- archive_run (Milestone 13) ---- + server.tool( + "archive_run", + "Explicitly archive a finalized run so it remains preserved and inspectable but is excluded from the default active run listing. Only finalized runs (completed, failed, or abandoned) may be archived. Archiving is deterministic and audited; it does not execute work or reopen the run. Archived runs can still be read via get_run_state, get_run_history, and list_runs (with includeArchived=true or archivedOnly=true).", + ArchiveRunInput, + async (params) => { + const result = await client.call("run.archive", { + runId: params.runId, + reason: params.reason, + }); + return { + content: [{ type: "text", text: JSON.stringify(result, null, 2) }], + }; + }, + ); } diff --git a/codex-rs/deterministic-core/src/approval.rs b/codex-rs/deterministic-core/src/approval.rs index 3f1bcecdea12..b5f7f899eeb4 100644 --- a/codex-rs/deterministic-core/src/approval.rs +++ b/codex-rs/deterministic-core/src/approval.rs @@ -204,6 +204,7 @@ mod tests { superseded_by_run_id: None, supersession_reason: None, superseded_at: None, + archive_metadata: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/lib.rs b/codex-rs/deterministic-core/src/lib.rs index e790ec9f1078..145f968b035c 100644 --- a/codex-rs/deterministic-core/src/lib.rs +++ b/codex-rs/deterministic-core/src/lib.rs @@ -11,6 +11,7 @@ pub mod git_diff; pub mod git_status; pub mod code_search; pub mod patch_apply; +pub mod run_archive; pub mod run_finalize; pub mod run_prepare; pub mod run_refresh; diff --git a/codex-rs/deterministic-core/src/run_archive.rs b/codex-rs/deterministic-core/src/run_archive.rs new file mode 100644 index 000000000000..9f6da2f3775f --- /dev/null +++ b/codex-rs/deterministic-core/src/run_archive.rs @@ -0,0 +1,329 @@ +//! Handler logic for `run.archive`. +//! +//! Provides a deterministic, explicit archiving surface for ChatGPT to mark a +//! finalized run as archived. Archived runs remain fully preserved and +//! inspectable; archiving merely distinguishes historical runs from the active +//! working set. Only finalized runs may be archived. + +use anyhow::{bail, Result}; +use deterministic_protocol::{ArchiveMetadata, RunArchiveParams, RunArchiveResult, RunState}; + +/// Archive a finalized run. +/// +/// Deterministic lifecycle rules: +/// - Only finalized runs (`status` starts with `"finalized:"`) may be archived. +/// - Active, prepared, or awaiting-approval runs are rejected. +/// - Already-archived runs are rejected (idempotent-safe rejection with a clear error). +/// - Archiving does not execute work. +/// - Archiving does not clear plan, completed steps, audit history, or finalized outcome. +/// - Archive metadata is appended to the run state. +/// +/// Returns the updated run state. +pub fn archive( + params: &RunArchiveParams, + state: &mut RunState, +) -> Result { + // Enforce: only finalized runs can be archived. + if !state.status.starts_with("finalized:") { + bail!( + "run '{}' cannot be archived: status is '{}' (only finalized runs may be archived)", + params.run_id, + state.status + ); + } + + // Reject if already archived. + if state.archive_metadata.is_some() { + bail!( + "run '{}' is already archived", + params.run_id + ); + } + + let now = chrono::Utc::now().to_rfc3339(); + + // Record compact archive metadata on the run state. + state.archive_metadata = Some(ArchiveMetadata { + reason: params.reason.clone(), + archived_at: now.clone(), + }); + state.updated_at = now.clone(); + + Ok(RunArchiveResult { + run_id: params.run_id.clone(), + status: state.status.clone(), + archived_at: now, + reason: params.reason.clone(), + message: format!( + "Run '{}' archived. It remains preserved and inspectable but will be excluded from the default active list.", + params.run_id + ), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use deterministic_protocol::{RunOutcome, RunPolicy}; + + fn make_state(run_id: &str, status: &str) -> RunState { + RunState { + run_id: run_id.into(), + workspace_id: "/tmp/ws".into(), + user_goal: "fix bug".into(), + status: status.into(), + plan: vec!["step 1".into()], + current_step: 0, + completed_steps: vec![], + pending_steps: vec!["step 1".into()], + last_action: None, + last_observation: None, + recommended_next_action: None, + recommended_tool: None, + latest_diff_summary: None, + latest_test_result: None, + focus_paths: vec![], + warnings: vec![], + retryable_action: None, + policy_profile: RunPolicy::default(), + finalized_outcome: None, + reopen_metadata: None, + supersedes_run_id: None, + superseded_by_run_id: None, + supersession_reason: None, + superseded_at: None, + archive_metadata: None, + created_at: "2024-01-01T00:00:00Z".into(), + updated_at: "2024-01-01T00:00:00Z".into(), + } + } + + fn make_finalized_state(run_id: &str, outcome_kind: &str) -> RunState { + let mut s = make_state(run_id, &format!("finalized:{outcome_kind}")); + s.finalized_outcome = Some(RunOutcome { + outcome_kind: outcome_kind.into(), + summary: "Done".into(), + reason: None, + finalized_at: "2024-01-01T01:00:00Z".into(), + }); + s + } + + // ----------------------------------------------------------------------- + // Happy-path: archive a completed run + // ----------------------------------------------------------------------- + + #[test] + fn archive_completed_run_succeeds() { + let mut state = make_finalized_state("run-c", "completed"); + let params = RunArchiveParams { + run_id: "run-c".into(), + reason: "Completed long ago; archiving for hygiene".into(), + }; + let result = archive(¶ms, &mut state).unwrap(); + + assert_eq!(result.run_id, "run-c"); + assert!(result.status.starts_with("finalized:")); + assert!(!result.archived_at.is_empty()); + assert_eq!(result.reason, "Completed long ago; archiving for hygiene"); + assert!(!result.message.is_empty()); + + // State must carry archive_metadata. + let meta = state.archive_metadata.as_ref().expect("archive_metadata must be set"); + assert_eq!(meta.reason, "Completed long ago; archiving for hygiene"); + assert!(!meta.archived_at.is_empty()); + + // Status must not change. + assert_eq!(state.status, "finalized:completed"); + + // Finalized outcome must be preserved. + assert!(state.finalized_outcome.is_some()); + } + + // ----------------------------------------------------------------------- + // Happy-path: archive a failed run + // ----------------------------------------------------------------------- + + #[test] + fn archive_failed_run_succeeds() { + let mut state = make_finalized_state("run-f", "failed"); + let params = RunArchiveParams { + run_id: "run-f".into(), + reason: "Failed build archived".into(), + }; + let result = archive(¶ms, &mut state).unwrap(); + assert_eq!(result.run_id, "run-f"); + assert!(state.archive_metadata.is_some()); + // Original finalized outcome preserved. + assert!(state.finalized_outcome.is_some()); + } + + // ----------------------------------------------------------------------- + // Happy-path: archive an abandoned run + // ----------------------------------------------------------------------- + + #[test] + fn archive_abandoned_run_succeeds() { + let mut state = make_finalized_state("run-a", "abandoned"); + let params = RunArchiveParams { + run_id: "run-a".into(), + reason: "Abandoned; keeping for records".into(), + }; + let result = archive(¶ms, &mut state).unwrap(); + assert_eq!(result.run_id, "run-a"); + assert!(state.archive_metadata.is_some()); + } + + // ----------------------------------------------------------------------- + // Happy-path: archive a superseded run (still finalized status) + // ----------------------------------------------------------------------- + + #[test] + fn archive_superseded_run_succeeds() { + let mut state = make_finalized_state("run-s", "completed"); + state.superseded_by_run_id = Some("run-s-v2".into()); + state.supersession_reason = Some("Replaced by v2".into()); + state.superseded_at = Some("2024-01-02T00:00:00Z".into()); + + let params = RunArchiveParams { + run_id: "run-s".into(), + reason: "Superseded run archived".into(), + }; + let result = archive(¶ms, &mut state).unwrap(); + assert_eq!(result.run_id, "run-s"); + assert!(state.archive_metadata.is_some()); + // Supersession lineage must be preserved. + assert_eq!(state.superseded_by_run_id.as_deref(), Some("run-s-v2")); + } + + // ----------------------------------------------------------------------- + // Reject: active run cannot be archived + // ----------------------------------------------------------------------- + + #[test] + fn archive_active_run_rejected() { + let mut state = make_state("run-act", "active"); + let params = RunArchiveParams { + run_id: "run-act".into(), + reason: "trying".into(), + }; + let err = archive(¶ms, &mut state).unwrap_err(); + assert!(err.to_string().contains("cannot be archived")); + assert!(err.to_string().contains("active")); + // State must not be mutated. + assert!(state.archive_metadata.is_none()); + assert_eq!(state.status, "active"); + } + + // ----------------------------------------------------------------------- + // Reject: prepared run cannot be archived + // ----------------------------------------------------------------------- + + #[test] + fn archive_prepared_run_rejected() { + let mut state = make_state("run-prep", "prepared"); + let params = RunArchiveParams { + run_id: "run-prep".into(), + reason: "trying".into(), + }; + let err = archive(¶ms, &mut state).unwrap_err(); + assert!(err.to_string().contains("cannot be archived")); + assert!(err.to_string().contains("prepared")); + assert!(state.archive_metadata.is_none()); + } + + // ----------------------------------------------------------------------- + // Reject: awaiting-approval run cannot be archived + // ----------------------------------------------------------------------- + + #[test] + fn archive_awaiting_approval_run_rejected() { + let mut state = make_state("run-aa", "awaiting_approval"); + let params = RunArchiveParams { + run_id: "run-aa".into(), + reason: "trying".into(), + }; + let err = archive(¶ms, &mut state).unwrap_err(); + assert!(err.to_string().contains("cannot be archived")); + assert!(state.archive_metadata.is_none()); + } + + // ----------------------------------------------------------------------- + // Reject: already-archived run cannot be archived again + // ----------------------------------------------------------------------- + + #[test] + fn archive_already_archived_run_rejected() { + let mut state = make_finalized_state("run-dup", "completed"); + state.archive_metadata = Some(ArchiveMetadata { + reason: "first archive".into(), + archived_at: "2024-01-01T02:00:00Z".into(), + }); + + let params = RunArchiveParams { + run_id: "run-dup".into(), + reason: "archiving again".into(), + }; + let err = archive(¶ms, &mut state).unwrap_err(); + assert!(err.to_string().contains("already archived")); + // Original archive metadata must not be overwritten. + assert_eq!(state.archive_metadata.as_ref().unwrap().reason, "first archive"); + } + + // ----------------------------------------------------------------------- + // Plan, completed steps, and finalized outcome are preserved after archiving + // ----------------------------------------------------------------------- + + #[test] + fn archive_preserves_run_history() { + let mut state = make_finalized_state("run-hist", "completed"); + state.plan = vec!["step A".into(), "step B".into()]; + state.completed_steps = vec!["step A".into(), "step B".into()]; + + let params = RunArchiveParams { + run_id: "run-hist".into(), + reason: "History preservation test".into(), + }; + archive(¶ms, &mut state).unwrap(); + + // Plan and completed steps must not be cleared. + assert_eq!(state.plan, vec!["step A", "step B"]); + assert_eq!(state.completed_steps, vec!["step A", "step B"]); + // Finalized outcome must still be present. + assert!(state.finalized_outcome.is_some()); + } + + // ----------------------------------------------------------------------- + // Status is unchanged after archiving + // ----------------------------------------------------------------------- + + #[test] + fn archive_does_not_change_status() { + for outcome_kind in &["completed", "failed", "abandoned"] { + let mut state = make_finalized_state("run-st", outcome_kind); + let expected_status = state.status.clone(); + let params = RunArchiveParams { + run_id: "run-st".into(), + reason: "status test".into(), + }; + archive(¶ms, &mut state).unwrap(); + assert_eq!(state.status, expected_status, "status must not change after archiving"); + } + } + + // ----------------------------------------------------------------------- + // updated_at is refreshed after archiving + // ----------------------------------------------------------------------- + + #[test] + fn archive_updates_updated_at() { + let mut state = make_finalized_state("run-ts", "completed"); + let params = RunArchiveParams { + run_id: "run-ts".into(), + reason: "timestamp test".into(), + }; + archive(¶ms, &mut state).unwrap(); + // updated_at must be a non-empty ISO 8601 timestamp after archiving. + assert!(!state.updated_at.is_empty()); + } +} diff --git a/codex-rs/deterministic-core/src/run_finalize.rs b/codex-rs/deterministic-core/src/run_finalize.rs index de003653eac9..ead2a6ed9f8e 100644 --- a/codex-rs/deterministic-core/src/run_finalize.rs +++ b/codex-rs/deterministic-core/src/run_finalize.rs @@ -92,6 +92,7 @@ mod tests { superseded_by_run_id: None, supersession_reason: None, superseded_at: None, + archive_metadata: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_prepare.rs b/codex-rs/deterministic-core/src/run_prepare.rs index 7e23b9cf3b17..0e048a49ef89 100644 --- a/codex-rs/deterministic-core/src/run_prepare.rs +++ b/codex-rs/deterministic-core/src/run_prepare.rs @@ -86,6 +86,7 @@ pub fn prepare(params: &RunPrepareParams) -> Result<(RunPrepareResult, RunState) superseded_by_run_id: None, supersession_reason: None, superseded_at: None, + archive_metadata: None, created_at: now.clone(), updated_at: now, }; diff --git a/codex-rs/deterministic-core/src/run_refresh.rs b/codex-rs/deterministic-core/src/run_refresh.rs index 33a3ffe855f4..bd2e0f93a68a 100644 --- a/codex-rs/deterministic-core/src/run_refresh.rs +++ b/codex-rs/deterministic-core/src/run_refresh.rs @@ -93,6 +93,7 @@ mod tests { superseded_by_run_id: None, supersession_reason: None, superseded_at: None, + archive_metadata: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_reopen.rs b/codex-rs/deterministic-core/src/run_reopen.rs index ca693c09a719..d418b63b7991 100644 --- a/codex-rs/deterministic-core/src/run_reopen.rs +++ b/codex-rs/deterministic-core/src/run_reopen.rs @@ -119,6 +119,7 @@ mod tests { superseded_by_run_id: None, supersession_reason: None, superseded_at: None, + archive_metadata: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_replan.rs b/codex-rs/deterministic-core/src/run_replan.rs index 53577d0205aa..ad235e1f9dc6 100644 --- a/codex-rs/deterministic-core/src/run_replan.rs +++ b/codex-rs/deterministic-core/src/run_replan.rs @@ -203,6 +203,7 @@ mod tests { superseded_by_run_id: None, supersession_reason: None, superseded_at: None, + archive_metadata: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-core/src/run_supersede.rs b/codex-rs/deterministic-core/src/run_supersede.rs index 09cb0099b28f..87090a5ecc61 100644 --- a/codex-rs/deterministic-core/src/run_supersede.rs +++ b/codex-rs/deterministic-core/src/run_supersede.rs @@ -78,6 +78,7 @@ pub fn supersede( superseded_by_run_id: None, supersession_reason: Some(params.reason.clone()), superseded_at: Some(now.clone()), + archive_metadata: None, created_at: now.clone(), updated_at: now.clone(), }; @@ -135,6 +136,7 @@ mod tests { superseded_by_run_id: None, supersession_reason: None, superseded_at: None, + archive_metadata: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } diff --git a/codex-rs/deterministic-daemon/src/handlers.rs b/codex-rs/deterministic-daemon/src/handlers.rs index 39e99a29efc5..e493c0e0e9bf 100644 --- a/codex-rs/deterministic-daemon/src/handlers.rs +++ b/codex-rs/deterministic-daemon/src/handlers.rs @@ -40,6 +40,8 @@ pub fn dispatch( Method::RunReopen => handle_run_reopen(params, store), // Milestone 12: deterministic run supersession Method::RunSupersede => handle_run_supersede(params, store), + // Milestone 13: deterministic run archiving + Method::RunArchive => handle_run_archive(params, store), } } @@ -411,10 +413,14 @@ fn handle_runs_list( ) -> Result<(serde_json::Value, Option)> { let p: RunsListParams = serde_json::from_value(params)?; let limit = p.limit.unwrap_or(20).min(100); + let include_archived = p.include_archived.unwrap_or(false); + let archived_only = p.archived_only.unwrap_or(false); let runs = store.list_runs( limit, p.workspace_id.as_deref(), p.status.as_deref(), + include_archived, + archived_only, )?; let count = runs.len(); let result = RunsListResult { runs, count }; @@ -444,6 +450,7 @@ fn handle_run_get( let superseded_by_run_id = state.superseded_by_run_id.clone(); let supersession_reason = state.supersession_reason.clone(); let superseded_at = state.superseded_at.clone(); + let archive_metadata = state.archive_metadata.clone(); let result = RunGetResult { run_state: state.clone(), @@ -461,6 +468,7 @@ fn handle_run_get( superseded_by_run_id, supersession_reason, superseded_at, + archive_metadata, }; Ok((serde_json::to_value(result)?, Some(state))) } @@ -706,6 +714,42 @@ fn handle_run_supersede( Ok((serde_json::to_value(result)?, Some(successor_state))) } +// ---- Milestone 13: deterministic run archiving ---- + +/// Archive an explicitly finalized run. +/// +/// Deterministic lifecycle rules (enforced in `deterministic_core::run_archive`): +/// - Only finalized runs (`status` starts with `"finalized:"`) may be archived. +/// - Active, prepared, or awaiting-approval runs are rejected. +/// - Already-archived runs are rejected. +/// - Archiving does not execute work or trigger autonomous follow-up. +/// - The run's plan, completed steps, and audit history are preserved. +/// - Archive metadata is appended to the run state and persisted. +fn handle_run_archive( + params: serde_json::Value, + store: &Store, +) -> Result<(serde_json::Value, Option)> { + let p: RunArchiveParams = serde_json::from_value(params)?; + let mut state = store + .get_run(&p.run_id)? + .ok_or_else(|| anyhow::anyhow!("unknown run: {}", p.run_id))?; + + let result = deterministic_core::run_archive::archive(&p, &mut state)?; + + // Persist the updated run state with archive metadata. + store.save_run(&state)?; + + // Audit trail: run archived. + let _ = store.append_audit_entry( + &p.run_id, + "run_archived", + &format!("Run archived: reason={}", p.reason), + Some(&format!("{{\"reason\":\"{}\"}}", p.reason)), + ); + + Ok((serde_json::to_value(result)?, Some(state))) +} + #[cfg(test)] mod tests { use super::*; @@ -738,6 +782,7 @@ mod tests { superseded_by_run_id: None, supersession_reason: None, superseded_at: None, + archive_metadata: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } @@ -1416,4 +1461,187 @@ mod tests { let succ_get: RunGetResult = serde_json::from_value(succ_val).unwrap(); assert_eq!(succ_get.supersedes_run_id.as_deref(), Some("r_sup_get_orig")); } + + // ---- Milestone 13: run.archive handler tests ---- + + #[test] + fn run_archive_completed_run_succeeds() { + let store = Store::open_in_memory().unwrap(); + let state = make_run_state("r_arch_h"); + store.save_run(&state).unwrap(); + finalize_run_in_store(&store, "r_arch_h", "completed"); + + let params = serde_json::json!({ + "runId": "r_arch_h", + "reason": "archiving completed run" + }); + let (val, run_state) = dispatch(Method::RunArchive, params, &store).unwrap(); + let result: RunArchiveResult = serde_json::from_value(val).unwrap(); + + assert_eq!(result.run_id, "r_arch_h"); + assert!(result.status.starts_with("finalized:")); + assert!(!result.archived_at.is_empty()); + assert_eq!(result.reason, "archiving completed run"); + assert!(!result.message.is_empty()); + + // run_state returned should carry archive_metadata. + let rs = run_state.unwrap(); + assert!(rs.archive_metadata.is_some()); + + // Persisted state should also carry archive_metadata. + let loaded = store.get_run("r_arch_h").unwrap().unwrap(); + let meta = loaded.archive_metadata.expect("archive_metadata must be persisted"); + assert_eq!(meta.reason, "archiving completed run"); + } + + #[test] + fn run_archive_failed_run_succeeds() { + let store = Store::open_in_memory().unwrap(); + let state = make_run_state("r_arch_fail"); + store.save_run(&state).unwrap(); + finalize_run_in_store(&store, "r_arch_fail", "failed"); + + let params = serde_json::json!({ "runId": "r_arch_fail", "reason": "failed build" }); + let (val, _) = dispatch(Method::RunArchive, params, &store).unwrap(); + let result: RunArchiveResult = serde_json::from_value(val).unwrap(); + assert_eq!(result.run_id, "r_arch_fail"); + } + + #[test] + fn run_archive_rejected_for_active_run() { + let store = Store::open_in_memory().unwrap(); + let state = make_run_state("r_arch_act"); + store.save_run(&state).unwrap(); + // Status is "active" — not eligible. + let params = serde_json::json!({ "runId": "r_arch_act", "reason": "should fail" }); + let err = dispatch(Method::RunArchive, params, &store).unwrap_err(); + assert!(err.to_string().contains("cannot be archived")); + } + + #[test] + fn run_archive_rejected_for_prepared_run() { + let store = Store::open_in_memory().unwrap(); + let mut state = make_run_state("r_arch_prep"); + state.status = "prepared".into(); + store.save_run(&state).unwrap(); + let params = serde_json::json!({ "runId": "r_arch_prep", "reason": "should fail" }); + let err = dispatch(Method::RunArchive, params, &store).unwrap_err(); + assert!(err.to_string().contains("cannot be archived")); + } + + #[test] + fn run_archive_unknown_run_returns_error() { + let store = Store::open_in_memory().unwrap(); + let params = serde_json::json!({ "runId": "no-such-run", "reason": "reason" }); + let err = dispatch(Method::RunArchive, params, &store).unwrap_err(); + assert!(err.to_string().contains("unknown run")); + } + + #[test] + fn run_archive_audit_trail_appended() { + let store = Store::open_in_memory().unwrap(); + let state = make_run_state("r_arch_audit"); + store.save_run(&state).unwrap(); + finalize_run_in_store(&store, "r_arch_audit", "completed"); + + let params = serde_json::json!({ "runId": "r_arch_audit", "reason": "audit trail test" }); + dispatch(Method::RunArchive, params, &store).unwrap(); + + let audit = store.get_audit_entries("r_arch_audit", 50).unwrap(); + let archived_event = audit.iter().find(|e| e.event_kind == "run_archived"); + assert!(archived_event.is_some(), "run_archived audit entry must be present"); + } + + #[test] + fn run_archive_visible_in_run_get() { + let store = Store::open_in_memory().unwrap(); + let state = make_run_state("r_arch_get"); + store.save_run(&state).unwrap(); + finalize_run_in_store(&store, "r_arch_get", "completed"); + + let params = serde_json::json!({ "runId": "r_arch_get", "reason": "get test" }); + dispatch(Method::RunArchive, params, &store).unwrap(); + + // run.get should expose archive_metadata. + let get_params = serde_json::json!({ "runId": "r_arch_get" }); + let (val, _) = dispatch(Method::RunGet, get_params, &store).unwrap(); + let get_result: RunGetResult = serde_json::from_value(val).unwrap(); + assert!(get_result.archive_metadata.is_some(), "archive_metadata must be visible in run.get"); + let meta = get_result.archive_metadata.unwrap(); + assert_eq!(meta.reason, "get test"); + } + + #[test] + fn run_archive_excluded_from_default_list() { + let store = Store::open_in_memory().unwrap(); + + let active_state = make_run_state("r_list_active"); + store.save_run(&active_state).unwrap(); + + let archived_state = make_run_state("r_list_arch"); + store.save_run(&archived_state).unwrap(); + finalize_run_in_store(&store, "r_list_arch", "completed"); + let arch_params = serde_json::json!({ "runId": "r_list_arch", "reason": "list test" }); + dispatch(Method::RunArchive, arch_params, &store).unwrap(); + + // Default list_runs excludes archived. + let list_params = serde_json::json!({}); + let (val, _) = dispatch(Method::RunsList, list_params, &store).unwrap(); + let result: RunsListResult = serde_json::from_value(val).unwrap(); + assert!( + result.runs.iter().any(|r| r.run_id == "r_list_active"), + "active run must be in default list" + ); + assert!( + !result.runs.iter().any(|r| r.run_id == "r_list_arch"), + "archived run must NOT be in default list" + ); + } + + #[test] + fn run_archive_visible_with_include_archived_flag() { + let store = Store::open_in_memory().unwrap(); + + let archived_state = make_run_state("r_incl_arch"); + store.save_run(&archived_state).unwrap(); + finalize_run_in_store(&store, "r_incl_arch", "completed"); + let arch_params = serde_json::json!({ "runId": "r_incl_arch", "reason": "include test" }); + dispatch(Method::RunArchive, arch_params, &store).unwrap(); + + // include_archived=true should show archived run. + let list_params = serde_json::json!({ "includeArchived": true }); + let (val, _) = dispatch(Method::RunsList, list_params, &store).unwrap(); + let result: RunsListResult = serde_json::from_value(val).unwrap(); + assert!( + result.runs.iter().any(|r| r.run_id == "r_incl_arch"), + "archived run must appear when includeArchived=true" + ); + } + + #[test] + fn run_archive_archived_only_filter() { + let store = Store::open_in_memory().unwrap(); + + let active_state = make_run_state("r_ao_active"); + store.save_run(&active_state).unwrap(); + + let archived_state = make_run_state("r_ao_arch"); + store.save_run(&archived_state).unwrap(); + finalize_run_in_store(&store, "r_ao_arch", "completed"); + let arch_params = serde_json::json!({ "runId": "r_ao_arch", "reason": "archived_only test" }); + dispatch(Method::RunArchive, arch_params, &store).unwrap(); + + // archived_only=true should return only archived run. + let list_params = serde_json::json!({ "archivedOnly": true }); + let (val, _) = dispatch(Method::RunsList, list_params, &store).unwrap(); + let result: RunsListResult = serde_json::from_value(val).unwrap(); + assert!( + !result.runs.iter().any(|r| r.run_id == "r_ao_active"), + "active run must NOT appear when archivedOnly=true" + ); + assert!( + result.runs.iter().any(|r| r.run_id == "r_ao_arch"), + "archived run must appear when archivedOnly=true" + ); + } } diff --git a/codex-rs/deterministic-daemon/src/persistence.rs b/codex-rs/deterministic-daemon/src/persistence.rs index b1b7ca44805f..665876bc5060 100644 --- a/codex-rs/deterministic-daemon/src/persistence.rs +++ b/codex-rs/deterministic-daemon/src/persistence.rs @@ -5,7 +5,7 @@ //! access — unlike the previous JSON-file approach. use anyhow::{Context, Result}; -use deterministic_protocol::{PendingApproval, ReopenMetadata, RetryableAction, RunHistoryEntry, RunOutcome, RunPolicy, RunState, RunSummary}; +use deterministic_protocol::{ArchiveMetadata, PendingApproval, ReopenMetadata, RetryableAction, RunHistoryEntry, RunOutcome, RunPolicy, RunState, RunSummary}; use rusqlite::Connection; use std::path::Path; use std::sync::Mutex; @@ -122,6 +122,9 @@ impl Store { ("runs", "superseded_by_run_id", "TEXT"), ("runs", "supersession_reason", "TEXT"), ("runs", "superseded_at", "TEXT"), + // Milestone 13 columns + ("runs", "is_archived", "INTEGER DEFAULT 0"), + ("runs", "archive_metadata", "TEXT"), ]; for (table, column, def) in migrations { @@ -195,6 +198,14 @@ impl Store { .map(serde_json::to_string) .transpose() .context("failed to serialise reopen_metadata")?; + // Milestone 13: persist archive_metadata as JSON; also extract is_archived for filtering. + let is_archived: i64 = if state.archive_metadata.is_some() { 1 } else { 0 }; + let archive_metadata_json: Option = state + .archive_metadata + .as_ref() + .map(serde_json::to_string) + .transpose() + .context("failed to serialise archive_metadata")?; conn.execute( "INSERT OR REPLACE INTO runs (run_id, workspace_id, user_goal, status, plan, current_step, @@ -203,8 +214,9 @@ impl Store { latest_diff_summary, latest_test_result, focus_paths, warnings, retryable_action, policy_profile, outcome_kind, finalized_outcome, reopen_metadata, supersedes_run_id, superseded_by_run_id, - supersession_reason, superseded_at, created_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27)", + supersession_reason, superseded_at, is_archived, archive_metadata, + created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29)", rusqlite::params![ state.run_id, state.workspace_id, @@ -231,6 +243,8 @@ impl Store { state.superseded_by_run_id, state.supersession_reason, state.superseded_at, + is_archived, + archive_metadata_json, state.created_at, state.updated_at, ], @@ -251,7 +265,8 @@ impl Store { latest_diff_summary, latest_test_result, focus_paths, warnings, retryable_action, policy_profile, finalized_outcome, reopen_metadata, supersedes_run_id, superseded_by_run_id, - supersession_reason, superseded_at, created_at, updated_at + supersession_reason, superseded_at, archive_metadata, + created_at, updated_at FROM runs WHERE run_id = ?1", ) .context("failed to prepare statement")?; @@ -369,6 +384,20 @@ impl Store { let supersession_reason: Option = row.get(22)?; let superseded_at: Option = row.get(23)?; + // Milestone 13: archive metadata. + let archive_metadata_json: Option = row.get(24)?; + let archive_metadata: Option = archive_metadata_json + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 24, + rusqlite::types::Type::Text, + Box::new(e), + ) + })?; + Ok(RunState { run_id: row.get(0)?, workspace_id: row.get(1)?, @@ -394,8 +423,9 @@ impl Store { superseded_by_run_id, supersession_reason, superseded_at, - created_at: row.get(24)?, - updated_at: row.get(25)?, + archive_metadata, + created_at: row.get(25)?, + updated_at: row.get(26)?, }) }) .context("failed to query run")?; @@ -534,22 +564,42 @@ impl Store { limit: usize, workspace_id: Option<&str>, status_filter: Option<&str>, + include_archived: bool, + archived_only: bool, ) -> Result> { let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("{e}"))?; - // Build query dynamically based on optional filters. - let mut conditions = Vec::new(); + // Build WHERE conditions and bind parameters dynamically. + // Parameter ?1 is always `limit` (i64). Additional parameters start at ?2. + let mut conditions: Vec = Vec::new(); + let mut next_param: usize = 2; // ?1 is limit + + // Track which positional index each optional param maps to. + let mut workspace_idx: Option = None; + let mut status_idx: Option = None; + if workspace_id.is_some() { - conditions.push("workspace_id = ?2"); + workspace_idx = Some(next_param); + conditions.push(format!("workspace_id = ?{next_param}")); + next_param += 1; } if status_filter.is_some() { - let idx = if workspace_id.is_some() { 3 } else { 2 }; - conditions.push(match idx { - 2 => "status = ?2", - _ => "status = ?3", - }); + status_idx = Some(next_param); + conditions.push(format!("status = ?{next_param}")); + // next_param would be incremented here if additional dynamic params were added. + let _ = next_param; } + // Milestone 13: archive filtering. + // archived_only takes precedence over include_archived. + if archived_only { + conditions.push("is_archived = 1".to_string()); + } else if !include_archived { + // Default: exclude archived runs. + conditions.push("is_archived = 0".to_string()); + } + // If include_archived=true and archived_only=false, no condition is added (show all). + let where_clause = if conditions.is_empty() { String::new() } else { @@ -559,7 +609,7 @@ impl Store { let sql = format!( "SELECT run_id, workspace_id, user_goal, status, current_step, plan, created_at, updated_at, outcome_kind, reopen_metadata, - supersedes_run_id, superseded_by_run_id + supersedes_run_id, superseded_by_run_id, is_archived, archive_metadata FROM runs {where_clause} ORDER BY updated_at DESC LIMIT ?1" @@ -573,6 +623,7 @@ impl Store { // 4: current_step 5: plan 6: created_at 7: updated_at // 8: outcome_kind 9: reopen_metadata // 10: supersedes_run_id 11: superseded_by_run_id + // 12: is_archived 13: archive_metadata let plan_json: String = row.get(5)?; let total_steps: usize = serde_json::from_str::>(&plan_json) .map(|v| v.len()) @@ -583,6 +634,15 @@ impl Store { .as_deref() .and_then(|s| serde_json::from_str::(s).ok()) .map(|m| m.reopen_count); + // Milestone 13: archive summary fields. + let is_archived_int: i64 = row.get(12)?; + let is_archived = if is_archived_int != 0 { Some(true) } else { None }; + let archive_metadata_json: Option = row.get(13)?; + let (archive_reason, archived_at) = archive_metadata_json + .as_deref() + .and_then(|s| serde_json::from_str::(s).ok()) + .map(|m| (Some(m.reason), Some(m.archived_at))) + .unwrap_or((None, None)); Ok(RunSummary { run_id: row.get(0)?, workspace_id: row.get(1)?, @@ -594,23 +654,27 @@ impl Store { reopen_count, supersedes_run_id: row.get(10)?, superseded_by_run_id: row.get(11)?, + is_archived, + archive_reason, + archived_at, created_at: row.get(6)?, updated_at: row.get(7)?, }) }; - let rows = match (workspace_id, status_filter) { + // Execute with the appropriate parameter combination. + let rows = match (workspace_idx, status_idx) { (None, None) => stmt .query_map(rusqlite::params![limit as i64], mapper) .context("failed to query runs")?, - (Some(ws), None) => stmt - .query_map(rusqlite::params![limit as i64, ws], mapper) + (Some(_), None) => stmt + .query_map(rusqlite::params![limit as i64, workspace_id], mapper) .context("failed to query runs")?, - (None, Some(st)) => stmt - .query_map(rusqlite::params![limit as i64, st], mapper) + (None, Some(_)) => stmt + .query_map(rusqlite::params![limit as i64, status_filter], mapper) .context("failed to query runs")?, - (Some(ws), Some(st)) => stmt - .query_map(rusqlite::params![limit as i64, ws, st], mapper) + (Some(_), Some(_)) => stmt + .query_map(rusqlite::params![limit as i64, workspace_id, status_filter], mapper) .context("failed to query runs")?, }; @@ -711,6 +775,7 @@ mod tests { superseded_by_run_id: None, supersession_reason: None, superseded_at: None, + archive_metadata: None, created_at: "2024-01-01T00:00:00Z".into(), updated_at: "2024-01-01T00:00:00Z".into(), } @@ -1287,7 +1352,7 @@ mod tests { #[test] fn list_runs_empty() { let store = Store::open_in_memory().unwrap(); - let runs = store.list_runs(20, None, None).unwrap(); + let runs = store.list_runs(20, None, None, false, false).unwrap(); assert!(runs.is_empty()); } @@ -1298,7 +1363,7 @@ mod tests { store.save_run(&make_run_state("r_b", "prepared")).unwrap(); store.save_run(&make_run_state("r_c", "done")).unwrap(); - let runs = store.list_runs(20, None, None).unwrap(); + let runs = store.list_runs(20, None, None, false, false).unwrap(); assert_eq!(runs.len(), 3); // Each summary should have basic fields. for r in &runs { @@ -1316,7 +1381,7 @@ mod tests { .save_run(&make_run_state(&format!("r_{i}"), "active")) .unwrap(); } - let runs = store.list_runs(3, None, None).unwrap(); + let runs = store.list_runs(3, None, None, false, false).unwrap(); assert_eq!(runs.len(), 3); } @@ -1327,10 +1392,10 @@ mod tests { store.save_run(&make_run_state("r_b", "active")).unwrap(); store.save_run(&make_run_state("r_c", "done")).unwrap(); - let active = store.list_runs(20, None, Some("active")).unwrap(); + let active = store.list_runs(20, None, Some("active"), false, false).unwrap(); assert_eq!(active.len(), 2); - let done = store.list_runs(20, None, Some("done")).unwrap(); + let done = store.list_runs(20, None, Some("done"), false, false).unwrap(); assert_eq!(done.len(), 1); } @@ -1347,10 +1412,10 @@ mod tests { store.save_run(&s2).unwrap(); store.save_run(&s3).unwrap(); - let ws1 = store.list_runs(20, Some("/ws/one"), None).unwrap(); + let ws1 = store.list_runs(20, Some("/ws/one"), None, false, false).unwrap(); assert_eq!(ws1.len(), 2); - let ws2 = store.list_runs(20, Some("/ws/two"), None).unwrap(); + let ws2 = store.list_runs(20, Some("/ws/two"), None, false, false).unwrap(); assert_eq!(ws2.len(), 1); } @@ -1360,7 +1425,7 @@ mod tests { let state = make_run_state("r_steps", "active"); // plan has 2 steps store.save_run(&state).unwrap(); - let runs = store.list_runs(20, None, None).unwrap(); + let runs = store.list_runs(20, None, None, false, false).unwrap(); assert_eq!(runs.len(), 1); assert_eq!(runs[0].total_steps, 2); } @@ -1746,7 +1811,7 @@ mod tests { }); store.save_run(&finalized).unwrap(); - let runs = store.list_runs(20, None, None).unwrap(); + let runs = store.list_runs(20, None, None, false, false).unwrap(); assert_eq!(runs.len(), 2); let active = runs.iter().find(|r| r.run_id == "r_lk_a").unwrap(); @@ -1947,7 +2012,7 @@ mod tests { }); store.save_run(&state_reopened).unwrap(); - let summaries = store.list_runs(10, None, None).unwrap(); + let summaries = store.list_runs(10, None, None, false, false).unwrap(); let s1 = summaries.iter().find(|s| s.run_id == "r_list_1").unwrap(); let s2 = summaries.iter().find(|s| s.run_id == "r_list_2").unwrap(); assert!(s1.reopen_count.is_none()); @@ -2020,7 +2085,7 @@ mod tests { state_successor.supersedes_run_id = Some("r_superseded".into()); store.save_run(&state_successor).unwrap(); - let summaries = store.list_runs(10, None, None).unwrap(); + let summaries = store.list_runs(10, None, None, false, false).unwrap(); let plain = summaries.iter().find(|s| s.run_id == "r_plain").unwrap(); let superseded = summaries.iter().find(|s| s.run_id == "r_superseded").unwrap(); let successor = summaries.iter().find(|s| s.run_id == "r_new").unwrap(); @@ -2090,6 +2155,203 @@ mod tests { assert!(loaded.superseded_by_run_id.is_none()); assert!(loaded.supersession_reason.is_none()); assert!(loaded.superseded_at.is_none()); + // M13 columns must also have safe defaults. + assert!(loaded.archive_metadata.is_none()); + } + + // ---- Milestone 13: archive metadata persistence tests ---- + + /// Verify that archive_metadata is correctly persisted and retrieved. + #[test] + fn archive_metadata_roundtrip() { + use deterministic_protocol::ArchiveMetadata; + + let store = Store::open_in_memory().unwrap(); + let mut state = make_run_state("r_arch", "finalized:completed"); + state.archive_metadata = Some(ArchiveMetadata { + reason: "Archiving completed run".into(), + archived_at: "2024-09-01T10:00:00Z".into(), + }); + store.save_run(&state).unwrap(); + + let loaded = store.get_run("r_arch").unwrap().unwrap(); + let meta = loaded.archive_metadata.expect("archive_metadata must be present"); + assert_eq!(meta.reason, "Archiving completed run"); + assert_eq!(meta.archived_at, "2024-09-01T10:00:00Z"); + } + + /// Verify that archive_metadata defaults to None for unarchived runs. + #[test] + fn archive_metadata_defaults_to_none() { + let store = Store::open_in_memory().unwrap(); + let state = make_run_state("r_noarch", "active"); + store.save_run(&state).unwrap(); + + let loaded = store.get_run("r_noarch").unwrap().unwrap(); + assert!(loaded.archive_metadata.is_none()); + } + + /// Verify list_runs excludes archived runs by default. + #[test] + fn list_runs_excludes_archived_by_default() { + use deterministic_protocol::ArchiveMetadata; + + let store = Store::open_in_memory().unwrap(); + + let active = make_run_state("r_active", "active"); + store.save_run(&active).unwrap(); + + let mut archived = make_run_state("r_archived", "finalized:completed"); + archived.archive_metadata = Some(ArchiveMetadata { + reason: "hygiene".into(), + archived_at: "2024-09-01T10:00:00Z".into(), + }); + store.save_run(&archived).unwrap(); + + // Default: exclude archived. + let runs = store.list_runs(20, None, None, false, false).unwrap(); + assert!( + runs.iter().any(|r| r.run_id == "r_active"), + "active run must be included" + ); + assert!( + !runs.iter().any(|r| r.run_id == "r_archived"), + "archived run must be excluded by default" + ); + } + + /// Verify list_runs includes archived runs when include_archived=true. + #[test] + fn list_runs_include_archived_shows_all() { + use deterministic_protocol::ArchiveMetadata; + + let store = Store::open_in_memory().unwrap(); + + let active = make_run_state("r_active2", "active"); + store.save_run(&active).unwrap(); + + let mut archived = make_run_state("r_archived2", "finalized:completed"); + archived.archive_metadata = Some(ArchiveMetadata { + reason: "include test".into(), + archived_at: "2024-09-01T10:00:00Z".into(), + }); + store.save_run(&archived).unwrap(); + + let runs = store.list_runs(20, None, None, true, false).unwrap(); + assert!( + runs.iter().any(|r| r.run_id == "r_active2"), + "active run must be included" + ); + assert!( + runs.iter().any(|r| r.run_id == "r_archived2"), + "archived run must be included when include_archived=true" + ); + } + + /// Verify list_runs returns only archived runs when archived_only=true. + #[test] + fn list_runs_archived_only() { + use deterministic_protocol::ArchiveMetadata; + + let store = Store::open_in_memory().unwrap(); + + let active = make_run_state("r_active3", "active"); + store.save_run(&active).unwrap(); + + let mut archived = make_run_state("r_archived3", "finalized:completed"); + archived.archive_metadata = Some(ArchiveMetadata { + reason: "only test".into(), + archived_at: "2024-09-01T10:00:00Z".into(), + }); + store.save_run(&archived).unwrap(); + + let runs = store.list_runs(20, None, None, false, true).unwrap(); + assert!( + !runs.iter().any(|r| r.run_id == "r_active3"), + "active run must NOT be included" + ); + assert!( + runs.iter().any(|r| r.run_id == "r_archived3"), + "archived run must be included when archived_only=true" + ); + } + + /// Verify list_runs RunSummary carries archive fields when present. + #[test] + fn list_runs_summary_carries_archive_fields() { + use deterministic_protocol::ArchiveMetadata; + + let store = Store::open_in_memory().unwrap(); + let mut state = make_run_state("r_arch_sum", "finalized:completed"); + state.archive_metadata = Some(ArchiveMetadata { + reason: "summary field test".into(), + archived_at: "2024-09-02T12:00:00Z".into(), + }); + store.save_run(&state).unwrap(); + + let runs = store.list_runs(20, None, None, true, false).unwrap(); + let summary = runs.iter().find(|r| r.run_id == "r_arch_sum").unwrap(); + assert_eq!(summary.is_archived, Some(true)); + assert_eq!(summary.archive_reason.as_deref(), Some("summary field test")); + assert_eq!(summary.archived_at.as_deref(), Some("2024-09-02T12:00:00Z")); + } + + /// Verify that migration from an older schema (pre-M13) correctly handles + /// the new archive columns being absent (safe defaults). + #[test] + fn migration_m13_columns_default_safely() { + use rusqlite::Connection; + + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("runs.db"); + + // Simulate a Milestone 12 schema (no M13 columns). + { + let conn = Connection::open(&db_path).unwrap(); + conn.execute_batch( + "CREATE TABLE runs ( + run_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + user_goal TEXT NOT NULL, + status TEXT NOT NULL, + plan TEXT NOT NULL DEFAULT '[]', + current_step INTEGER NOT NULL DEFAULT 0, + completed_steps TEXT NOT NULL DEFAULT '[]', + pending_steps TEXT NOT NULL DEFAULT '[]', + last_action TEXT, + last_observation TEXT, + recommended_next_action TEXT, + recommended_tool TEXT, + latest_diff_summary TEXT, + latest_test_result TEXT, + focus_paths TEXT NOT NULL DEFAULT '[]', + warnings TEXT NOT NULL DEFAULT '[]', + retryable_action TEXT, + policy_profile TEXT NOT NULL DEFAULT '{}', + outcome_kind TEXT, + finalized_outcome TEXT, + reopen_metadata TEXT, + supersedes_run_id TEXT, + superseded_by_run_id TEXT, + supersession_reason TEXT, + superseded_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + );", + ).unwrap(); + conn.execute( + "INSERT INTO runs (run_id, workspace_id, user_goal, status, plan, created_at, updated_at) + VALUES ('r_m12', '/tmp/ws', 'old goal', 'finalized:completed', '[]', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z')", + [], + ).unwrap(); + } + + // Open with migration — should add M13 columns. + let store = Store::open(dir.path()).unwrap(); + let loaded = store.get_run("r_m12").unwrap().unwrap(); + + // M13 archive_metadata must default to None. + assert!(loaded.archive_metadata.is_none()); } } diff --git a/codex-rs/deterministic-protocol/src/methods.rs b/codex-rs/deterministic-protocol/src/methods.rs index c2d8771589b2..cc81e92e4022 100644 --- a/codex-rs/deterministic-protocol/src/methods.rs +++ b/codex-rs/deterministic-protocol/src/methods.rs @@ -33,6 +33,8 @@ pub enum Method { RunReopen, // Milestone 12: deterministic run supersession RunSupersede, + // Milestone 13: deterministic run archiving + RunArchive, } impl Method { @@ -63,6 +65,8 @@ impl Method { Self::RunReopen => "run.reopen", // Milestone 12 Self::RunSupersede => "run.supersede", + // Milestone 13 + Self::RunArchive => "run.archive", } } @@ -93,6 +97,8 @@ impl Method { "run.reopen" => Some(Self::RunReopen), // Milestone 12 "run.supersede" => Some(Self::RunSupersede), + // Milestone 13 + "run.archive" => Some(Self::RunArchive), _ => None, } } @@ -124,6 +130,8 @@ impl Method { Self::RunReopen, // Milestone 12 Self::RunSupersede, + // Milestone 13 + Self::RunArchive, ] } } diff --git a/codex-rs/deterministic-protocol/src/types.rs b/codex-rs/deterministic-protocol/src/types.rs index f162abd8deb8..aea5c485c38e 100644 --- a/codex-rs/deterministic-protocol/src/types.rs +++ b/codex-rs/deterministic-protocol/src/types.rs @@ -602,6 +602,9 @@ pub struct RunState { /// ISO 8601 timestamp of when the supersession occurred (Milestone 12). #[serde(default, skip_serializing_if = "Option::is_none")] pub superseded_at: Option, + /// Archive metadata if this run has been explicitly archived (Milestone 13). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_metadata: Option, pub created_at: String, pub updated_at: String, } @@ -632,6 +635,15 @@ pub struct RunSummary { /// Run ID that superseded this run, if any (Milestone 12). #[serde(default, skip_serializing_if = "Option::is_none")] pub superseded_by_run_id: Option, + /// Whether this run has been explicitly archived (Milestone 13). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub is_archived: Option, + /// Archive reason if the run has been archived (Milestone 13). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_reason: Option, + /// ISO 8601 timestamp of when this run was archived (Milestone 13). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archived_at: Option, pub created_at: String, pub updated_at: String, } @@ -648,6 +660,14 @@ pub struct RunsListParams { /// Filter by status (optional). #[serde(default)] pub status: Option, + /// When true, archived runs are included alongside non-archived runs (Milestone 13). + /// Default: false (archived runs are excluded). + #[serde(default)] + pub include_archived: Option, + /// When true, return only archived runs (Milestone 13). + /// Takes precedence over `include_archived`. + #[serde(default)] + pub archived_only: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -711,6 +731,9 @@ pub struct RunGetResult { /// ISO 8601 timestamp of when supersession occurred (Milestone 12). #[serde(default, skip_serializing_if = "Option::is_none")] pub superseded_at: Option, + /// Archive metadata if this run has been explicitly archived (Milestone 13). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_metadata: Option, } // --------------------------------------------------------------------------- @@ -952,3 +975,50 @@ pub struct RunSupersedeResult { /// Recommended MCP tool to invoke next. pub recommended_tool: String, } + +// --------------------------------------------------------------------------- +// run.archive (Milestone 13) +// --------------------------------------------------------------------------- + +/// Compact archive metadata recorded when a run is explicitly archived. +/// +/// Persisted in SQLite alongside run state. Provides an auditable record +/// of archival without duplicating the full run state. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveMetadata { + /// Human-readable reason supplied by ChatGPT for archiving. + pub reason: String, + /// ISO 8601 timestamp of when the run was archived. + pub archived_at: String, +} + +/// Parameters for `run.archive` — explicit deterministic run archiving. +/// +/// Only finalized runs (`completed`, `failed`, `abandoned`) may be archived. +/// Active, prepared, or awaiting-approval runs are rejected. +/// Archiving is deterministic and audited; it does not execute work. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunArchiveParams { + /// ID of the run to archive. + pub run_id: String, + /// Human-readable reason for archiving (required for auditability). + pub reason: String, +} + +/// Result of `run.archive`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RunArchiveResult { + /// ID of the run that was archived. + pub run_id: String, + /// Current status of the run (e.g. `"finalized:completed"`). + pub status: String, + /// ISO 8601 timestamp of when the run was archived. + pub archived_at: String, + /// Human-readable reason supplied for archiving. + pub reason: String, + /// Confirmation message. + pub message: String, +} diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 4d902552fde9..b934f88c9d60 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -450,6 +450,47 @@ Acceptance: - ✅ TypeScript remains thin - ✅ no autonomous continuation; no coarse autonomous tools +## Milestone 13: deterministic run archiving and retention controls ✅ + +**Goal:** Let ChatGPT explicitly archive eligible runs so they remain preserved and inspectable while being distinguishable from the active working set. + +**Rust (deterministic-protocol / deterministic-core / deterministic-daemon):** + +- ✅ `ArchiveMetadata` struct added to protocol types (`reason`, `archived_at`) +- ✅ `archive_metadata: Option` field on `RunState` +- ✅ `RunArchiveParams` / `RunArchiveResult` added to protocol types +- ✅ `include_archived` / `archived_only` added to `RunsListParams` +- ✅ `is_archived`, `archive_reason`, `archived_at` fields on `RunSummary` +- ✅ `archive_metadata` field on `RunGetResult` +- ✅ `Method::RunArchive` (`run.archive`) added to methods enum +- ✅ `deterministic_core::run_archive` module implements eligibility rules and archive logic +- ✅ Only finalized runs may be archived; active/prepared/awaiting-approval runs and already-archived runs are rejected +- ✅ Archiving does not execute work, reopen, supersede, or continue the run +- ✅ Archive metadata is appended to run state and persisted +- ✅ `run_archived` audit entry is appended with archive reason +- ✅ `handle_run_archive` in daemon handlers dispatches archive operation +- ✅ `handle_runs_list` passes `include_archived` / `archived_only` through to persistence +- ✅ `handle_run_get` exposes `archive_metadata` in `RunGetResult` +- ✅ SQLite persistence: `is_archived` and `archive_metadata` columns added with safe migration +- ✅ `list_runs` in persistence supports archive filtering: default excludes archived, `include_archived=true` includes all, `archived_only=true` returns only archived +- ✅ `RunSummary` carries archive fields from persistence query + +**TypeScript (MCP gateway):** + +- ✅ `ArchiveRunInput` Zod schema added to `schemas.ts` (`runId`, `reason` 1–500 chars) +- ✅ `ListRunsInput` extended with `includeArchived` and `archivedOnly` optional booleans +- ✅ `archive_run` added to `REGISTERED_TOOL_NAMES` +- ✅ `archive_run` tool registered: validates inputs, calls `run.archive`, returns result +- ✅ `list_runs` tool updated to pass `includeArchived` and `archivedOnly` to daemon +- ✅ TypeScript remains thin: validation + mapping + daemon calls only + +**Tests:** + +- ✅ Core: archiving completed/failed runs, eligibility rejection for active/prepared/awaiting-approval, already-archived, archive metadata roundtrip +- ✅ Daemon handlers: M13 tests for all archive scenarios including audit trail, run.get visibility, list filtering +- ✅ Persistence: archive metadata roundtrip, defaults to None, list filtering (default excludes, include_archived, archived_only), summary fields, migration from M12 schema +- ✅ TypeScript: `ArchiveRunInput` schema validation, `ListRunsInput` archive filter schema, no-hidden-agent regression, exact registry test updated + ## Out of scope These are intentionally not implemented: diff --git a/docs/INTERNAL_RPC.md b/docs/INTERNAL_RPC.md index 0b73a68ad84a..b26539983a75 100644 --- a/docs/INTERNAL_RPC.md +++ b/docs/INTERNAL_RPC.md @@ -163,6 +163,49 @@ Lifecycle rules enforced by the daemon: - `run_superseded` audit entry appended to the original run - `run_created_from_supersession` audit entry appended to the successor run +### Milestone 13 methods + +* `run.archive` — explicitly archive a finalized run so it remains preserved and inspectable but is excluded from the default active run listing + +Only finalized runs (`finalized:completed`, `finalized:failed`, `finalized:abandoned`) may be archived. +Active, prepared, or awaiting-approval runs cannot be archived. Already-archived runs are also rejected. +Archiving does not execute work or trigger autonomous follow-up; it appends `archiveMetadata` to the run state and an audit entry. + +#### `run.archive` params + +```json +{ + "runId": "run_abc", + "reason": "Archiving completed run for historical reference" +} +``` + +`reason` is required (min 1 character, max 500 characters) for auditability. + +Returns `RunArchiveResult`: +- `runId` — the archived run ID +- `status` — the run status at the time of archiving (unchanged) +- `archivedAt` — ISO 8601 timestamp +- `reason` — the reason provided +- `message` — human-readable confirmation + +Lifecycle rules enforced by the daemon: +- Only finalized runs may be archived; all other status values are rejected +- Already-archived runs are rejected idempotently (not silently) +- The run's plan, steps, outcome, and prior audit history are fully preserved +- `archiveMetadata` is persisted in SQLite in a single transaction +- `run_archived` audit entry appended with the archive reason + +#### Archive filtering in `runs.list` + +`runs.list` now supports two optional boolean parameters: +- `includeArchived` — when `true`, include archived runs alongside non-archived runs (default: `false`) +- `archivedOnly` — when `true`, return only archived runs (takes precedence over `includeArchived`) + +When both are omitted, archived runs are excluded by default. + +`RunSummary` now carries `isArchived`, `archiveReason`, and `archivedAt` fields (all optional). + ## Forbidden internal methods Do not implement or surface: diff --git a/docs/MCP_TOOL_CONTRACTS.md b/docs/MCP_TOOL_CONTRACTS.md index 2f000e7da4ec..d3406b60f796 100644 --- a/docs/MCP_TOOL_CONTRACTS.md +++ b/docs/MCP_TOOL_CONTRACTS.md @@ -505,6 +505,43 @@ Create a new successor run that explicitly replaces a finalized run with preserv - Does **not** execute work or trigger any autonomous follow-up - Lineage is visible in `get_run_state` and `list_runs` +## archive_run (Milestone 13) + +Explicitly archive a finalized run so it remains preserved and inspectable, but is excluded from the default active run listing. + +### Input + +- `runId`: string — Run ID of the finalized run to archive (must be `finalized:completed`, `finalized:failed`, or `finalized:abandoned`) +- `reason`: string — human-readable reason for archiving (required, 1–500 chars) + +### Returns + +- `runId` — the archived run ID +- `status` — the run status at the time of archiving (unchanged) +- `archivedAt` — ISO 8601 timestamp of archiving +- `reason` — the reason provided +- `message` — human-readable confirmation + +### Behavior + +- Only finalized runs may be archived; active, prepared, or awaiting-approval runs are rejected deterministically +- Already-archived runs are also rejected +- Archiving does **not** execute work, trigger any autonomous follow-up, or reopen the run +- The run's plan, completed steps, audit history, and outcome are fully preserved +- `archiveMetadata` is added to the run state and persisted in SQLite +- Appends `run_archived` audit entry with the archive reason +- Archived runs remain fully inspectable via `get_run_state` and `get_run_history` +- `list_runs` excludes archived runs by default; use `includeArchived=true` or `archivedOnly=true` to include them + +### `list_runs` archive filtering (Milestone 13 extension) + +The `list_runs` tool now accepts two optional boolean parameters: + +- `includeArchived` (boolean, default: false) — when `true`, include archived runs alongside non-archived runs +- `archivedOnly` (boolean, default: false) — when `true`, return only archived runs (takes precedence over `includeArchived`) + +When both are omitted, archived runs are excluded by default. + ## Forbidden public tools Do not expose: diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md index 20e77bd12b54..39745e4fd3b1 100644 --- a/docs/PROJECT_STATUS.md +++ b/docs/PROJECT_STATUS.md @@ -192,9 +192,41 @@ ChatGPT-hosted model - 12 new TypeScript tests (9 schema validation + 3 no-hidden-agent regression) - No backend model calls; no autonomous continuation; no coarse tools introduced +### Milestone 13: Deterministic Run Archiving and Retention Controls +- Added `ArchiveMetadata` struct to `deterministic-protocol` (`reason`, `archived_at`) +- Added `archive_metadata: Option` field to `RunState` +- Added `RunArchiveParams` and `RunArchiveResult` to `deterministic-protocol` +- Added `include_archived` and `archived_only` fields to `RunsListParams` +- Added `is_archived`, `archive_reason`, `archived_at` fields to `RunSummary` +- Added `archive_metadata` field to `RunGetResult` +- Added `Method::RunArchive` (`run.archive`) to the daemon method enum +- Added `deterministic_core::run_archive` module with deterministic eligibility rules: + - Only finalized runs (`finalized:completed`, `finalized:failed`, `finalized:abandoned`) may be archived + - Active, prepared, or awaiting-approval runs are rejected deterministically + - Already-archived runs are rejected idempotently (not silently) + - Archiving does not execute work, reopen, supersede, or continue the run + - Archive metadata is applied to run state and persisted +- Added `handle_run_archive` in daemon handlers +- Updated `handle_runs_list` to pass `include_archived` / `archived_only` through to persistence +- Updated `handle_run_get` to expose `archive_metadata` in `RunGetResult` +- SQLite: added `is_archived INTEGER DEFAULT 0` and `archive_metadata TEXT` columns with backward-compatible migration (M13) +- `list_runs` persistence function updated: default excludes archived; `include_archived=true` includes all; `archived_only=true` returns only archived +- `RunSummary` populated with `is_archived`, `archive_reason`, `archived_at` from persistence query +- `run_archived` audit entry appended with archive reason on successful archive +- Added `ArchiveRunInput` Zod schema in TypeScript (`runId`, `reason` 1–500 chars) +- Extended `ListRunsInput` Zod schema with `includeArchived` and `archivedOnly` optional booleans +- Added `archive_run` to `REGISTERED_TOOL_NAMES` and registered the MCP tool +- Updated `list_runs` tool to pass archive filtering params to daemon +- TypeScript remains thin: validation + mapping + daemon calls only +- 11 new Rust core tests (archive completed/failed/abandoned, eligibility rejection, already-archived, metadata roundtrip, audit entry) +- 11 new Rust handler tests (archive completed/failed, rejections for active/prepared/unknown, audit trail, run.get visibility, list filtering: default/includeArchived/archivedOnly) +- 10 new Rust persistence tests (metadata roundtrip, default None, list filtering, summary fields, M13 migration) +- 14 new TypeScript tests (6 schema validation + 5 list filtering + 3 no-hidden-agent regression) +- No backend model calls; no autonomous continuation; no coarse tools introduced + ## Current Implementation Surface -### Public MCP Tools (19) +### Public MCP Tools (20) | Tool | Description | |------|-------------| @@ -209,7 +241,7 @@ ChatGPT-hosted model | `refresh_run_state` | Read-only run state snapshot | | `replan_run` | Deterministic rule-based replanning | | `approve_action` | Resolve pending approvals | -| `list_runs` | List known runs with status and metadata (read-only) | +| `list_runs` | List known runs with status and metadata; supports `includeArchived` / `archivedOnly` filtering (read-only) | | `get_run_state` | Get authoritative current state of a run (read-only) | | `get_run_history` | Get audit trail of key events for a run (read-only) | | `preview_patch_policy` | Preview patch policy decision without applying changes (read-only, Milestone 9) | @@ -217,8 +249,9 @@ ChatGPT-hosted model | `finalize_run` | Explicitly close a run with a structured outcome record (Milestone 10) | | `reopen_run` | Reopen a finalized run for deterministic continuation (Milestone 11) | | `supersede_run` | Create a successor run that explicitly replaces a finalized run with preserved lineage (Milestone 12) | +| `archive_run` | Explicitly archive a finalized run so it remains preserved but out of the active working set (Milestone 13) | -### Internal Daemon Methods (19) +### Internal Daemon Methods (20) | Method | Description | |--------|-------------| @@ -233,14 +266,15 @@ ChatGPT-hosted model | `tests.run` | Run tests with policy checks | | `git.diff` | Diff summary/patch | | `approval.resolve` | Resolve pending approvals | -| `runs.list` | List runs (read-only) | -| `run.get` | Get full run state with approvals and retryable action (read-only) | +| `runs.list` | List runs; supports `include_archived` / `archived_only` filtering (read-only) | +| `run.get` | Get full run state with approvals, retryable action, and archive metadata (read-only) | | `run.history` | Get audit trail entries for a run (read-only) | | `patch.preflight` | Evaluate patch policy without applying changes (read-only, Milestone 9) | | `tests.preflight` | Evaluate test-run policy without executing tests (read-only, Milestone 9) | | `run.finalize` | Close a run with structured outcome record (Milestone 10) | | `run.reopen` | Reopen a finalized run for deterministic continuation (Milestone 11) | | `run.supersede` | Create a successor run that supersedes a finalized run with preserved lineage (Milestone 12) | +| `run.archive` | Archive a finalized run for retention with audit trail (Milestone 13) | ### Run State Model @@ -258,6 +292,7 @@ ChatGPT-hosted model - `finalizedOutcome` (Milestone 10) — structured outcome record for closed runs - `reopenMetadata` (Milestone 11) — compact reopen lineage metadata for reopened runs - `supersedes_run_id` / `superseded_by_run_id` / `supersession_reason` / `superseded_at` (Milestone 12) — supersession lineage +- `archiveMetadata` (Milestone 13) — compact archive metadata (`reason`, `archivedAt`) for archived runs - `warnings` - `createdAt`, `updatedAt`