Skip to main content

zenith_session/
runs.rs

1//! Agent-run provenance records: schema and append-only JSONL log.
2//!
3//! Each [`RunRecord`] captures the intent, steps, and output hash of one
4//! agent invocation. Records are written by the caller after the run
5//! completes; this module performs no clock reads or hash computation —
6//! those values arrive pre-computed on the record.
7
8use std::collections::BTreeMap;
9
10use serde::{Deserialize, Serialize};
11
12use crate::adapter::Fs;
13use crate::error::SessionError;
14use crate::layout::StorePaths;
15use crate::manifest::{append_jsonl_record, read_jsonl_records};
16
17// ── RunDiagnostic ─────────────────────────────────────────────────────────────
18
19/// A single diagnostic emitted during a run step.
20#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
21pub struct RunDiagnostic {
22    /// Severity level (e.g. `"error"`, `"warning"`, `"info"`).
23    pub severity: String,
24    /// Machine-readable diagnostic code (e.g. `"font.glyph_missing"`).
25    pub code: String,
26    /// Human-readable diagnostic message.
27    pub message: String,
28}
29
30// ── RunStep ───────────────────────────────────────────────────────────────────
31
32/// One discrete step within a [`RunRecord`].
33///
34/// A step corresponds to a single action invocation. The `params` map holds a
35/// flat representation of the action's inputs: each value is the caller's
36/// canonical string form of the original typed value (the store holds no KDL
37/// types; callers are responsible for converting their typed values to a
38/// display string before recording).
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
40pub struct RunStep {
41    /// Stable step id (unique within its run).
42    pub id: String,
43    /// Parent step id in the step DAG (None for root steps).
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub parent: Option<String>,
46    /// Name of the action invoked (e.g. `"move_node"`, `"apply_style"`).
47    pub action: String,
48    /// Optional version pin for `action` (e.g. an action revision string).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub action_version: Option<String>,
51    /// Optional content hash of the action definition itself.
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub action_hash: Option<String>,
54    /// Flat map of action parameters. Values are caller-supplied display
55    /// strings; the store does not interpret them.
56    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
57    pub params: BTreeMap<String, String>,
58    /// Ids of document nodes affected by this step (display only).
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub affected_nodes: Vec<String>,
61    /// Diagnostics emitted during this step.
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub diagnostics: Vec<RunDiagnostic>,
64    /// Optional content hash of the source artifact this step consumed.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub source_hash: Option<String>,
67}
68
69// ── RunRecord ─────────────────────────────────────────────────────────────────
70
71/// A top-level agent-run provenance record appended to `runs.jsonl`.
72///
73/// The caller is responsible for computing `timestamp_ms` (unix milliseconds)
74/// and `snapshot_hash` (the content hash of the document state produced by the
75/// run) before calling [`append_run`]. This module performs no clock reads.
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77pub struct RunRecord {
78    /// Stable run id (unique within a document's runs log).
79    pub id: String,
80    /// Monotonic sequence number within this log (0-based).
81    pub seq: u64,
82    /// Short human-readable description of what the agent was asked to do.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub brief: Option<String>,
85    /// Optional constraints or guardrails supplied to the agent.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub constraints: Option<String>,
88    /// Optional plan or reasoning trace produced before execution.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub plan: Option<String>,
91    /// Ordered list of steps executed during this run.
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub steps: Vec<RunStep>,
94    /// Unix timestamp in milliseconds at which the run completed.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub timestamp_ms: Option<u128>,
97    /// Content hash of the document state this run produced (into `objects/`).
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub snapshot_hash: Option<String>,
100}
101
102// ── I/O ───────────────────────────────────────────────────────────────────────
103
104/// Append one agent-run record to the document's runs log.
105///
106/// Creates the log file and its parent directory if they do not yet exist.
107pub fn append_run(
108    fs: &impl Fs,
109    paths: &StorePaths,
110    doc_id: &str,
111    record: &RunRecord,
112) -> Result<(), SessionError> {
113    append_jsonl_record(fs, &paths.runs_file(doc_id), record)
114}
115
116/// Read all agent-run records for a document in append order.
117///
118/// Returns an empty vec when no runs log exists for the document.
119pub fn read_runs(
120    fs: &impl Fs,
121    paths: &StorePaths,
122    doc_id: &str,
123) -> Result<Vec<RunRecord>, SessionError> {
124    read_jsonl_records(fs, &paths.runs_file(doc_id))
125}
126
127// ── Tests ─────────────────────────────────────────────────────────────────────
128
129#[cfg(test)]
130mod tests {
131    use std::collections::BTreeMap;
132
133    use super::*;
134    use crate::adapter::MemFs;
135    use crate::layout::StorePaths;
136
137    fn paths() -> StorePaths {
138        StorePaths::new("/data")
139    }
140
141    fn make_fs() -> MemFs {
142        MemFs::new()
143    }
144
145    fn full_step(id: &str) -> RunStep {
146        let mut params = BTreeMap::new();
147        params.insert("x".to_string(), "10".to_string());
148        params.insert("y".to_string(), "20".to_string());
149        RunStep {
150            id: id.to_string(),
151            parent: None,
152            action: "move_node".to_string(),
153            action_version: Some("rev-2".to_string()),
154            action_hash: Some("acthash42".to_string()),
155            params,
156            affected_nodes: vec!["node-a".to_string(), "node-b".to_string()],
157            diagnostics: vec![RunDiagnostic {
158                severity: "warning".to_string(),
159                code: "font.glyph_missing".to_string(),
160                message: "glyph U+FFFD not found".to_string(),
161            }],
162            source_hash: Some("src123".to_string()),
163        }
164    }
165
166    fn minimal_step(id: &str) -> RunStep {
167        RunStep {
168            id: id.to_string(),
169            parent: None,
170            action: "noop".to_string(),
171            action_version: None,
172            action_hash: None,
173            params: BTreeMap::new(),
174            affected_nodes: Vec::new(),
175            diagnostics: Vec::new(),
176            source_hash: None,
177        }
178    }
179
180    #[test]
181    fn append_then_read_runs_roundtrip() {
182        let fs = make_fs();
183        let paths = paths();
184
185        let r0 = RunRecord {
186            id: "run-0".to_string(),
187            seq: 0,
188            brief: Some("move two nodes".to_string()),
189            constraints: None,
190            plan: Some("step A then step B".to_string()),
191            steps: vec![full_step("s0"), minimal_step("s1")],
192            timestamp_ms: Some(1_700_000_000_100),
193            snapshot_hash: Some("snap0".to_string()),
194        };
195        let r1 = RunRecord {
196            id: "run-1".to_string(),
197            seq: 1,
198            brief: None,
199            constraints: Some("read-only".to_string()),
200            plan: None,
201            steps: Vec::new(),
202            timestamp_ms: Some(1_700_000_001_000),
203            snapshot_hash: None,
204        };
205
206        append_run(&fs, &paths, "doc1", &r0).unwrap();
207        append_run(&fs, &paths, "doc1", &r1).unwrap();
208
209        let records = read_runs(&fs, &paths, "doc1").unwrap();
210        assert_eq!(records.len(), 2);
211        assert_eq!(records[0], r0);
212        assert_eq!(records[1], r1);
213    }
214
215    #[test]
216    fn lean_run_omits_optionals() {
217        let fs = make_fs();
218        let paths = paths();
219
220        let rec = RunRecord {
221            id: "run-lean".to_string(),
222            seq: 0,
223            brief: None,
224            constraints: None,
225            plan: None,
226            steps: Vec::new(),
227            timestamp_ms: None,
228            snapshot_hash: None,
229        };
230
231        append_run(&fs, &paths, "doc1", &rec).unwrap();
232
233        let raw = fs.read(&paths.runs_file("doc1")).unwrap();
234        let line = std::str::from_utf8(&raw).unwrap();
235
236        assert!(!line.contains("brief"), "brief must be absent in lean form");
237        assert!(
238            !line.contains("constraints"),
239            "constraints must be absent in lean form"
240        );
241        assert!(!line.contains("plan"), "plan must be absent in lean form");
242        assert!(!line.contains("steps"), "steps must be absent in lean form");
243        assert!(
244            !line.contains("timestamp_ms"),
245            "timestamp_ms must be absent in lean form"
246        );
247        assert!(
248            !line.contains("snapshot_hash"),
249            "snapshot_hash must be absent in lean form"
250        );
251        assert!(line.contains("\"id\""), "id must be present");
252        assert!(line.contains("\"seq\""), "seq must be present");
253    }
254
255    #[test]
256    fn old_run_line_without_new_fields_deserializes() {
257        let fs = make_fs();
258        let paths = paths();
259
260        // Simulate a JSONL line written before optional fields existed.
261        let old_line = b"{\"id\":\"run-old\",\"seq\":3}\n";
262        let run_path = paths.runs_file("doc1");
263        fs.create_dir_all(run_path.parent().unwrap()).unwrap();
264        fs.write(&run_path, old_line).unwrap();
265
266        let records = read_runs(&fs, &paths, "doc1").unwrap();
267        assert_eq!(records.len(), 1);
268        assert_eq!(records[0].id, "run-old");
269        assert_eq!(records[0].seq, 3);
270        assert_eq!(records[0].brief, None);
271        assert_eq!(records[0].constraints, None);
272        assert_eq!(records[0].plan, None);
273        assert!(records[0].steps.is_empty());
274        assert_eq!(records[0].timestamp_ms, None);
275        assert_eq!(records[0].snapshot_hash, None);
276    }
277
278    #[test]
279    fn read_runs_absent_is_empty() {
280        let fs = make_fs();
281        let paths = paths();
282
283        let records = read_runs(&fs, &paths, "no-such-doc").unwrap();
284        assert!(records.is_empty());
285    }
286}