Skip to main content

heartbit_core/agent/flow/
journal.rs

1//! [`RunJournal`] — deterministic resume for workflow runs.
2//!
3//! A run journal memoizes each [`agent`](super::agent::agent) leaf's output to a
4//! JSONL file so a re-run can replay unchanged work instead of re-calling the
5//! model. It is **content-addressed**: each entry is keyed by
6//! `(content_hash, occurrence)` where `content_hash` covers the call's *inputs*
7//! (prompt, requested model, schema) and `occurrence` is how many times that
8//! exact hash has been issued so far in the run.
9//!
10//! # Why content-addressed, not positional
11//!
12//! Keying on a global call index would make inserting or removing one early
13//! `agent()` call shift every later index, so the whole tail would miss on
14//! resume even though its prompts are byte-identical. Content addressing keeps a
15//! call's key stable regardless of position, so an unchanged call replays
16//! wherever it sits. The `occurrence` tiebreaker keeps byte-identical calls in a
17//! loop distinct. A call whose inputs *did* change re-runs, and so do any later
18//! calls whose prompts are derived from its (now different) output — data
19//! dependencies propagate invalidation for free.
20//!
21//! # Soundness and scope (read before relying on this)
22//!
23//! - **Return values, not side effects.** The journal restores an agent's
24//!   returned [`AgentOutput`]; it does **not** re-execute the agent, so any
25//!   filesystem/network/message side effects are *not* replayed. Only journal
26//!   agents that are pure with respect to their return value. (P5 worktree
27//!   isolation will gate side-effecting agents out of the journal.)
28//! - **Single process, single journal, fixed provider.** One [`RunJournal`] per
29//!   path per process; no advisory file lock is taken in this phase, so two
30//!   journals on one path corrupt the log. The journal is also scoped to the
31//!   ctx's provider — sharing a path across different providers/models would
32//!   replay the wrong model's answer (the requested model is hashed, but the
33//!   provider identity is not).
34//! - **Concurrency.** Call-for-call replay is guaranteed only for
35//!   sequentially-issued calls. Inside `parallel()`/`pipeline()` the occurrence
36//!   counter races, so those calls may re-run on resume — safe, since a raced
37//!   occurrence is a false *miss*, never a false hit.
38
39use std::collections::{BTreeMap, HashMap};
40use std::path::Path;
41use std::sync::Mutex;
42
43use serde::{Deserialize, Serialize};
44use serde_json::Value;
45use sha2::{Digest, Sha256};
46
47use crate::agent::AgentOutput;
48use crate::error::Error;
49
50/// Whether to start a fresh journal or resume from an existing one.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ResumeMode {
53    /// Truncate any existing journal and start empty (no replay).
54    Fresh,
55    /// Load an existing journal (if any) and replay matching calls.
56    Resume,
57}
58
59/// Identity of one journaled agent call: its input hash plus how many times that
60/// hash had already been issued in the run (to disambiguate identical calls).
61#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
62pub(crate) struct CallKey {
63    /// Hex SHA-256 over the call's inputs (prompt, requested model, schema).
64    pub content_hash: String,
65    /// 0-based count of prior calls with this exact `content_hash` this run.
66    pub occurrence: u64,
67}
68
69/// One line of the JSONL journal.
70#[derive(Debug, Serialize, Deserialize)]
71struct JournalRecord {
72    /// Format version, for forward compatibility.
73    v: u16,
74    key: CallKey,
75    output: AgentOutput,
76}
77
78/// A content-addressed, JSONL-backed memo of agent outputs for one run.
79#[derive(Debug)]
80pub struct RunJournal {
81    /// Loaded + appended entries, keyed by [`CallKey`].
82    entries: Mutex<HashMap<CallKey, AgentOutput>>,
83    /// Per-`content_hash` issue counter, for assigning [`CallKey::occurrence`].
84    seen: Mutex<HashMap<String, u64>>,
85    /// Append handle for new records.
86    file: Mutex<std::fs::File>,
87}
88
89impl RunJournal {
90    /// Open a journal at `path` (a `.jsonl` file). [`ResumeMode::Fresh`]
91    /// truncates any existing file; [`ResumeMode::Resume`] loads existing
92    /// records (skipping a torn final line from a prior crash) and appends.
93    pub fn open(path: &Path, mode: ResumeMode) -> Result<Self, Error> {
94        use std::fs::OpenOptions;
95        use std::io::{BufRead, BufReader};
96
97        if let Some(parent) = path.parent()
98            && !parent.as_os_str().is_empty()
99        {
100            std::fs::create_dir_all(parent).map_err(|e| Error::Store(e.to_string()))?;
101        }
102
103        let mut entries: HashMap<CallKey, AgentOutput> = HashMap::new();
104        if mode == ResumeMode::Resume && path.exists() {
105            let file = std::fs::File::open(path).map_err(|e| Error::Store(e.to_string()))?;
106            for line in BufReader::new(file).lines() {
107                let line = line.map_err(|e| Error::Store(e.to_string()))?;
108                if line.trim().is_empty() {
109                    continue;
110                }
111                // A torn final line (crash mid-write) or a forward-incompatible
112                // record fails to parse — skip it rather than abort the resume.
113                if let Ok(rec) = serde_json::from_str::<JournalRecord>(&line) {
114                    entries.insert(rec.key, rec.output);
115                }
116            }
117        }
118
119        // Fresh truncates; Resume appends (creating the file if absent).
120        let file = match mode {
121            ResumeMode::Fresh => OpenOptions::new()
122                .write(true)
123                .create(true)
124                .truncate(true)
125                .open(path),
126            ResumeMode::Resume => OpenOptions::new().append(true).create(true).open(path),
127        }
128        .map_err(|e| Error::Store(e.to_string()))?;
129
130        Ok(Self {
131            entries: Mutex::new(entries),
132            seen: Mutex::new(HashMap::new()),
133            file: Mutex::new(file),
134        })
135    }
136
137    /// Assign the next 0-based occurrence index for `content_hash` (mutating).
138    pub(crate) fn next_occurrence(&self, content_hash: &str) -> u64 {
139        let mut seen = self.seen.lock().expect("journal seen lock poisoned");
140        let counter = seen.entry(content_hash.to_string()).or_insert(0);
141        let occurrence = *counter;
142        *counter += 1;
143        occurrence
144    }
145
146    /// Look up a cached output by key (a clone, so callers don't hold the lock).
147    pub(crate) fn lookup(&self, key: &CallKey) -> Option<AgentOutput> {
148        self.entries
149            .lock()
150            .expect("journal entries lock poisoned")
151            .get(key)
152            .cloned()
153    }
154
155    /// Append one record to the JSONL file and the in-memory map. The record and
156    /// its trailing newline are written in a single `write_all` so a crash
157    /// leaves at most a torn *final* line (skipped on the next resume).
158    pub(crate) fn append(&self, key: &CallKey, output: &AgentOutput) -> Result<(), Error> {
159        use std::io::Write;
160
161        let record = JournalRecord {
162            v: 1,
163            key: key.clone(),
164            output: output.clone(),
165        };
166        let mut line = serde_json::to_string(&record)?;
167        line.push('\n');
168        {
169            let mut file = self.file.lock().expect("journal file lock poisoned");
170            file.write_all(line.as_bytes())
171                .map_err(|e| Error::Store(e.to_string()))?;
172            file.flush().map_err(|e| Error::Store(e.to_string()))?;
173        }
174        self.entries
175            .lock()
176            .expect("journal entries lock poisoned")
177            .insert(key.clone(), output.clone());
178        Ok(())
179    }
180}
181
182/// Recursively sort object keys so logically-equal JSON hashes identically,
183/// independent of the `serde_json` `preserve_order` feature (which is off in
184/// this workspace but could be turned on transitively by Cargo feature
185/// unification). Arrays keep their order; object *values* are canonicalized too.
186pub(crate) fn canonical_json(value: &Value) -> Value {
187    match value {
188        Value::Object(map) => {
189            let sorted: BTreeMap<String, Value> = map
190                .iter()
191                .map(|(k, v)| (k.clone(), canonical_json(v)))
192                .collect();
193            // serde_json::Map collects from a BTreeMap in sorted-key order.
194            Value::Object(sorted.into_iter().collect())
195        }
196        Value::Array(items) => Value::Array(items.iter().map(canonical_json).collect()),
197        other => other.clone(),
198    }
199}
200
201/// Lowercase-hex a byte slice (avoids adding a `hex` dependency to the crate).
202fn to_hex(bytes: &[u8]) -> String {
203    use std::fmt::Write;
204    let mut s = String::with_capacity(bytes.len() * 2);
205    for b in bytes {
206        let _ = write!(s, "{b:02x}");
207    }
208    s
209}
210
211/// Hex SHA-256 over a call's output-determining inputs. `model` is `None` today
212/// (no per-call model override yet); it is part of the hashed format now so the
213/// on-disk journal stays valid when a future `.model()` starts passing `Some`.
214pub(crate) fn content_hash(prompt: &str, model: Option<&str>, schema: Option<&Value>) -> String {
215    let mut hasher = Sha256::new();
216    // Domain-separate fields with a 0x00 byte so concatenations can't collide
217    // (prompt "a" + model "b" must not equal prompt "ab" + model "").
218    hasher.update(prompt.as_bytes());
219    hasher.update([0u8]);
220    hasher.update(model.unwrap_or("").as_bytes());
221    hasher.update([0u8]);
222    if let Some(schema) = schema {
223        let canonical = serde_json::to_string(&canonical_json(schema)).unwrap_or_default();
224        hasher.update(canonical.as_bytes());
225    }
226    to_hex(&hasher.finalize())
227}
228
229/// Derive a stable run id from a seed (e.g. a workflow name + serialized args)
230/// so "same inputs ⇒ same journal file". Hex SHA-256 of the seed.
231pub fn derive_run_id(seed: &str) -> String {
232    let mut hasher = Sha256::new();
233    hasher.update(seed.as_bytes());
234    to_hex(&hasher.finalize())
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn out(text: &str, input: u32, output: u32) -> AgentOutput {
242        AgentOutput {
243            result: text.to_string(),
244            tokens_used: crate::llm::types::TokenUsage {
245                input_tokens: input,
246                output_tokens: output,
247                ..Default::default()
248            },
249            ..Default::default()
250        }
251    }
252
253    fn key(hash: &str, occurrence: u64) -> CallKey {
254        CallKey {
255            content_hash: hash.to_string(),
256            occurrence,
257        }
258    }
259
260    // ----- canonicalization + hashing -----
261
262    #[test]
263    fn canonical_json_sorts_nested_keys() {
264        let a = serde_json::json!({ "b": 1, "a": { "y": 2, "x": 3 } });
265        let b = serde_json::json!({ "a": { "x": 3, "y": 2 }, "b": 1 });
266        // Same logical content, different key order -> identical canonical form.
267        assert_eq!(canonical_json(&a), canonical_json(&b));
268        // Canonical serialization has sorted keys.
269        let s = serde_json::to_string(&canonical_json(&a)).unwrap();
270        assert!(s.starts_with(r#"{"a":"#), "not sorted: {s}");
271    }
272
273    #[test]
274    fn content_hash_stable_under_schema_key_reorder() {
275        let s1 = serde_json::json!({ "type": "object", "required": ["x"] });
276        let s2 = serde_json::json!({ "required": ["x"], "type": "object" });
277        assert_eq!(
278            content_hash("p", None, Some(&s1)),
279            content_hash("p", None, Some(&s2)),
280            "reordered-but-equal schemas must hash the same"
281        );
282    }
283
284    #[test]
285    fn content_hash_distinguishes_inputs() {
286        let base = content_hash("prompt", None, None);
287        assert_ne!(base, content_hash("other", None, None), "prompt matters");
288        assert_ne!(
289            base,
290            content_hash("prompt", Some("haiku"), None),
291            "model matters"
292        );
293        let schema = serde_json::json!({ "type": "string" });
294        assert_ne!(
295            base,
296            content_hash("prompt", None, Some(&schema)),
297            "schema matters"
298        );
299    }
300
301    #[test]
302    fn derive_run_id_is_deterministic_and_seed_sensitive() {
303        assert_eq!(derive_run_id("a|b"), derive_run_id("a|b"));
304        assert_ne!(derive_run_id("a|b"), derive_run_id("a|c"));
305    }
306
307    // ----- journal open / append / lookup -----
308
309    #[test]
310    fn fresh_journal_is_empty() {
311        let dir = tempfile::tempdir().unwrap();
312        let j = RunJournal::open(&dir.path().join("j.jsonl"), ResumeMode::Fresh).unwrap();
313        assert!(j.lookup(&key("h", 0)).is_none());
314    }
315
316    #[test]
317    fn append_then_lookup_roundtrips() {
318        let dir = tempfile::tempdir().unwrap();
319        let j = RunJournal::open(&dir.path().join("j.jsonl"), ResumeMode::Fresh).unwrap();
320        j.append(&key("h", 0), &out("hello", 10, 5)).unwrap();
321        let got = j.lookup(&key("h", 0)).expect("hit");
322        assert_eq!(got.result, "hello");
323        assert_eq!(got.tokens_used.input_tokens, 10);
324    }
325
326    #[test]
327    fn occurrence_disambiguates_identical_content() {
328        let dir = tempfile::tempdir().unwrap();
329        let j = RunJournal::open(&dir.path().join("j.jsonl"), ResumeMode::Fresh).unwrap();
330        // Same hash issued twice -> occurrences 0 then 1.
331        assert_eq!(j.next_occurrence("h"), 0);
332        assert_eq!(j.next_occurrence("h"), 1);
333        assert_eq!(j.next_occurrence("other"), 0);
334        j.append(&key("h", 0), &out("first", 1, 1)).unwrap();
335        j.append(&key("h", 1), &out("second", 1, 1)).unwrap();
336        assert_eq!(j.lookup(&key("h", 0)).unwrap().result, "first");
337        assert_eq!(j.lookup(&key("h", 1)).unwrap().result, "second");
338    }
339
340    #[test]
341    fn resume_reads_existing_entries() {
342        let dir = tempfile::tempdir().unwrap();
343        let path = dir.path().join("j.jsonl");
344        {
345            let j = RunJournal::open(&path, ResumeMode::Fresh).unwrap();
346            j.append(&key("h", 0), &out("persisted", 7, 3)).unwrap();
347        }
348        // Re-open in Resume mode: the entry is loaded from disk.
349        let j2 = RunJournal::open(&path, ResumeMode::Resume).unwrap();
350        assert_eq!(j2.lookup(&key("h", 0)).unwrap().result, "persisted");
351    }
352
353    #[test]
354    fn fresh_truncates_existing() {
355        let dir = tempfile::tempdir().unwrap();
356        let path = dir.path().join("j.jsonl");
357        {
358            let j = RunJournal::open(&path, ResumeMode::Fresh).unwrap();
359            j.append(&key("h", 0), &out("old", 1, 1)).unwrap();
360        }
361        // Fresh again -> previous entries gone.
362        let j2 = RunJournal::open(&path, ResumeMode::Fresh).unwrap();
363        assert!(j2.lookup(&key("h", 0)).is_none());
364    }
365
366    #[test]
367    fn torn_final_line_is_skipped_on_resume() {
368        use std::io::Write;
369        let dir = tempfile::tempdir().unwrap();
370        let path = dir.path().join("j.jsonl");
371        {
372            let j = RunJournal::open(&path, ResumeMode::Fresh).unwrap();
373            j.append(&key("h", 0), &out("good", 1, 1)).unwrap();
374        }
375        // Simulate a crash mid-write: append a partial, unterminated JSON line.
376        {
377            let mut f = std::fs::OpenOptions::new()
378                .append(true)
379                .open(&path)
380                .unwrap();
381            f.write_all(br#"{"v":1,"key":{"content_hash":"h","occ"#)
382                .unwrap();
383        }
384        // Resume must load the good record and ignore the torn one (no panic).
385        let j2 = RunJournal::open(&path, ResumeMode::Resume).unwrap();
386        assert_eq!(j2.lookup(&key("h", 0)).unwrap().result, "good");
387    }
388
389    #[test]
390    fn resume_on_missing_file_is_empty_not_error() {
391        let dir = tempfile::tempdir().unwrap();
392        let j = RunJournal::open(&dir.path().join("absent.jsonl"), ResumeMode::Resume).unwrap();
393        assert!(j.lookup(&key("h", 0)).is_none());
394    }
395}