Skip to main content

zenith_session/
manifest.rs

1//! History records and the append-only JSONL manifest.
2//!
3//! A record's SUBSTANCE is `snapshot` — the content-addressed object hash of the
4//! full `.zen` state at this point. Restore replaces the working file with that
5//! snapshot; the operation/label fields are OPTIONAL display metadata and are
6//! never required to reconstruct state (most edits — GUI drags, hand-edits, git
7//! checkouts — carry no operation, so an op-log could not capture them).
8
9use std::path::Path;
10
11use serde::{Deserialize, Serialize};
12
13use crate::adapter::Fs;
14use crate::error::SessionError;
15
16// ── CheckpointMeta ────────────────────────────────────────────────────────────
17
18/// Optional agent-checkpoint metadata attached to a durable version record.
19#[derive(Debug, Clone, Default, PartialEq)]
20pub struct CheckpointMeta {
21    /// Id of the agent action that produced this record.
22    pub action_id: Option<String>,
23    /// Version pin for `action_id` (e.g. an action revision string).
24    pub action_version: Option<String>,
25    /// Content hash linking this record to a rendered preview artifact.
26    pub preview_hash: Option<String>,
27    /// Whether this record can be deterministically re-run.
28    pub replay_eligible: bool,
29}
30
31// ── Record ────────────────────────────────────────────────────────────────────
32
33/// A single entry in the history manifest.
34///
35/// The `snapshot` field is the substance: the content-addressed object hash of
36/// the full document state at this point in history. All other fields are
37/// optional display metadata and are never required to reconstruct state.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct HistoryRecord {
40    /// Stable record id (unique within a manifest).
41    pub id: String,
42    /// Monotonic sequence number within this manifest (0-based).
43    pub seq: u64,
44    /// Parent record id in the history DAG (None for the first record).
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub parent: Option<String>,
47    /// THE SUBSTANCE: object hash of the full snapshot for this record.
48    pub snapshot: String,
49    /// Optional label for the kind of operation that produced this state.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub op_kind: Option<String>,
52    /// Optional human-facing label / version name.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub label: Option<String>,
55    /// Optional list of affected node ids (display only).
56    #[serde(default, skip_serializing_if = "Vec::is_empty")]
57    pub affected: Vec<String>,
58    /// Optional unix-ms timestamp.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub timestamp_ms: Option<u128>,
61    /// Optional author/participant id.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub author: Option<String>,
64    /// Id of the agent action that produced this record (agent checkpoints only).
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub action_id: Option<String>,
67    /// Version pin for `action_id` (e.g. an action revision string).
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub action_version: Option<String>,
70    /// Content hash linking this record to a rendered preview artifact.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub preview_hash: Option<String>,
73    /// Whether this record can be deterministically re-run. Defaults to false.
74    #[serde(default, skip_serializing_if = "is_false")]
75    pub replay_eligible: bool,
76}
77
78impl HistoryRecord {
79    /// Construct a lean record: the required core (id, seq, parent, snapshot)
80    /// with all optional metadata empty. Set the optional fields afterward.
81    pub fn new(
82        id: impl Into<String>,
83        seq: u64,
84        parent: Option<String>,
85        snapshot: impl Into<String>,
86    ) -> Self {
87        Self {
88            id: id.into(),
89            seq,
90            parent,
91            snapshot: snapshot.into(),
92            op_kind: None,
93            label: None,
94            affected: Vec::new(),
95            timestamp_ms: None,
96            author: None,
97            action_id: None,
98            action_version: None,
99            preview_hash: None,
100            replay_eligible: false,
101        }
102    }
103}
104
105fn is_false(b: &bool) -> bool {
106    !*b
107}
108
109// ── Manifest I/O ──────────────────────────────────────────────────────────────
110
111/// Append one serde-serializable record as a JSON line to `path`, creating the
112/// file and its parent directory if needed.
113pub(crate) fn append_jsonl_record<T: serde::Serialize>(
114    fs: &impl Fs,
115    path: &Path,
116    record: &T,
117) -> Result<(), SessionError> {
118    if let Some(parent) = path.parent() {
119        fs.create_dir_all(parent)?;
120    }
121    let mut line = serde_json::to_vec(record)
122        .map_err(|e| SessionError::new(format!("serialize record: {e}")))?;
123    line.push(b'\n');
124    fs.append(path, &line)
125}
126
127/// Read all JSON-line records of type `T` from `path`. Returns an empty vec if
128/// the file is absent. Blank lines are skipped; a malformed line is a hard error.
129pub(crate) fn read_jsonl_records<T: serde::de::DeserializeOwned>(
130    fs: &impl Fs,
131    path: &Path,
132) -> Result<Vec<T>, SessionError> {
133    if !fs.exists(path) {
134        return Ok(Vec::new());
135    }
136    let bytes = fs.read(path)?;
137    let text = std::str::from_utf8(&bytes)
138        .map_err(|e| SessionError::new(format!("manifest is not utf-8: {e}")))?;
139    let mut out = Vec::new();
140    for line in text.lines() {
141        if line.trim().is_empty() {
142            continue;
143        }
144        let rec = serde_json::from_str(line)
145            .map_err(|e| SessionError::new(format!("parse record: {e}")))?;
146        out.push(rec);
147    }
148    Ok(out)
149}
150
151/// Append one record to the JSONL manifest at `path`, creating the file (and its
152/// parent directory) if needed. Each record is written as a single JSON line.
153pub fn append_record(
154    fs: &impl Fs,
155    path: &Path,
156    record: &HistoryRecord,
157) -> Result<(), SessionError> {
158    append_jsonl_record(fs, path, record)
159}
160
161/// Read and parse every record from the JSONL manifest at `path`. Returns an
162/// empty vec if the manifest does not exist. Blank lines are skipped; a malformed
163/// line is a hard error (the manifest is corrupt).
164pub fn read_records(fs: &impl Fs, path: &Path) -> Result<Vec<HistoryRecord>, SessionError> {
165    read_jsonl_records::<HistoryRecord>(fs, path)
166}
167
168// ── Tests ─────────────────────────────────────────────────────────────────────
169
170#[cfg(test)]
171mod tests {
172    use std::path::PathBuf;
173
174    use super::*;
175    use crate::adapter::{Fs, MemFs};
176
177    fn manifest_path() -> PathBuf {
178        PathBuf::from("/data/m.jsonl")
179    }
180
181    fn make_fs() -> MemFs {
182        MemFs::new()
183    }
184
185    #[test]
186    fn append_then_read_one() {
187        let fs = make_fs();
188        let path = manifest_path();
189        let rec = HistoryRecord::new("r0", 0, None, "deadbeef");
190        append_record(&fs, &path, &rec).unwrap();
191        let records = read_records(&fs, &path).unwrap();
192        assert_eq!(records.len(), 1);
193        assert_eq!(records[0], rec);
194    }
195
196    #[test]
197    fn append_multiple_preserves_order() {
198        let fs = make_fs();
199        let path = manifest_path();
200        let r0 = HistoryRecord::new("r0", 0, None, "aaa");
201        let r1 = HistoryRecord::new("r1", 1, Some("r0".to_string()), "bbb");
202        let r2 = HistoryRecord::new("r2", 2, Some("r1".to_string()), "ccc");
203        append_record(&fs, &path, &r0).unwrap();
204        append_record(&fs, &path, &r1).unwrap();
205        append_record(&fs, &path, &r2).unwrap();
206        let records = read_records(&fs, &path).unwrap();
207        assert_eq!(records.len(), 3);
208        assert_eq!(records[0], r0);
209        assert_eq!(records[1], r1);
210        assert_eq!(records[2], r2);
211    }
212
213    #[test]
214    fn read_missing_is_empty() {
215        let fs = make_fs();
216        let path = PathBuf::from("/nonexistent/m.jsonl");
217        let records = read_records(&fs, &path).unwrap();
218        assert!(records.is_empty());
219    }
220
221    #[test]
222    fn lean_record_omits_optionals() {
223        let fs = make_fs();
224        let path = manifest_path();
225        let rec = HistoryRecord::new("r0", 0, None, "cafebabe");
226        append_record(&fs, &path, &rec).unwrap();
227        let raw = fs.read(&path).unwrap();
228        let line = std::str::from_utf8(&raw).unwrap();
229        assert!(
230            !line.contains("op_kind"),
231            "op_kind must be absent in lean form"
232        );
233        assert!(!line.contains("label"), "label must be absent in lean form");
234        assert!(
235            !line.contains("affected"),
236            "affected must be absent in lean form"
237        );
238        assert!(
239            !line.contains("timestamp_ms"),
240            "timestamp_ms must be absent in lean form"
241        );
242        assert!(
243            !line.contains("author"),
244            "author must be absent in lean form"
245        );
246        assert!(
247            !line.contains("action_id"),
248            "action_id must be absent in lean form"
249        );
250        assert!(
251            !line.contains("action_version"),
252            "action_version must be absent in lean form"
253        );
254        assert!(
255            !line.contains("preview_hash"),
256            "preview_hash must be absent in lean form"
257        );
258        assert!(
259            !line.contains("replay_eligible"),
260            "replay_eligible must be absent in lean form"
261        );
262        assert!(line.contains("\"snapshot\""), "snapshot must be present");
263        assert!(line.contains("\"seq\""), "seq must be present");
264    }
265
266    #[test]
267    fn checkpoint_record_roundtrips() {
268        let fs = make_fs();
269        let path = manifest_path();
270        let mut rec = HistoryRecord::new("cp0", 0, None, "cafef00d");
271        rec.action_id = Some("act-1".to_string());
272        rec.action_version = Some("rev-3".to_string());
273        rec.preview_hash = Some("preview123".to_string());
274        rec.replay_eligible = true;
275        append_record(&fs, &path, &rec).unwrap();
276        let raw = fs.read(&path).unwrap();
277        let line = std::str::from_utf8(&raw).unwrap();
278        assert!(line.contains("action_id"), "action_id must appear when set");
279        assert!(
280            line.contains("action_version"),
281            "action_version must appear when set"
282        );
283        assert!(
284            line.contains("preview_hash"),
285            "preview_hash must appear when set"
286        );
287        assert!(
288            line.contains("replay_eligible"),
289            "replay_eligible must appear when true"
290        );
291        let records = read_records(&fs, &path).unwrap();
292        assert_eq!(records.len(), 1);
293        assert_eq!(records[0], rec);
294    }
295
296    #[test]
297    fn old_manifest_without_checkpoint_fields_deserializes() {
298        let fs = make_fs();
299        let path = manifest_path();
300        // A JSONL line as produced before the checkpoint fields were added.
301        let old_line = b"{\"id\":\"r0\",\"seq\":0,\"snapshot\":\"oldhash\",\"op_kind\":\"edit\"}\n";
302        fs.create_dir_all(path.parent().unwrap()).unwrap();
303        fs.write(&path, old_line).unwrap();
304        let records = read_records(&fs, &path).unwrap();
305        assert_eq!(records.len(), 1);
306        assert_eq!(records[0].action_id, None);
307        assert_eq!(records[0].action_version, None);
308        assert_eq!(records[0].preview_hash, None);
309        assert!(!records[0].replay_eligible);
310    }
311
312    #[test]
313    fn full_record_roundtrips() {
314        let fs = make_fs();
315        let path = manifest_path();
316        let rec = HistoryRecord {
317            id: "full".to_string(),
318            seq: 7,
319            parent: Some("prev".to_string()),
320            snapshot: "abc123".to_string(),
321            op_kind: Some("move".to_string()),
322            label: Some("v2".to_string()),
323            affected: vec!["node-a".to_string(), "node-b".to_string()],
324            timestamp_ms: Some(1_700_000_000_000),
325            author: Some("alice".to_string()),
326            action_id: Some("act-42".to_string()),
327            action_version: Some("rev-7".to_string()),
328            preview_hash: Some("deadbeef".to_string()),
329            replay_eligible: true,
330        };
331        append_record(&fs, &path, &rec).unwrap();
332        let records = read_records(&fs, &path).unwrap();
333        assert_eq!(records.len(), 1);
334        assert_eq!(records[0], rec);
335    }
336
337    #[test]
338    fn blank_lines_skipped() {
339        let fs = make_fs();
340        let path = manifest_path();
341        let rec = HistoryRecord::new("r0", 0, None, "deadbeef");
342        append_record(&fs, &path, &rec).unwrap();
343        // Inject blank lines directly.
344        fs.append(&path, b"\n  \n").unwrap();
345        let records = read_records(&fs, &path).unwrap();
346        assert_eq!(records.len(), 1);
347        assert_eq!(records[0], rec);
348    }
349
350    #[test]
351    fn malformed_line_errors() {
352        let fs = make_fs();
353        let path = manifest_path();
354        // Create the parent dir and write corrupt content.
355        fs.create_dir_all(path.parent().unwrap()).unwrap();
356        fs.write(&path, b"{not json}\n").unwrap();
357        let result = read_records(&fs, &path);
358        assert!(result.is_err(), "expected error on malformed JSON line");
359    }
360}