heartbit_core/agent/flow/
journal.rs1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum ResumeMode {
53 Fresh,
55 Resume,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
62pub(crate) struct CallKey {
63 pub content_hash: String,
65 pub occurrence: u64,
67}
68
69#[derive(Debug, Serialize, Deserialize)]
71struct JournalRecord {
72 v: u16,
74 key: CallKey,
75 output: AgentOutput,
76}
77
78#[derive(Debug)]
80pub struct RunJournal {
81 entries: Mutex<HashMap<CallKey, AgentOutput>>,
83 seen: Mutex<HashMap<String, u64>>,
85 file: Mutex<std::fs::File>,
87}
88
89impl RunJournal {
90 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 if let Ok(rec) = serde_json::from_str::<JournalRecord>(&line) {
114 entries.insert(rec.key, rec.output);
115 }
116 }
117 }
118
119 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 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 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 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
182pub(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 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
201fn 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
211pub(crate) fn content_hash(prompt: &str, model: Option<&str>, schema: Option<&Value>) -> String {
215 let mut hasher = Sha256::new();
216 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
229pub 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 #[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 assert_eq!(canonical_json(&a), canonical_json(&b));
268 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 #[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 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 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 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 {
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 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}