diff --git a/apps/decodex/src/cli.rs b/apps/decodex/src/cli.rs index c307dd9f..dbf5111b 100644 --- a/apps/decodex/src/cli.rs +++ b/apps/decodex/src/cli.rs @@ -20,7 +20,9 @@ use crate::{ archive_hygiene::{self, ArchiveHygieneRequest}, maintenance::{self, MaintenanceMode, MaintenancePruneRequest, MaintenanceScope}, manual::{self, ManualCommitRequest, ManualLandRequest}, - orchestrator::{self, DiagnoseRequest, IssueDispatchMode, RunOnceRequest, ServeRequest}, + orchestrator::{ + self, DiagnoseRequest, EvidenceRequest, IssueDispatchMode, RunOnceRequest, ServeRequest, + }, prelude::eyre, recovery::{self, ReviewHandoffDiagnoseRequest, ReviewHandoffRebindRequest}, runtime, @@ -56,6 +58,7 @@ impl Cli { Command::Project(args) => args.run(), Command::Status(args) => args.run(), Command::Diagnose(args) => args.run(), + Command::Evidence(args) => args.run(), Command::Recover(args) => args.run(), Command::ArchiveLinear(args) => args.run(), Command::Maintenance(args) => args.run(), @@ -452,6 +455,38 @@ impl DiagnoseCommand { } } +#[derive(Debug, Args)] +struct EvidenceCommand { + #[command(flatten)] + project_config: ProjectConfigArgs, + /// Issue identifier or local issue id to inspect. + issue: String, + /// Restrict readback to one run id. Defaults to the latest local run for the issue. + #[arg(long, value_name = "RUN_ID")] + run_id: Option, + /// Restrict readback to one attempt number. Defaults to the selected run attempt. + #[arg(long, value_name = "NUMBER")] + attempt: Option, + /// Emit structured JSON instead of human-readable text. + #[arg(long)] + json: bool, + /// Include full structured payload values instead of compact payload summaries only. + #[arg(long)] + include_payload: bool, +} +impl EvidenceCommand { + fn run(&self) -> crate::prelude::Result<()> { + orchestrator::print_private_evidence(EvidenceRequest { + config_path: self.project_config.as_path(), + issue: &self.issue, + run_id: self.run_id.as_deref(), + attempt_number: self.attempt, + json: self.json, + include_payload: self.include_payload, + }) + } +} + #[derive(Debug, Args)] struct RecoverCommand { #[command(flatten)] @@ -684,6 +719,8 @@ enum Command { Status(StatusCommand), /// Write and print the agent-readable local evidence index. Diagnose(DiagnoseCommand), + /// Inspect local-only private execution evidence for one issue or run. + Evidence(EvidenceCommand), /// Diagnose or explicitly repair supported retained-lane recovery cases. Recover(RecoverCommand), /// Dry-run or archive old terminal Linear issues by repo label. @@ -811,7 +848,7 @@ mod tests { use crate::cli::{ AccountCommand, AccountSubcommand, AccountUseCommand, AttemptCommand, Cli, Command, - CommitCommand, DiagnoseCommand, LandCommand, ProbeCommand, ProjectCommand, + CommitCommand, DiagnoseCommand, EvidenceCommand, LandCommand, ProbeCommand, ProjectCommand, ProjectConfigArgs, ProjectSubcommand, RecoverCommand, RecoverSubcommand, ReviewHandoffDiagnoseCommand, ReviewHandoffRebindCommand, ReviewHandoffRecoveryCommand, ReviewHandoffRecoverySubcommand, RunCommand, ServeCommand, StatusCommand, @@ -1128,6 +1165,35 @@ mod tests { )); } + #[test] + fn parses_evidence_with_issue_run_attempt_json_payload_and_project_config() { + let cli = Cli::parse_from([ + "decodex", + "evidence", + "--config", + "./project.toml", + "PUB-101", + "--run-id", + "run-1", + "--attempt", + "2", + "--json", + "--include-payload", + ]); + + assert!(matches!( + cli.command, + Command::Evidence(EvidenceCommand { + project_config: ProjectConfigArgs { config: Some(config) }, + issue, + run_id: Some(_), + attempt: Some(2), + json: true, + include_payload: true, + }) if config == Path::new("./project.toml") && issue == "PUB-101" + )); + } + #[test] fn parses_review_handoff_diagnose_with_issue_and_json() { let cli = Cli::parse_from([ diff --git a/apps/decodex/src/orchestrator/agent_evidence.rs b/apps/decodex/src/orchestrator/agent_evidence.rs index 1e825f46..bf7fc240 100644 --- a/apps/decodex/src/orchestrator/agent_evidence.rs +++ b/apps/decodex/src/orchestrator/agent_evidence.rs @@ -1,9 +1,13 @@ use std::collections::{self, BTreeMap}; +use state::PrivateExecutionEvent; + const AGENT_HANDOFF_INDEX_SCHEMA: &str = "decodex.agent_handoff_index/1"; const AGENT_BLOCKER_SNAPSHOT_SCHEMA: &str = "decodex.blocker_snapshot/1"; const AGENT_RUN_CAPSULE_SCHEMA: &str = "decodex.run_capsule/1"; const AGENT_EVIDENCE_EVENT_SCHEMA: &str = "decodex.agent_evidence_event/1"; +const PRIVATE_EVIDENCE_READBACK_SCHEMA: &str = "decodex.private_execution_evidence_readback/1"; +const PRIVATE_EVIDENCE_PAYLOAD_PREVIEW_LIMIT: usize = 160; const HANDOFF_INDEX_FILE_NAME: &str = "handoff-index.json"; const BLOCKERS_DIR_NAME: &str = "blockers"; const RUNS_DIR_NAME: &str = "runs"; @@ -79,6 +83,14 @@ struct AgentConnectorBackoff { next_action: String, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct AgentPrivateEvidenceRef { + evidence_ref: String, + source: String, + default_view: String, + read_command: String, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] struct AgentBlocker { evidence_ref: String, @@ -118,6 +130,7 @@ struct AgentRunCapsuleRef { phase: String, current_operation: String, path: String, + private_evidence: AgentPrivateEvidenceRef, } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -168,6 +181,7 @@ struct AgentRunCapsule { effective_sandbox_mode: Option, branch_name: Option, worktree_path: Option, + private_evidence: AgentPrivateEvidenceRef, ledger_outcome: Option, diagnosis: AgentRunDiagnosis, } @@ -230,6 +244,52 @@ struct AgentEvidenceEvent { connector_backoff_count: usize, } +#[derive(Clone, Debug, PartialEq, Serialize)] +struct PrivateEvidenceReadback { + schema: &'static str, + project_id: String, + issue_selector: String, + issue_id: String, + issue_identifier: Option, + run_id: String, + attempt_number: i64, + source: &'static str, + evidence_ref: String, + read_command: String, + payload_mode: &'static str, + event_count: usize, + latest_event_type: Option, + latest_event_at: Option, + events: Vec, + warnings: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +struct PrivateEvidenceReadbackEvent { + record_id: i64, + event_type: String, + recorded_at: String, + payload_summary: PrivateEvidencePayloadSummary, + #[serde(skip_serializing_if = "Option::is_none")] + payload: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct PrivateEvidencePayloadSummary { + kind: String, + byte_count: usize, + keys: Vec, + preview: Vec, + redacted_default_keys: Vec, +} + +struct PrivateEvidenceTarget { + issue_id: String, + issue_identifier: Option, + run_id: String, + attempt_number: i64, +} + struct AgentEvidenceFileWriteContext<'a> { project_id: &'a str, generated_at: &'a str, @@ -441,6 +501,456 @@ fn render_agent_evidence_write_result(result: &AgentEvidenceWriteResult) -> Stri ) } +fn render_private_evidence_reference(run: &OperatorRunStatus) -> String { + let private_evidence = agent_private_evidence_ref(run); + + format!( + "ref={} source={} default_view={} read=`{}`", + private_evidence.evidence_ref, + private_evidence.source, + private_evidence.default_view, + private_evidence.read_command + ) +} + +fn agent_private_evidence_ref(run: &OperatorRunStatus) -> AgentPrivateEvidenceRef { + run.private_evidence.clone() +} + +fn private_evidence_ref_for_run_fields( + project_id: &str, + issue_id: &str, + issue_identifier: Option<&str>, + run_id: &str, + attempt_number: i64, +) -> AgentPrivateEvidenceRef { + AgentPrivateEvidenceRef { + evidence_ref: private_evidence_ref_for_parts(project_id, issue_id, run_id, attempt_number), + source: String::from("runtime_sqlite"), + default_view: String::from("summarized_payloads"), + read_command: private_evidence_read_command( + issue_identifier.unwrap_or(issue_id), + Some(run_id), + Some(attempt_number), + true, + false, + ), + } +} + +fn private_evidence_read_command( + issue_selector: &str, + run_id: Option<&str>, + attempt_number: Option, + json: bool, + include_payload: bool, +) -> String { + let mut command = format!("decodex evidence {}", shell_quote(issue_selector)); + + if let Some(run_id) = run_id { + command.push_str(&format!(" --run-id {}", shell_quote(run_id))); + } + if let Some(attempt_number) = attempt_number { + command.push_str(&format!(" --attempt {attempt_number}")); + } + + if json { + command.push_str(" --json"); + } + if include_payload { + command.push_str(" --include-payload"); + } + + command +} + +fn private_evidence_ref_for_parts( + project_id: &str, + issue_id: &str, + run_id: &str, + attempt_number: i64, +) -> String { + format!("private-evidence:{project_id}/{issue_id}/{run_id}/{attempt_number}") +} + +fn shell_quote(raw: &str) -> String { + if !raw.is_empty() + && raw + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'/' | b':')) + { + return raw.to_owned(); + } + + format!("'{}'", raw.replace('\'', "'\\''")) +} + +fn build_private_evidence_readback( + state_store: &StateStore, + project: &ServiceConfig, + request: &EvidenceRequest<'_>, +) -> Result { + let target = resolve_private_evidence_target( + state_store, + project, + request.issue, + request.run_id, + request.attempt_number, + )?; + let events = state_store.list_private_execution_events( + project.service_id(), + &target.issue_id, + &target.run_id, + target.attempt_number, + )?; + let latest_event = events.last(); + let warnings = if events.is_empty() { + vec![String::from("private_execution_evidence_missing")] + } else { + Vec::new() + }; + let issue_selector = target + .issue_identifier + .as_deref() + .unwrap_or(&target.issue_id) + .to_owned(); + let read_command = private_evidence_read_command( + &issue_selector, + Some(&target.run_id), + Some(target.attempt_number), + true, + request.include_payload, + ); + + Ok(PrivateEvidenceReadback { + schema: PRIVATE_EVIDENCE_READBACK_SCHEMA, + project_id: project.service_id().to_owned(), + issue_selector: request.issue.to_owned(), + issue_id: target.issue_id.clone(), + issue_identifier: target.issue_identifier, + run_id: target.run_id.clone(), + attempt_number: target.attempt_number, + source: "runtime_sqlite", + evidence_ref: private_evidence_ref_for_parts( + project.service_id(), + &target.issue_id, + &target.run_id, + target.attempt_number, + ), + read_command, + payload_mode: if request.include_payload { "full_payloads" } else { "summarized_payloads" }, + event_count: events.len(), + latest_event_type: latest_event.map(|event| event.event_type().to_owned()), + latest_event_at: latest_event.map(|event| event.recorded_at().to_owned()), + events: events + .iter() + .map(|event| private_evidence_readback_event(event, request.include_payload)) + .collect(), + warnings, + }) +} + +fn resolve_private_evidence_target( + state_store: &StateStore, + project: &ServiceConfig, + issue_selector: &str, + run_id: Option<&str>, + attempt_number: Option, +) -> Result { + let (_, runs) = state_store.list_project_runs(project.service_id(), usize::MAX)?; + let selector = issue_selector.trim(); + let matching_run = runs + .iter() + .filter(|run| private_evidence_run_matches_issue(project, run, selector)) + .filter(|run| run_id.is_none_or(|run_id| run.run_id() == run_id)).find(|run| attempt_number.is_none_or(|attempt| run.attempt_number() == attempt)); + + if let Some(run) = matching_run { + let branch_name = run.branch_name().map(str::to_owned); + let worktree_path = run + .worktree_path() + .map(|path| relative_worktree_path_for_path(project, path)); + let issue_identifier = operator_run_issue_identifier_from_fields( + run.run_id(), + branch_name.as_deref(), + worktree_path.as_deref(), + ); + + return Ok(PrivateEvidenceTarget { + issue_id: run.issue_id().to_owned(), + issue_identifier, + run_id: run.run_id().to_owned(), + attempt_number: run.attempt_number(), + }); + } + if let (Some(run_id), Some(attempt_number)) = (run_id, attempt_number) { + let events = state_store.list_private_execution_events_for_run_attempt( + project.service_id(), + run_id, + attempt_number, + )?; + + if let Some(issue_id) = private_evidence_direct_lookup_issue_id(&events, selector)? { + return Ok(PrivateEvidenceTarget { + issue_identifier: (issue_id != selector).then(|| selector.to_owned()), + issue_id, + run_id: run_id.to_owned(), + attempt_number, + }); + } + + return Ok(PrivateEvidenceTarget { + issue_id: selector.to_owned(), + issue_identifier: None, + run_id: run_id.to_owned(), + attempt_number, + }); + } + + eyre::bail!( + "No local run matched issue `{selector}` in project `{}`. Pass --run-id and --attempt for direct runtime-store lookup, or run `decodex status --json` to find local run ids.", + project.service_id() + ) +} + +fn private_evidence_direct_lookup_issue_id( + events: &[PrivateExecutionEvent], + selector: &str, +) -> Result> { + let issue_ids = events + .iter() + .map(PrivateExecutionEvent::issue_id) + .collect::>(); + + if issue_ids.is_empty() { + return Ok(None); + } + if issue_ids.len() == 1 { + return Ok(issue_ids.iter().next().map(|issue_id| (*issue_id).to_owned())); + } + if issue_ids.contains(selector) { + return Ok(Some(selector.to_owned())); + } + + eyre::bail!( + "Direct private evidence lookup for issue `{selector}` matched multiple local issue ids for the supplied run and attempt; pass the local issue id from `decodex status --json`." + ) +} + +fn private_evidence_run_matches_issue( + project: &ServiceConfig, + run: &ProjectRunStatus, + selector: &str, +) -> bool { + if run.issue_id() == selector { + return true; + } + + let branch_name = run.branch_name().map(str::to_owned); + let worktree_path = run + .worktree_path() + .map(|path| relative_worktree_path_for_path(project, path)); + let issue_identifier = operator_run_issue_identifier_from_fields( + run.run_id(), + branch_name.as_deref(), + worktree_path.as_deref(), + ); + + issue_identifier + .as_deref() + .is_some_and(|issue_identifier| issue_identifier.eq_ignore_ascii_case(selector)) +} + +fn private_evidence_readback_event( + event: &PrivateExecutionEvent, + include_payload: bool, +) -> PrivateEvidenceReadbackEvent { + PrivateEvidenceReadbackEvent { + record_id: event.record_id(), + event_type: event.event_type().to_owned(), + recorded_at: event.recorded_at().to_owned(), + payload_summary: summarize_private_evidence_payload(event.payload()), + payload: include_payload.then(|| event.payload().clone()), + } +} + +fn summarize_private_evidence_payload(payload: &Value) -> PrivateEvidencePayloadSummary { + let encoded = serde_json::to_vec(payload).unwrap_or_default(); + let mut keys = Vec::new(); + let mut preview = Vec::new(); + let mut redacted_default_keys = Vec::new(); + let kind = match payload { + Value::Object(object) => { + for (key, value) in object { + keys.push(key.clone()); + + if private_evidence_payload_key_is_sensitive(key) { + redacted_default_keys.push(key.clone()); + preview.push(format!("{key}=")); + } else { + preview.push(format!("{key}={}", summarize_private_evidence_payload_value(value))); + } + } + + String::from("object") + }, + Value::Array(values) => { + preview.push(format!("array_len={}", values.len())); + + String::from("array") + }, + Value::String(value) => { + preview.push(truncate_private_evidence_payload_preview(value)); + + String::from("string") + }, + Value::Number(value) => { + preview.push(value.to_string()); + + String::from("number") + }, + Value::Bool(value) => { + preview.push(value.to_string()); + + String::from("bool") + }, + Value::Null => String::from("null"), + }; + + PrivateEvidencePayloadSummary { + kind, + byte_count: encoded.len(), + keys, + preview, + redacted_default_keys, + } +} + +fn summarize_private_evidence_payload_value(value: &Value) -> String { + match value { + Value::Null => String::from("null"), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + Value::String(value) => truncate_private_evidence_payload_preview(value), + Value::Array(values) => format!("array(len={})", values.len()), + Value::Object(object) => format!("object(keys={})", object.len()), + } +} + +fn private_evidence_payload_key_is_sensitive(key: &str) -> bool { + let key = key.to_ascii_lowercase(); + + key.contains("transcript") + || key.contains("message") + || key.contains("conversation") + || key.contains("raw") + || key.contains("stdout") + || key.contains("stderr") + || key.contains("log") + || key.contains("token") + || key.contains("secret") +} + +fn truncate_private_evidence_payload_preview(value: &str) -> String { + let mut preview = String::new(); + let mut truncated = false; + + for character in value.chars() { + if preview.len() + character.len_utf8() > PRIVATE_EVIDENCE_PAYLOAD_PREVIEW_LIMIT { + truncated = true; + + break; + } + + preview.push(character); + } + + if truncated { + preview.push_str("..."); + } + + preview +} + +fn render_private_evidence_readback(readback: &PrivateEvidenceReadback) -> String { + let mut output = String::new(); + + output.push_str(&format!("Project: {}\n", readback.project_id)); + output.push_str("Private Execution Evidence\n"); + output.push_str(&format!("issue_selector: {}\n", readback.issue_selector)); + output.push_str(&format!("issue_id: {}\n", readback.issue_id)); + output.push_str(&format!( + "issue_identifier: {}\n", + readback.issue_identifier.as_deref().unwrap_or("none") + )); + output.push_str(&format!("run_id: {}\n", readback.run_id)); + output.push_str(&format!("attempt: {}\n", readback.attempt_number)); + output.push_str(&format!("source: {}\n", readback.source)); + output.push_str(&format!("evidence_ref: {}\n", readback.evidence_ref)); + output.push_str(&format!("payload_mode: {}\n", readback.payload_mode)); + output.push_str(&format!("event_count: {}\n", readback.event_count)); + output.push_str(&format!( + "latest_event_type: {}\n", + readback.latest_event_type.as_deref().unwrap_or("none") + )); + output.push_str(&format!( + "latest_event_at: {}\n", + readback.latest_event_at.as_deref().unwrap_or("none") + )); + + if !readback.warnings.is_empty() { + output.push_str(&format!("warnings: {}\n", readback.warnings.join(", "))); + } + + output.push_str("\nEvents\n"); + + if readback.events.is_empty() { + output.push_str("- none\n"); + + return output; + } + + for event in &readback.events { + output.push_str(&format!( + "- record_id: {}\n event_type: {}\n recorded_at: {}\n payload: {}\n", + event.record_id, + event.event_type, + event.recorded_at, + render_private_evidence_payload_summary(&event.payload_summary) + )); + + if let Some(payload) = &event.payload { + output.push_str(&format!(" full_payload: {}\n", payload)); + } + } + + output +} + +fn render_private_evidence_payload_summary( + summary: &PrivateEvidencePayloadSummary, +) -> String { + let keys = if summary.keys.is_empty() { + String::from("none") + } else { + summary.keys.join(",") + }; + let preview = if summary.preview.is_empty() { + String::from("none") + } else { + summary.preview.join("; ") + }; + let redacted = if summary.redacted_default_keys.is_empty() { + String::from("none") + } else { + summary.redacted_default_keys.join(",") + }; + + format!( + "kind={} bytes={} keys={} preview={} redacted_default_keys={}", + summary.kind, summary.byte_count, keys, preview, redacted + ) +} + fn lane_issue_belongs_to_project( issue_id: &str, project_id: &str, @@ -563,6 +1073,7 @@ fn agent_run_capsule( ) -> AgentRunCapsule { let path = run_capsule_path(runs_dir, month_bucket, &run.run_id); let diagnosis = agent_run_diagnosis(run); + let private_evidence = agent_private_evidence_ref(run); AgentRunCapsule { schema: AGENT_RUN_CAPSULE_SCHEMA, @@ -611,6 +1122,7 @@ fn agent_run_capsule( effective_sandbox_mode: run.effective_sandbox_mode.clone(), branch_name: run.branch_name.clone(), worktree_path: run.worktree_path.clone(), + private_evidence, ledger_outcome, diagnosis, } @@ -627,6 +1139,7 @@ fn run_capsule_ref(capsule: &AgentRunCapsule) -> AgentRunCapsuleRef { phase: capsule.phase.clone(), current_operation: capsule.current_operation.clone(), path: capsule.path.clone(), + private_evidence: capsule.private_evidence.clone(), } } diff --git a/apps/decodex/src/orchestrator/entrypoints.rs b/apps/decodex/src/orchestrator/entrypoints.rs index f7a06260..a810d394 100644 --- a/apps/decodex/src/orchestrator/entrypoints.rs +++ b/apps/decodex/src/orchestrator/entrypoints.rs @@ -332,6 +332,28 @@ pub(crate) fn run_diagnose(request: DiagnoseRequest<'_>) -> Result<()> { Ok(()) } +pub(crate) fn print_private_evidence(request: EvidenceRequest<'_>) -> Result<()> { + let state_store = runtime::open_runtime_store()?; + let Some(config_path) = resolve_config_path(request.config_path, &state_store)? else { + eyre::bail!( + "No Decodex project config found. Pass this command's --config or register one with `decodex project add `." + ); + }; + let config = ServiceConfig::from_path(&config_path)?; + + runtime::register_project_config(&state_store, &config_path, true)?; + + let readback = build_private_evidence_readback(&state_store, &config, &request)?; + + if request.json { + println!("{}", serde_json::to_string_pretty(&readback)?); + } else { + print!("{}", render_private_evidence_readback(&readback)); + } + + Ok(()) +} + fn publish_operator_snapshot( operator_state_endpoint: &OperatorStateEndpoint, snapshot: &OperatorStatusSnapshot, diff --git a/apps/decodex/src/orchestrator/status.rs b/apps/decodex/src/orchestrator/status.rs index 35079377..e4da6e5a 100644 --- a/apps/decodex/src/orchestrator/status.rs +++ b/apps/decodex/src/orchestrator/status.rs @@ -3494,6 +3494,7 @@ fn operator_run_status( branch_name.as_deref(), worktree_path.as_deref(), ); + let private_evidence = operator_run_private_evidence(project, &run, issue_identifier.as_deref()); let execution_liveness = operator_run_execution_liveness(&status, &timing, &app_server_state, &protocol_summary); @@ -3530,12 +3531,13 @@ fn operator_run_status( suspected_stall, last_event_type: protocol_summary.last_event_type, last_event_at: protocol_summary.last_event_at, - event_count: protocol_summary.event_count, - process_id: timing.process_id, - process_alive: timing.process_alive, - process_liveness_reason: timing.process_liveness_reason, - retry_kind, - next_retry_at: format_optional_unix_timestamp(retry_ready_at_unix_epoch), + event_count: protocol_summary.event_count, + private_evidence, + process_id: timing.process_id, + process_alive: timing.process_alive, + process_liveness_reason: timing.process_liveness_reason, + retry_kind, + next_retry_at: format_optional_unix_timestamp(retry_ready_at_unix_epoch), effective_model: app_server_state.effective_model, effective_model_provider: app_server_state.effective_model_provider, effective_cwd: app_server_state.effective_cwd, @@ -3551,6 +3553,20 @@ fn operator_run_status( }) } +fn operator_run_private_evidence( + project: &ServiceConfig, + run: &ProjectRunStatus, + issue_identifier: Option<&str>, +) -> AgentPrivateEvidenceRef { + private_evidence_ref_for_run_fields( + project.service_id(), + run.issue_id(), + issue_identifier, + run.run_id(), + run.attempt_number(), + ) +} + fn load_operator_run_marker( run: &ProjectRunStatus, ) -> crate::prelude::Result> { @@ -4818,9 +4834,10 @@ fn append_rendered_run(output: &mut String, run: &OperatorRunStatus) { let protocol_activity = render_protocol_activity_summary(run.protocol_activity.as_ref()); let account = render_account_summary(run.account.as_ref()); let accounts = render_accounts_summary(&run.accounts); + let private_evidence = render_private_evidence_reference(run); output.push_str(&format!( - "- run_id: {}\n project_id: {}\n issue_id: {}\n issue_identifier: {}\n title: {}\n attempt: {}\n status: {}\n attempt_status: {}\n phase: {}\n wait_reason: {}\n current_operation: {}\n active_lease: {}\n queue_lease_state: {}\n queue_lease: {}\n execution_liveness: {}\n freshness_at: {}\n freshness_source: {}\n timing: run_idle={} protocol_idle={} last_progress={} protocol_event={} events={}\n account: {}\n accounts: {}\n child_agent_activity: {}\n protocol_activity: {}\n context_pressure: {}\n thread_id: {}\n turn_id: {}\n thread_status: {}\n thread_active_flags: {}\n interactive_requested: {}\n continuation_pending: {}\n branch: {}\n worktree_path: {}\n updated_at: {}\n last_run_activity_at: {}\n last_protocol_activity_at: {}\n last_progress_at: {}\n idle_for_seconds: {}\n protocol_idle_for_seconds: {}\n suspected_stall: {}\n process_id: {}\n process_alive: {}\n process_liveness_reason: {}\n retry_kind: {}\n next_retry_at: {}\n effective_model: {}\n effective_model_provider: {}\n effective_cwd: {}\n effective_approval_policy: {}\n effective_approvals_reviewer: {}\n effective_sandbox_mode: {}\n protocol_event: {}\n event_count: {}\n", + "- run_id: {}\n project_id: {}\n issue_id: {}\n issue_identifier: {}\n title: {}\n attempt: {}\n status: {}\n attempt_status: {}\n phase: {}\n wait_reason: {}\n current_operation: {}\n active_lease: {}\n queue_lease_state: {}\n queue_lease: {}\n execution_liveness: {}\n freshness_at: {}\n freshness_source: {}\n timing: run_idle={} protocol_idle={} last_progress={} protocol_event={} events={}\n account: {}\n accounts: {}\n child_agent_activity: {}\n protocol_activity: {}\n context_pressure: {}\n private_evidence: {}\n thread_id: {}\n turn_id: {}\n thread_status: {}\n thread_active_flags: {}\n interactive_requested: {}\n continuation_pending: {}\n branch: {}\n worktree_path: {}\n updated_at: {}\n last_run_activity_at: {}\n last_protocol_activity_at: {}\n last_progress_at: {}\n idle_for_seconds: {}\n protocol_idle_for_seconds: {}\n suspected_stall: {}\n process_id: {}\n process_alive: {}\n process_liveness_reason: {}\n retry_kind: {}\n next_retry_at: {}\n effective_model: {}\n effective_model_provider: {}\n effective_cwd: {}\n effective_approval_policy: {}\n effective_approvals_reviewer: {}\n effective_sandbox_mode: {}\n protocol_event: {}\n event_count: {}\n", run.run_id, run.project_id, run.issue_id, @@ -4848,6 +4865,7 @@ fn append_rendered_run(output: &mut String, run: &OperatorRunStatus) { child_agent_activity, protocol_activity, context_pressure, + private_evidence, thread_id, turn_id, thread_status, diff --git a/apps/decodex/src/orchestrator/tests.rs b/apps/decodex/src/orchestrator/tests.rs index 794ee589..20189322 100644 --- a/apps/decodex/src/orchestrator/tests.rs +++ b/apps/decodex/src/orchestrator/tests.rs @@ -32,7 +32,7 @@ use crate::config::{InternalReviewMode, ServiceConfig}; #[rustfmt::skip] use crate::github; #[rustfmt::skip] -use crate::orchestrator::{self, ActiveChildRunContext, ActiveRunDisposition, ActiveRunReconciliation, ActiveWorkflowOverride, AgentEvidenceSource, ChildExitRetryContext, ChildRunRef, ControlPlaneProjectTick, CONTINUATION_PENDING_RUN_STATUS, DaemonRunChild, DaemonTickRuntimeContext, DashboardEventHub, GhPullRequestReviewStateInspector, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, IssueDispatchMode, IssueRunPlan, IssueTurnContinuationGuard, ManualAttentionRequested, OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH, OPERATOR_DASHBOARD_ENDPOINT_PATH, OperatorCodexAccountControlStatus, OperatorStatusSnapshot, PostReviewLaneClassification, PostReviewLaneDecision, PostReviewLaneSnapshot, PreferredRunIdentity, PrepareIssueRunContext, PublishedOperatorSnapshot, PullRequestCommitConnection, PullRequestCommitNode, PullRequestCommitPayload, PullRequestIssueCommentConnection, PullRequestIssueCommentState, PullRequestIssueCommentsNode, PullRequestPageInfo, PullRequestReactionGroup, PullRequestReactionUsersConnection, PullRequestActor, PullRequestRepository, PullRequestRepositoryOwner, PullRequestReviewConnection, PullRequestIssueCommentNode, PullRequestReviewNode, PullRequestReviewRequestConnection, PullRequestReviewState, PullRequestReviewStateInspector, PullRequestReviewStateNode, PullRequestReviewStateRepository, PullRequestReviewSummaryState, PullRequestReviewThreadConnection, PullRequestReviewThreadNode, PullRequestStatusCheckRollup, RecoveredRuntimeState, RetainedPartialProgress, RetainedReviewRunIdentity, RetryComment, RetryDispatchDecision, RetryEntry, RetryKind, RetryQueue, RunCompletionDisposition, RunSummary, RepoGateFailure, TERMINAL_GUARD_MARKER_FILE, TERMINAL_GUARDED_RUN_STATUS, TRACKER_RATE_LIMIT_WARNING, TargetIssueRunContext, EXTERNAL_REVIEW_ACTOR_LOGIN, EXTERNAL_REVIEW_PASS_PHRASE, EXTERNAL_REVIEW_REQUEST_BODY}; +use crate::orchestrator::{self, ActiveChildRunContext, ActiveRunDisposition, ActiveRunReconciliation, ActiveWorkflowOverride, AgentEvidenceSource, ChildExitRetryContext, ChildRunRef, ControlPlaneProjectTick, CONTINUATION_PENDING_RUN_STATUS, DaemonRunChild, DaemonTickRuntimeContext, DashboardEventHub, EvidenceRequest, GhPullRequestReviewStateInspector, ISSUE_DELIVERY_CLOSEOUT_COMPLETE_TOOL_NAME, ISSUE_LABEL_ADD_TOOL_NAME, ISSUE_PROGRESS_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_CHECKPOINT_TOOL_NAME, ISSUE_REVIEW_HANDOFF_TOOL_NAME, ISSUE_REVIEW_REPAIR_COMPLETE_TOOL_NAME, ISSUE_TERMINAL_FINALIZE_TOOL_NAME, ISSUE_TRANSITION_TOOL_NAME, IssueDispatchMode, IssueRunPlan, IssueTurnContinuationGuard, ManualAttentionRequested, OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH, OPERATOR_DASHBOARD_ENDPOINT_PATH, OperatorCodexAccountControlStatus, OperatorStatusSnapshot, PostReviewLaneClassification, PostReviewLaneDecision, PostReviewLaneSnapshot, PreferredRunIdentity, PrepareIssueRunContext, PublishedOperatorSnapshot, PullRequestCommitConnection, PullRequestCommitNode, PullRequestCommitPayload, PullRequestIssueCommentConnection, PullRequestIssueCommentState, PullRequestIssueCommentsNode, PullRequestPageInfo, PullRequestReactionGroup, PullRequestReactionUsersConnection, PullRequestActor, PullRequestRepository, PullRequestRepositoryOwner, PullRequestReviewConnection, PullRequestIssueCommentNode, PullRequestReviewNode, PullRequestReviewRequestConnection, PullRequestReviewState, PullRequestReviewStateInspector, PullRequestReviewStateNode, PullRequestReviewStateRepository, PullRequestReviewSummaryState, PullRequestReviewThreadConnection, PullRequestReviewThreadNode, PullRequestStatusCheckRollup, RecoveredRuntimeState, RetainedPartialProgress, RetainedReviewRunIdentity, RetryComment, RetryDispatchDecision, RetryEntry, RetryKind, RetryQueue, RunCompletionDisposition, RunSummary, RepoGateFailure, TERMINAL_GUARD_MARKER_FILE, TERMINAL_GUARDED_RUN_STATUS, TRACKER_RATE_LIMIT_WARNING, TargetIssueRunContext, EXTERNAL_REVIEW_ACTOR_LOGIN, EXTERNAL_REVIEW_PASS_PHRASE, EXTERNAL_REVIEW_REQUEST_BODY}; #[rustfmt::skip] use crate::prelude::Result; #[rustfmt::skip] diff --git a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs index 44d782e6..545496b1 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/agent_evidence.rs @@ -59,6 +59,14 @@ fn agent_evidence_snapshot_writes_index_blockers_capsules_and_event_stream() { assert_eq!(index_json["source"], "diagnose_command"); assert_eq!(index_json["summary"]["blocker_count"], 3); assert_eq!(index_json["summary"]["run_capsule_count"], 1); + assert_eq!( + index_json["run_capsules"][0]["private_evidence"]["evidence_ref"], + "private-evidence:pubfi/issue-1/run-1/1" + ); + assert_eq!( + index_json["run_capsules"][0]["private_evidence"]["read_command"], + "decodex evidence PUB-101 --run-id run-1 --attempt 1 --json" + ); assert_eq!( index_json["blockers"][0]["blocker_snapshot_path"], temp_dir @@ -83,6 +91,10 @@ fn agent_evidence_snapshot_writes_index_blockers_capsules_and_event_stream() { assert_eq!(capsule_json["schema"], "decodex.run_capsule/1"); assert_eq!(capsule_json["run_id"], "run-1"); assert_eq!(capsule_json["diagnosis"]["reason_code"], "suspected_stall"); + assert_eq!( + capsule_json["private_evidence"]["default_view"], + "summarized_payloads" + ); let blocker_json = read_json_file( &temp_dir @@ -106,6 +118,160 @@ fn agent_evidence_snapshot_writes_index_blockers_capsules_and_event_stream() { assert_eq!(event_json["blocker_count"], 3); } +#[test] +fn private_evidence_readback_summarizes_payloads_without_connector() { + let (_temp_dir, config, _workflow) = temp_project_layout(); + let state_store = StateStore::open_in_memory().expect("state store should open"); + + state_store + .upsert_worktree( + TEST_SERVICE_ID, + "issue-1", + "x/pubfi-pub-101", + ".worktrees/PUB-101", + ) + .expect("worktree should persist"); + state_store + .record_run_attempt("run-1", "issue-1", 1, "failed") + .expect("run should persist"); + state_store + .append_private_execution_event( + TEST_SERVICE_ID, + "issue-1", + "run-1", + 1, + "command_failed", + serde_json::json!({ + "summary": "cargo make test failed", + "next_action": "repair the failing assertion", + "stdout": "full command output stays hidden by default", + }), + ) + .expect("private evidence should append"); + + let request = EvidenceRequest { + config_path: None, + issue: "PUB-101", + run_id: Some("run-1"), + attempt_number: Some(1), + json: true, + include_payload: false, + }; + let readback = orchestrator::build_private_evidence_readback( + &state_store, + &config, + &request, + ) + .expect("private evidence should read from local state"); + + assert_eq!(readback.event_count, 1); + assert_eq!(readback.issue_id, "issue-1"); + assert_eq!(readback.issue_identifier.as_deref(), Some("PUB-101")); + assert_eq!(readback.latest_event_type.as_deref(), Some("command_failed")); + assert!(readback.warnings.is_empty()); + assert_eq!(readback.events[0].payload, None); + assert!( + readback.events[0] + .payload_summary + .preview + .iter() + .any(|preview| preview.contains("summary=cargo make test failed")) + ); + assert_eq!( + readback.events[0].payload_summary.redacted_default_keys, + vec![String::from("stdout")] + ); + + let rendered = orchestrator::render_private_evidence_readback(&readback); + + assert!(rendered.contains("event_count: 1")); + assert!(rendered.contains("redacted_default_keys=stdout")); + assert!(!rendered.contains("full command output stays hidden by default")); +} + +#[test] +fn private_evidence_readback_reports_missing_events_for_known_run() { + let (_temp_dir, config, _workflow) = temp_project_layout(); + let state_store = StateStore::open_in_memory().expect("state store should open"); + + state_store + .upsert_worktree( + TEST_SERVICE_ID, + "issue-2", + "x/pubfi-pub-102", + ".worktrees/PUB-102", + ) + .expect("worktree should persist"); + state_store + .record_run_attempt("run-empty", "issue-2", 1, "running") + .expect("run should persist"); + + let request = EvidenceRequest { + config_path: None, + issue: "PUB-102", + run_id: Some("run-empty"), + attempt_number: Some(1), + json: false, + include_payload: false, + }; + let readback = orchestrator::build_private_evidence_readback( + &state_store, + &config, + &request, + ) + .expect("missing private evidence should still produce readback"); + + assert_eq!(readback.event_count, 0); + assert_eq!( + readback.warnings, + vec![String::from("private_execution_evidence_missing")] + ); + assert!( + orchestrator::render_private_evidence_readback(&readback) + .contains("- none") + ); +} + +#[test] +fn private_evidence_readback_direct_lookup_uses_stored_issue_id() { + let (_temp_dir, config, _workflow) = temp_project_layout(); + let state_store = StateStore::open_in_memory().expect("state store should open"); + + state_store + .append_private_execution_event( + TEST_SERVICE_ID, + "issue-1", + "run-detached", + 3, + "progress_checkpoint", + serde_json::json!({ + "summary": "private checkpoint stayed local", + }), + ) + .expect("private evidence should append without run metadata"); + + let request = EvidenceRequest { + config_path: None, + issue: "PUB-101", + run_id: Some("run-detached"), + attempt_number: Some(3), + json: true, + include_payload: false, + }; + let readback = orchestrator::build_private_evidence_readback( + &state_store, + &config, + &request, + ) + .expect("direct private evidence lookup should infer stored issue id"); + + assert_eq!(readback.event_count, 1); + assert_eq!(readback.issue_id, "issue-1"); + assert_eq!(readback.issue_identifier.as_deref(), Some("PUB-101")); + assert_eq!(readback.latest_event_type.as_deref(), Some("progress_checkpoint")); + assert!(readback.warnings.is_empty()); +} + fn read_json_file(path: &Path) -> Value { let body = fs::read_to_string(path).expect("JSON file should exist"); diff --git a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs index 353df809..18a18efd 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/running_lanes.rs @@ -229,6 +229,10 @@ fn live_operator_status_snapshot_hydrates_active_run_issue_display_metadata() { assert_eq!(active_run.issue_identifier.as_deref(), Some("XY-392")); assert_eq!(active_run.title.as_deref(), Some("Hydrate issue display metadata on run rows")); assert_eq!(active_run.author.as_deref(), Some("Yvette")); + assert_eq!( + active_run.private_evidence.read_command, + format!("decodex evidence XY-392 --run-id {run_id} --attempt 1 --json") + ); assert_eq!(recent_run.issue_identifier.as_deref(), Some("XY-392")); assert_eq!(recent_run.title.as_deref(), Some("Hydrate issue display metadata on run rows")); assert_eq!(recent_run.author.as_deref(), Some("Yvette")); @@ -239,6 +243,10 @@ fn live_operator_status_snapshot_hydrates_active_run_issue_display_metadata() { "Hydrate issue display metadata on run rows" ); assert_eq!(snapshot_json["active_runs"][0]["author"], "Yvette"); + assert_eq!( + snapshot_json["active_runs"][0]["private_evidence"]["read_command"], + format!("decodex evidence XY-392 --run-id {run_id} --attempt 1 --json") + ); } #[test] diff --git a/apps/decodex/src/orchestrator/tests/operator/status_support.rs b/apps/decodex/src/orchestrator/tests/operator/status_support.rs index 1ac216f2..dba80d14 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status_support.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status_support.rs @@ -2,6 +2,7 @@ use std::{panic, slice}; use orchestrator::{OperatorPostReviewLaneStatus, OperatorQueuedIssueStatus, OperatorWorktreeStatus}; use serde_json::Value; +use orchestrator::AgentPrivateEvidenceRef; fn successful_linear_execution_history_comments(issue: &TrackerIssue) -> Vec { vec![ @@ -234,11 +235,19 @@ fn operator_status_text_active_run() -> orchestrator::OperatorRunStatus { suspected_stall: false, last_event_type: Some(String::from("turn/completed")), last_event_at: Some(String::from("2026-03-14 10:00:01")), - event_count: 4, - process_id: Some(1_234), - process_alive: Some(true), - process_liveness_reason: Some(String::from("process_alive")), - retry_kind: None, + event_count: 4, + private_evidence: AgentPrivateEvidenceRef { + evidence_ref: String::from("private-evidence:pubfi/issue-1/run-1/1"), + source: String::from("runtime_sqlite"), + default_view: String::from("summarized_payloads"), + read_command: String::from( + "decodex evidence PUB-101 --run-id run-1 --attempt 1 --json", + ), + }, + process_id: Some(1_234), + process_alive: Some(true), + process_liveness_reason: Some(String::from("process_alive")), + retry_kind: None, next_retry_at: None, effective_model: Some(String::from("gpt-5.4")), effective_model_provider: Some(String::from("openai")), diff --git a/apps/decodex/src/orchestrator/types.rs b/apps/decodex/src/orchestrator/types.rs index 501f92aa..05e77519 100644 --- a/apps/decodex/src/orchestrator/types.rs +++ b/apps/decodex/src/orchestrator/types.rs @@ -42,6 +42,16 @@ pub(crate) struct DiagnoseRequest<'a> { pub(crate) limit: usize, } +/// Local private execution evidence readback request. +pub(crate) struct EvidenceRequest<'a> { + pub(crate) config_path: Option<&'a Path>, + pub(crate) issue: &'a str, + pub(crate) run_id: Option<&'a str>, + pub(crate) attempt_number: Option, + pub(crate) json: bool, + pub(crate) include_payload: bool, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) struct RunSummary { project_id: String, @@ -783,6 +793,7 @@ struct OperatorRunStatus { last_event_type: Option, last_event_at: Option, event_count: i64, + private_evidence: AgentPrivateEvidenceRef, process_id: Option, process_alive: Option, process_liveness_reason: Option, diff --git a/apps/decodex/src/state/store.rs b/apps/decodex/src/state/store.rs index bf7ab855..f6b33695 100644 --- a/apps/decodex/src/state/store.rs +++ b/apps/decodex/src/state/store.rs @@ -1049,6 +1049,30 @@ impl StateStore { Ok(records.into_iter().map(|record| record.as_public()).collect()) } + /// List private execution events for one project/run/attempt tuple. + pub fn list_private_execution_events_for_run_attempt( + &self, + project_id: &str, + run_id: &str, + attempt_number: i64, + ) -> Result> { + let state = self.lock()?; + let mut records = state + .private_execution_events + .iter() + .filter(|record| { + record.project_id == project_id + && record.run_id == run_id + && record.attempt_number == attempt_number + }) + .cloned() + .collect::>(); + + records.sort_by(compare_private_execution_event_runtime_records); + + Ok(records.into_iter().map(|record| record.as_public()).collect()) + } + /// Count protocol journal records for one run. pub fn event_count(&self, run_id: &str) -> Result { let state = self.lock()?; diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index 9897530a..b6cb7578 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -56,6 +56,12 @@ operator snapshot and exists so a repair agent can quickly open one handoff inde related blocker snapshots, and run capsules. It is not scheduling authority, not a replacement for the runtime database, and not a Linear or GitHub collaboration record. Use `decodex diagnose --json` when an agent needs the current handoff index directly. +When Linear shows only a public lifecycle summary, inspect local private execution +evidence with `decodex evidence --run-id --attempt --json`. +The evidence command reads runtime SQLite directly, so it remains useful when tracker +or GitHub connectors are unavailable. By default it prints compact payload summaries +rather than full structured payloads; add `--include-payload` only for local repair +work that needs full private payload values. ## State Ownership @@ -124,6 +130,30 @@ outside the local operator surface. | `Recovery Worktrees` | Retained local worktrees that are not currently owned by `Running Lanes`, `Review & Landing`, or queued attention in `Intake Queue`. This is the cleanup or recovery inbox for recovered paths, retained PR leftovers, and cleanup-only local worktrees. Empty is the normal healthy state. | | `Run Ledger` | Completed or non-running issue history, grouped by issue/lane. Decodex Linear execution ledger comments provide the durable completed outcome when available. If no `decodex.linear_execution_event` record exists, the row reports `missing` / `execution_ledger_missing`; the control plane does not derive a completed or landed outcome from tracker state, local attempts, or non-ledger comments. Raw local attempts and heartbeat details stay in debug expansion. | +## Private Evidence Readback + +Private execution evidence is local runtime evidence, not public tracker history. +Use it when a Linear execution ledger comment is intentionally brief and an operator +or repair agent needs to answer what failed, what was verified, or what the next +local recovery step is. + +Recommended readback sequence: + +1. Run `decodex status` or `decodex diagnose --json` and identify the issue, run id, + and attempt number. Status rows and run capsules include a `private_evidence` + command reference for this tuple. Operator JSON snapshots carry the same compact + reference; they do not embed private event payloads. +2. Run `decodex evidence --run-id --attempt --json`. +3. If `event_count` is `0` and warnings include + `private_execution_evidence_missing`, use the status row, run capsule, protocol + summary, retained worktree, and Linear public summary as the available evidence. +4. Use `--include-payload` only when compact payload summaries are insufficient for + local repair. Do not paste full payloads into Linear or GitHub. + +The command does not require live Linear or GitHub observer access. It resolves known +local runs from the runtime database and can also perform a direct lookup when both +`--run-id` and `--attempt` are supplied. + Worktree visibility follows the owning dashboard section: - `Running Lanes` means the runtime DB still has an active lease, active attempt, or diff --git a/docs/spec/agent-evidence.md b/docs/spec/agent-evidence.md index c2251d20..847baf64 100644 --- a/docs/spec/agent-evidence.md +++ b/docs/spec/agent-evidence.md @@ -83,7 +83,8 @@ Required fields: - `warnings`: typed operator snapshot or diagnose warning strings - `connector_backoffs`: typed connector wait records from the operator snapshot - `blockers`: compact blocker refs with reason codes, next action, and snapshot path -- `run_capsules`: compact run refs with capsule paths +- `run_capsules`: compact run refs with capsule paths and `private_evidence` + references - `recovery_worktrees`: retained local worktrees that need cleanup or recovery context - `recovery_contracts`: commands or next actions an agent can use for supported recovery classes @@ -124,6 +125,9 @@ worktree: - thread, turn, process, protocol event, idle, and progress fields - effective model/provider/cwd/approval/sandbox fields when known - branch and worktree path +- `private_evidence`: a compact reference to local runtime SQLite evidence for the + same project, issue, run, and attempt, including a `decodex evidence ... --json` + read command - optional Run Ledger outcome - `diagnosis.attention_required`, `diagnosis.reason_code`, and `diagnosis.next_action` @@ -131,6 +135,27 @@ worktree: Capsules are rewritten snapshots, not append-only event logs. The append-only stream is `events.jsonl`. +## Private Execution Readback + +`decodex evidence --run-id --attempt --json` reads private +execution events from the local runtime SQLite database and prints +`decodex.private_execution_evidence_readback/1`. + +The readback includes: + +- project id, issue id or identifier, run id, attempt number, and evidence ref +- event count, latest event type, and latest event timestamp +- compact event rows with record id, event type, recorded timestamp, and payload + summaries +- `private_execution_evidence_missing` when the selected run is known but has no + private execution events + +The default readback summarizes payloads and redacts transcript-like, raw output, +log, token, and secret-shaped payload keys. Operators may pass `--include-payload` +for full structured local payloads when a repair requires them. This flag still +reads from the local runtime store only; it must not mirror payloads into Linear, +GitHub, or agent-evidence files. + ## Event Stream `events.jsonl` uses schema `decodex.agent_evidence_event/1`. diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 50b4a4b6..49db3589 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -38,7 +38,9 @@ Defines: The runtime scope, source-of-truth boundaries, eligibility rules, lane - Private execution events are structured runtime evidence rows scoped by `project_id`, `issue_id`, `run_id`, and `attempt_number`. They hold full local evidence that should be queryable through `StateStore` without being mirrored to - Linear execution ledger payloads. + Linear execution ledger payloads. The operator CLI readback path is + `decodex evidence --run-id --attempt `, which reads the local + runtime store and summarizes payloads by default. - Centralized project directories under `~/.codex/decodex/projects//` form the project contract. Each directory contains `project.toml` for service paths and credentials plus `WORKFLOW.md` for execution policy. They do not store