Skip to main content

machi_workflow/
journal.rs

1//! Append-only host-call journal for resume (format v2).
2//!
3//! Maturity: **core**
4//!
5//! Format v2 (breaking, no migration):
6//! - optional first line: `# machi-journal/2`
7//! - dense JSONL entries with canonical 16-byte request hashes
8//! - `MAX_JOURNAL_BYTES` enforced on load and append
9//! - torn-write repair of an incomplete final line
10
11use std::io::Write as _;
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use sha2::{Digest, Sha256};
16
17use crate::MAX_HOST_CALLS;
18
19/// Maximum journal file size in bytes.
20pub const MAX_JOURNAL_BYTES: u64 = 64 * 1024 * 1024;
21
22/// Maximum journal entries (same ceiling as host-call budget).
23#[allow(
24    clippy::cast_possible_truncation,
25    reason = "MAX_HOST_CALLS fits usize on all supported targets"
26)]
27pub const MAX_JOURNAL_ENTRIES: usize = MAX_HOST_CALLS as usize;
28
29/// First-line format marker for durable journals.
30pub const JOURNAL_VERSION_HEADER: &str = "# machi-journal/2";
31
32/// Sentinel key for journaled, re-raiseable host failures (`Failed` / `Unsupported`).
33pub const HOST_ERROR_KEY: &str = "__machi_host_error";
34
35/// One recorded host call.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
37#[allow(
38    clippy::derive_partial_eq_without_eq,
39    reason = "result contains JSON Value"
40)]
41pub struct JournalEntry {
42    /// Dense sequence number starting at 0.
43    pub seq: u64,
44    /// Request kind.
45    pub kind: String,
46    /// Hash of canonical request payload (32 hex chars = 16 digest bytes).
47    pub req_hash: String,
48    /// Recorded result JSON.
49    pub result: serde_json::Value,
50    /// Host wall-clock ms (not used for script determinism).
51    pub at_ms: u64,
52}
53
54/// Journal failures.
55#[derive(Debug, thiserror::Error)]
56pub enum JournalError {
57    /// I/O failure.
58    #[error("journal io: {0}")]
59    Io(#[from] std::io::Error),
60    /// Parse failure on a complete line.
61    #[error("journal parse at line {line}: {error}")]
62    Parse {
63        /// Line number (1-based among file lines).
64        line: usize,
65        /// Parse error.
66        error: String,
67    },
68    /// Restore rejected for safety (symlink, size, entry count).
69    #[error("journal restore rejected (limit {limit}): {reason}")]
70    UnsafeRestore {
71        /// Cap that was violated.
72        limit: u64,
73        /// Human reason.
74        reason: String,
75    },
76    /// Sequence gap or mismatch.
77    #[error("journal is not dense at entry {index}: expected sequence {expected}, found {actual}")]
78    Sequence {
79        /// Index.
80        index: usize,
81        /// Expected seq.
82        expected: u64,
83        /// Actual seq.
84        actual: u64,
85    },
86    /// Replay saw a different request than recorded.
87    #[error(
88        "replay divergence at seq {seq} ({kind}): the script issued a different call than the \
89         recorded run — the workflow script is nondeterministic or was edited mid-run"
90    )]
91    Divergence {
92        /// Sequence.
93        seq: u64,
94        /// Kind.
95        kind: String,
96    },
97    /// Append would exceed the durable byte cap.
98    #[error(
99        "journal full: appending seq {seq} would exceed the {limit}-byte cap \
100         that restore enforces, which would strand the run unresumable"
101    )]
102    Full {
103        /// Sequence that would have been written.
104        seq: u64,
105        /// Byte limit.
106        limit: u64,
107    },
108}
109
110/// In-memory journal with optional durable path.
111///
112/// Maturity: **core**
113#[derive(Debug, Default)]
114pub struct Journal {
115    entries: Vec<JournalEntry>,
116    path: Option<PathBuf>,
117    bytes: u64,
118    /// Byte offset of each entry's line start (parallel to [`Self::entries`]).
119    /// Enables repeated [`Self::prune_trailing_host_error`] without reload.
120    line_starts: Vec<u64>,
121    /// Whether a version header is present / should be written on first append.
122    has_header: bool,
123}
124
125impl Journal {
126    /// Empty journal, optionally bound to a path for appends.
127    #[must_use]
128    pub const fn new(path: Option<PathBuf>) -> Self {
129        Self {
130            entries: Vec::new(),
131            path,
132            bytes: 0,
133            line_starts: Vec::new(),
134            has_header: false,
135        }
136    }
137
138    /// Load from jsonl path (missing file => empty bound journal).
139    ///
140    /// # Errors
141    ///
142    /// Returns parse/IO/unsafe-restore errors. Torn final lines are repaired
143    /// (completed with `\n` when valid JSON, otherwise truncated).
144    #[allow(
145        clippy::too_many_lines,
146        clippy::excessive_nesting,
147        clippy::indexing_slicing,
148        clippy::single_match_else,
149        reason = "byte-oriented journal parser with torn-write repair is inherently nested"
150    )]
151    pub fn load(path: PathBuf) -> Result<Self, JournalError> {
152        let content = match read_journal_bounded(&path) {
153            Ok(content) => content,
154            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
155                return Ok(Self::new(Some(path)));
156            }
157            Err(error) if error.kind() == std::io::ErrorKind::InvalidData => {
158                return Err(JournalError::UnsafeRestore {
159                    limit: MAX_JOURNAL_BYTES,
160                    reason: error.to_string(),
161                });
162            }
163            Err(error) => return Err(error.into()),
164        };
165
166        let mut entries = Vec::new();
167        let mut line_starts = Vec::new();
168        let mut offset = 0usize;
169        let mut line_number = 0usize;
170        let mut bytes = u64::try_from(content.len()).unwrap_or(u64::MAX);
171        let mut has_header = false;
172
173        while offset < content.len() {
174            line_number = line_number.saturating_add(1);
175            let Some(relative_newline) = content
176                .get(offset..)
177                .and_then(|s| s.iter().position(|b| *b == b'\n'))
178            else {
179                // Torn final line (no trailing newline).
180                let tail = content.get(offset..).unwrap_or(&[]);
181                if tail.iter().all(u8::is_ascii_whitespace) {
182                    truncate_tail(&path, u64::try_from(offset).unwrap_or(0))?;
183                    bytes = u64::try_from(offset).unwrap_or(0);
184                    break;
185                }
186                match serde_json::from_slice::<JournalEntry>(tail) {
187                    Ok(entry) => {
188                        if entries.len() >= MAX_JOURNAL_ENTRIES {
189                            return Err(JournalError::UnsafeRestore {
190                                limit: u64::try_from(MAX_JOURNAL_ENTRIES).unwrap_or(u64::MAX),
191                                reason: "too many journal entries".into(),
192                            });
193                        }
194                        if validate_sequence(&entries, &entry).is_err() {
195                            // Wrong-seq torn tail: drop it so prior entries stay loadable.
196                            truncate_tail(&path, u64::try_from(offset).unwrap_or(0))?;
197                            bytes = u64::try_from(offset).unwrap_or(0);
198                            break;
199                        }
200                        let line_start = u64::try_from(offset).unwrap_or(0);
201                        // Completing the line adds one byte; must stay within the restore cap.
202                        if bytes.saturating_add(1) > MAX_JOURNAL_BYTES {
203                            truncate_tail(&path, line_start)?;
204                            bytes = line_start;
205                            break;
206                        }
207                        entries.push(entry);
208                        line_starts.push(line_start);
209                        terminate_line(&path)?;
210                        bytes = bytes.saturating_add(1);
211                    }
212                    Err(_) => {
213                        truncate_tail(&path, u64::try_from(offset).unwrap_or(0))?;
214                        bytes = u64::try_from(offset).unwrap_or(0);
215                    }
216                }
217                break;
218            };
219
220            let end = offset.saturating_add(relative_newline);
221            let line = content.get(offset..end).unwrap_or(&[]);
222            let line_start = u64::try_from(offset).unwrap_or(0);
223            offset = end.saturating_add(1);
224
225            if line.iter().all(u8::is_ascii_whitespace) {
226                continue;
227            }
228
229            // Version header (format evolution anchor).
230            if line_number == 1 && line.starts_with(b"#") {
231                let header = std::str::from_utf8(line).unwrap_or("");
232                if header.trim() == JOURNAL_VERSION_HEADER {
233                    has_header = true;
234                    continue;
235                }
236                return Err(JournalError::UnsafeRestore {
237                    limit: 2,
238                    reason: format!("unsupported journal header: {header}"),
239                });
240            }
241
242            let entry = serde_json::from_slice::<JournalEntry>(line).map_err(|error| {
243                JournalError::Parse {
244                    line: line_number,
245                    error: error.to_string(),
246                }
247            })?;
248            if entries.len() >= MAX_JOURNAL_ENTRIES {
249                return Err(JournalError::UnsafeRestore {
250                    limit: u64::try_from(MAX_JOURNAL_ENTRIES).unwrap_or(u64::MAX),
251                    reason: "too many journal entries".into(),
252                });
253            }
254            validate_sequence(&entries, &entry)?;
255            entries.push(entry);
256            line_starts.push(line_start);
257        }
258
259        debug_assert_eq!(
260            entries.len(),
261            line_starts.len(),
262            "line_starts must track every journal entry"
263        );
264        Ok(Self {
265            entries,
266            path: Some(path),
267            bytes,
268            line_starts,
269            has_header,
270        })
271    }
272
273    /// Number of entries.
274    #[must_use]
275    pub const fn len(&self) -> usize {
276        self.entries.len()
277    }
278
279    /// Empty check.
280    #[must_use]
281    pub const fn is_empty(&self) -> bool {
282        self.entries.is_empty()
283    }
284
285    /// Durable byte length (header + lines, after repairs).
286    #[must_use]
287    pub const fn bytes(&self) -> u64 {
288        self.bytes
289    }
290
291    /// Count of recorded `spawn_agent` entries (reservation accounting helper).
292    #[must_use]
293    pub fn agent_reservation_count(&self) -> u64 {
294        u64::try_from(
295            self.entries
296                .iter()
297                .filter(|entry| entry.kind == "spawn_agent")
298                .count(),
299        )
300        .unwrap_or(u64::MAX)
301    }
302
303    /// Whether `seq` is already covered.
304    #[must_use]
305    pub fn covers(&self, seq: u64) -> bool {
306        usize::try_from(seq).is_ok_and(|i| i < self.entries.len())
307    }
308
309    /// Replay a covered call or return `None` to execute live.
310    ///
311    /// # Errors
312    ///
313    /// Divergence when kind/hash mismatch.
314    pub fn replay(
315        &self,
316        seq: u64,
317        kind: &str,
318        hash: &str,
319    ) -> Result<Option<serde_json::Value>, JournalError> {
320        let Some(entry) = usize::try_from(seq).ok().and_then(|i| self.entries.get(i)) else {
321            return Ok(None);
322        };
323        if entry.seq != seq || entry.kind != kind || entry.req_hash != hash {
324            return Err(JournalError::Divergence {
325                seq,
326                kind: kind.to_owned(),
327            });
328        }
329        Ok(Some(entry.result.clone()))
330    }
331
332    /// Append a live result.
333    ///
334    /// # Errors
335    ///
336    /// Full journal, sequence errors, or IO failures.
337    #[allow(
338        clippy::excessive_nesting,
339        reason = "durable append has header + TOCTOU + path branches collocated"
340    )]
341    pub fn record(
342        &mut self,
343        seq: u64,
344        kind: &str,
345        hash: String,
346        result: serde_json::Value,
347    ) -> Result<(), JournalError> {
348        let entry = JournalEntry {
349            seq,
350            kind: kind.to_owned(),
351            req_hash: hash,
352            result,
353            at_ms: unix_now_ms(),
354        };
355        validate_sequence(&self.entries, &entry)?;
356        if self.entries.len() >= MAX_JOURNAL_ENTRIES {
357            return Err(JournalError::Full {
358                seq,
359                limit: u64::try_from(MAX_JOURNAL_ENTRIES).unwrap_or(u64::MAX),
360            });
361        }
362
363        let mut line = serde_json::to_string(&entry).map_err(|error| {
364            JournalError::Io(std::io::Error::other(format!("serialize entry: {error}")))
365        })?;
366        line.push('\n');
367        let line_len = u64::try_from(line.len()).unwrap_or(u64::MAX);
368
369        // Ensure version header on first durable write.
370        let header_extra = if self.path.is_some() && !self.has_header && self.entries.is_empty() {
371            u64::try_from(JOURNAL_VERSION_HEADER.len().saturating_add(1)).unwrap_or(0)
372        } else {
373            0
374        };
375
376        if self
377            .bytes
378            .saturating_add(header_extra)
379            .saturating_add(line_len)
380            > MAX_JOURNAL_BYTES
381        {
382            return Err(JournalError::Full {
383                seq,
384                limit: MAX_JOURNAL_BYTES,
385            });
386        }
387
388        if let Some(path) = &self.path {
389            // TOCTOU re-check: reject when on-disk size + this write would exceed the cap.
390            if path.exists() {
391                let disk = std::fs::metadata(path)?.len();
392                if disk.saturating_add(header_extra).saturating_add(line_len) > MAX_JOURNAL_BYTES {
393                    return Err(JournalError::Full {
394                        seq,
395                        limit: MAX_JOURNAL_BYTES,
396                    });
397                }
398            }
399            if !self.has_header && self.entries.is_empty() {
400                write_version_header(path)?;
401                self.has_header = true;
402                self.bytes = self.bytes.saturating_add(header_extra);
403            }
404            let line_start = self.bytes;
405            append_line(path, &line)?;
406            self.line_starts.push(line_start);
407        } else {
408            self.line_starts.push(self.bytes);
409        }
410        self.bytes = self.bytes.saturating_add(line_len);
411
412        self.entries.push(entry);
413        Ok(())
414    }
415
416    /// Drop the trailing host-error sentinel when it matches `failure_detail`.
417    ///
418    /// Used so a recoverable host failure can be retried on resume without
419    /// replaying the same error. Safe to call repeatedly: each successful prune
420    /// restores the prior entry's line offset.
421    ///
422    /// # Errors
423    ///
424    /// IO failures while truncating the durable file.
425    pub fn prune_trailing_host_error(
426        &mut self,
427        failure_detail: &str,
428    ) -> Result<bool, JournalError> {
429        let Some(last) = self.entries.last() else {
430            return Ok(false);
431        };
432        let Some(message) = last.result.get(HOST_ERROR_KEY).and_then(|v| v.as_str()) else {
433            return Ok(false);
434        };
435        if message.is_empty() || !failure_detail.contains(message) {
436            return Ok(false);
437        }
438        let Some(new_len) = self.line_starts.last().copied() else {
439            return Err(JournalError::Io(std::io::Error::other(
440                "journal cannot locate the trailing entry's byte offset",
441            )));
442        };
443        if let Some(path) = &self.path {
444            truncate_tail(path, new_len)?;
445        }
446        self.entries.pop();
447        self.line_starts.pop();
448        self.bytes = new_len;
449        Ok(true)
450    }
451
452    /// Drop trailing host-error sentinels while `failure_detail` matches the
453    /// current last entry's message (repeated prune).
454    ///
455    /// # Errors
456    ///
457    /// IO failures while truncating.
458    pub fn prune_trailing_host_errors(
459        &mut self,
460        failure_detail: &str,
461    ) -> Result<usize, JournalError> {
462        let mut n = 0usize;
463        while self.prune_trailing_host_error(failure_detail)? {
464            n = n.saturating_add(1);
465        }
466        Ok(n)
467    }
468
469    /// Optional durable path.
470    #[must_use]
471    pub fn path(&self) -> Option<&Path> {
472        self.path.as_deref()
473    }
474
475    /// Entries as a slice (for inspection / tests).
476    #[must_use]
477    pub fn entries(&self) -> &[JournalEntry] {
478        &self.entries
479    }
480}
481
482/// Build a host-error sentinel payload for journal + replay.
483#[must_use]
484pub fn host_error_sentinel(message: &str) -> serde_json::Value {
485    serde_json::json!({ HOST_ERROR_KEY: message })
486}
487
488/// Whether `value` is a host-error sentinel.
489#[must_use]
490pub fn is_host_error_sentinel(value: &serde_json::Value) -> bool {
491    value
492        .get(HOST_ERROR_KEY)
493        .and_then(serde_json::Value::as_str)
494        .is_some()
495}
496
497/// Extract host-error message from a sentinel, if any.
498#[must_use]
499pub fn host_error_message(value: &serde_json::Value) -> Option<&str> {
500    value
501        .get(HOST_ERROR_KEY)
502        .and_then(serde_json::Value::as_str)
503}
504
505/// Recursively sort object keys for stable hashing.
506#[must_use]
507pub fn canonical_json(value: &serde_json::Value) -> serde_json::Value {
508    match value {
509        serde_json::Value::Object(map) => {
510            let mut entries: Vec<(&String, &serde_json::Value)> = map.iter().collect();
511            entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
512            serde_json::Value::Object(
513                entries
514                    .into_iter()
515                    .map(|(k, v)| (k.clone(), canonical_json(v)))
516                    .collect(),
517            )
518        }
519        serde_json::Value::Array(items) => {
520            serde_json::Value::Array(items.iter().map(canonical_json).collect())
521        }
522        other => other.clone(),
523    }
524}
525
526/// Hash a host request for divergence detection (16 digest bytes → 32 hex chars).
527#[must_use]
528pub fn request_hash(kind: &str, payload: &serde_json::Value) -> String {
529    let mut hasher = Sha256::new();
530    hasher.update(kind.as_bytes());
531    hasher.update([0u8]);
532    hasher.update(canonical_json(payload).to_string().as_bytes());
533    let digest = hasher.finalize();
534    encode_hex(digest.iter().take(16).copied())
535}
536
537fn encode_hex(bytes: impl IntoIterator<Item = u8>) -> String {
538    const HEX: &[u8; 16] = b"0123456789abcdef";
539    let mut s = String::with_capacity(32);
540    for b in bytes {
541        let hi = usize::from(b >> 4);
542        let lo = usize::from(b & 0x0f);
543        if let (Some(&h), Some(&l)) = (HEX.get(hi), HEX.get(lo)) {
544            s.push(char::from(h));
545            s.push(char::from(l));
546        }
547    }
548    s
549}
550
551fn validate_sequence(entries: &[JournalEntry], entry: &JournalEntry) -> Result<(), JournalError> {
552    let expected = u64::try_from(entries.len()).unwrap_or(u64::MAX);
553    if entry.seq != expected {
554        return Err(JournalError::Sequence {
555            index: entries.len(),
556            expected,
557            actual: entry.seq,
558        });
559    }
560    Ok(())
561}
562
563fn read_journal_bounded(path: &Path) -> std::io::Result<Vec<u8>> {
564    let metadata = std::fs::symlink_metadata(path)?;
565    if metadata.file_type().is_symlink() || !metadata.is_file() {
566        return Err(std::io::Error::new(
567            std::io::ErrorKind::InvalidData,
568            format!("journal is not a regular file: {}", path.display()),
569        ));
570    }
571    if metadata.len() > MAX_JOURNAL_BYTES {
572        return Err(std::io::Error::new(
573            std::io::ErrorKind::InvalidData,
574            format!("journal exceeds {MAX_JOURNAL_BYTES} bytes"),
575        ));
576    }
577
578    let mut options = std::fs::OpenOptions::new();
579    options.read(true);
580    #[cfg(unix)]
581    {
582        use std::os::unix::fs::OpenOptionsExt as _;
583        // Reject opening through a symlink (TOCTOU defense after symlink_metadata).
584        options.custom_flags(libc::O_NOFOLLOW);
585    }
586    let mut file = options.open(path)?;
587    let opened = file.metadata()?;
588    if !opened.is_file() || opened.len() > MAX_JOURNAL_BYTES {
589        return Err(std::io::Error::new(
590            std::io::ErrorKind::InvalidData,
591            "journal changed during open",
592        ));
593    }
594    let mut content = Vec::with_capacity(usize::try_from(opened.len()).unwrap_or(0));
595    std::io::Read::read_to_end(&mut file, &mut content)?;
596    if u64::try_from(content.len()).unwrap_or(u64::MAX) > MAX_JOURNAL_BYTES {
597        return Err(std::io::Error::new(
598            std::io::ErrorKind::InvalidData,
599            format!("journal exceeds {MAX_JOURNAL_BYTES} bytes"),
600        ));
601    }
602    Ok(content)
603}
604
605fn truncate_tail(path: &Path, len: u64) -> Result<(), JournalError> {
606    let file = std::fs::OpenOptions::new().write(true).open(path)?;
607    file.set_len(len)?;
608    file.sync_data()?;
609    Ok(())
610}
611
612fn terminate_line(path: &Path) -> Result<(), JournalError> {
613    let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
614    file.write_all(b"\n")?;
615    file.sync_data()?;
616    Ok(())
617}
618
619fn write_version_header(path: &Path) -> Result<(), JournalError> {
620    if let Some(parent) = path.parent() {
621        std::fs::create_dir_all(parent)?;
622    }
623    let mut file = std::fs::OpenOptions::new()
624        .create(true)
625        .append(true)
626        .open(path)?;
627    // Only write if file is empty (first durable record).
628    if file.metadata()?.len() == 0 {
629        file.write_all(JOURNAL_VERSION_HEADER.as_bytes())?;
630        file.write_all(b"\n")?;
631        file.sync_data()?;
632    }
633    Ok(())
634}
635
636fn append_line(path: &Path, line: &str) -> Result<(), JournalError> {
637    if let Some(parent) = path.parent() {
638        std::fs::create_dir_all(parent)?;
639    }
640    let mut file = std::fs::OpenOptions::new()
641        .create(true)
642        .append(true)
643        .open(path)?;
644    file.write_all(line.as_bytes())?;
645    file.sync_data()?;
646    Ok(())
647}
648
649fn unix_now_ms() -> u64 {
650    std::time::SystemTime::now()
651        .duration_since(std::time::UNIX_EPOCH)
652        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
653}
654
655#[cfg(test)]
656#[allow(
657    clippy::expect_used,
658    clippy::unwrap_used,
659    reason = "unit tests use expect/unwrap"
660)]
661mod tests {
662    use serde_json::json;
663
664    use super::*;
665
666    #[test]
667    fn replay_and_divergence() {
668        let mut j = Journal::new(None);
669        let hash = request_hash("spawn_agent", &json!({"prompt": "a"}));
670        j.record(0, "spawn_agent", hash.clone(), json!({"ok": true}))
671            .expect("record");
672        let replayed = j
673            .replay(0, "spawn_agent", &hash)
674            .expect("replay")
675            .expect("hit");
676        assert_eq!(replayed.get("ok"), Some(&json!(true)));
677        let err = j
678            .replay(0, "spawn_agent", "deadbeef")
679            .expect_err("divergence");
680        assert!(matches!(err, JournalError::Divergence { .. }));
681    }
682
683    #[test]
684    fn canonical_hash_is_key_order_independent() {
685        let a = request_hash("k", &json!({"b": 1, "a": 2}));
686        let b = request_hash("k", &json!({"a": 2, "b": 1}));
687        assert_eq!(a, b);
688        assert_eq!(a.len(), 32);
689    }
690
691    #[test]
692    fn durable_round_trip_with_version_header() {
693        let dir = tempfile::tempdir().expect("tmp");
694        let path = dir.path().join("j.jsonl");
695        let mut j = Journal::new(Some(path.clone()));
696        let hash = request_hash("spawn_agent", &json!(1));
697        j.record(0, "spawn_agent", hash.clone(), json!(42))
698            .expect("rec");
699        let raw = std::fs::read_to_string(&path).expect("read");
700        assert!(
701            raw.starts_with(JOURNAL_VERSION_HEADER),
702            "missing header: {raw}"
703        );
704        let loaded = Journal::load(path).expect("load");
705        assert_eq!(loaded.len(), 1);
706        assert_eq!(
707            loaded.replay(0, "spawn_agent", &hash).expect("r"),
708            Some(json!(42))
709        );
710    }
711
712    #[test]
713    fn durable_load_after_drop_simulates_cross_process() {
714        let dir = tempfile::tempdir().expect("tmp");
715        let path = dir.path().join("cross.jsonl");
716        {
717            let mut j = Journal::new(Some(path.clone()));
718            let h0 = request_hash("spawn_agent", &json!({"prompt": "a"}));
719            let h1 = request_hash("spawn_agent", &json!({"prompt": "b"}));
720            j.record(0, "spawn_agent", h0, json!({"output": "A"}))
721                .expect("r0");
722            j.record(1, "spawn_agent", h1, json!({"output": "B"}))
723                .expect("r1");
724        }
725        let loaded = Journal::load(path).expect("load");
726        assert_eq!(loaded.len(), 2);
727        let h0 = request_hash("spawn_agent", &json!({"prompt": "a"}));
728        let replayed = loaded
729            .replay(0, "spawn_agent", &h0)
730            .expect("replay")
731            .expect("hit");
732        assert_eq!(replayed.get("output"), Some(&json!("A")));
733    }
734
735    #[test]
736    fn sequence_gap_on_record() {
737        let mut j = Journal::new(None);
738        let err = j
739            .record(1, "spawn_agent", "h".into(), json!(null))
740            .expect_err("seq");
741        assert!(matches!(err, JournalError::Sequence { .. }));
742    }
743
744    #[test]
745    fn torn_tail_valid_json_gets_newline() {
746        let dir = tempfile::tempdir().expect("tmp");
747        let path = dir.path().join("torn.jsonl");
748        let entry = JournalEntry {
749            seq: 0,
750            kind: "spawn_agent".into(),
751            req_hash: "aa".into(),
752            result: json!({"ok": true}),
753            at_ms: 0,
754        };
755        let body = serde_json::to_string(&entry).expect("ser");
756        // Header + body without trailing newline.
757        let mut raw = String::new();
758        raw.push_str(JOURNAL_VERSION_HEADER);
759        raw.push('\n');
760        raw.push_str(&body);
761        std::fs::write(&path, raw.as_bytes()).expect("write");
762
763        let loaded = Journal::load(path.clone()).expect("load");
764        assert_eq!(loaded.len(), 1);
765        let disk = std::fs::read_to_string(&path).expect("reread");
766        assert!(disk.ends_with('\n'));
767    }
768
769    #[test]
770    fn torn_tail_invalid_json_is_truncated() {
771        let dir = tempfile::tempdir().expect("tmp");
772        let path = dir.path().join("torn_bad.jsonl");
773        let mut raw = String::new();
774        raw.push_str(JOURNAL_VERSION_HEADER);
775        raw.push('\n');
776        // One good entry.
777        let good = JournalEntry {
778            seq: 0,
779            kind: "spawn_agent".into(),
780            req_hash: "aa".into(),
781            result: json!(1),
782            at_ms: 0,
783        };
784        raw.push_str(&serde_json::to_string(&good).expect("ser"));
785        raw.push('\n');
786        raw.push_str("{\"seq\":1, incomplete");
787        std::fs::write(&path, raw.as_bytes()).expect("write");
788
789        let loaded = Journal::load(path.clone()).expect("load");
790        assert_eq!(loaded.len(), 1);
791        let disk = std::fs::read_to_string(&path).expect("reread");
792        assert!(!disk.contains("incomplete"));
793    }
794
795    #[test]
796    fn prune_trailing_host_error_truncates_and_allows_reappend() {
797        let dir = tempfile::tempdir().expect("tmp");
798        let path = dir.path().join("prune.jsonl");
799        let mut j = Journal::new(Some(path.clone()));
800        let h0 = request_hash("spawn_agent", &json!({"p": "a"}));
801        j.record(0, "spawn_agent", h0, json!({"ok": true}))
802            .expect("r0");
803        let h1 = request_hash("spawn_agent", &json!({"p": "b"}));
804        j.record(1, "spawn_agent", h1, host_error_sentinel("boom"))
805            .expect("r1");
806        assert!(j.prune_trailing_host_error("error: boom").expect("prune"));
807        assert_eq!(j.len(), 1);
808        let h1b = request_hash("spawn_agent", &json!({"p": "b"}));
809        j.record(1, "spawn_agent", h1b, json!({"ok": 2}))
810            .expect("reappend");
811        let loaded = Journal::load(path).expect("load");
812        assert_eq!(loaded.len(), 2);
813    }
814
815    #[test]
816    fn prune_twice_without_reload_keeps_line_offsets() {
817        let dir = tempfile::tempdir().expect("tmp");
818        let path = dir.path().join("prune2.jsonl");
819        let mut j = Journal::new(Some(path.clone()));
820        j.record(0, "spawn_agent", "h0".into(), json!({"ok": true}))
821            .expect("r0");
822        j.record(1, "spawn_agent", "h1".into(), host_error_sentinel("e1"))
823            .expect("r1");
824        j.record(2, "spawn_agent", "h2".into(), host_error_sentinel("e2"))
825            .expect("r2");
826        assert_eq!(j.prune_trailing_host_errors("e1 e2").expect("prune"), 2);
827        assert_eq!(j.len(), 1);
828        j.record(1, "spawn_agent", "h1b".into(), json!(1))
829            .expect("re");
830        let loaded = Journal::load(path).expect("load");
831        assert_eq!(loaded.len(), 2);
832    }
833
834    #[test]
835    fn prune_is_noop_when_last_is_success() {
836        let mut j = Journal::new(None);
837        j.record(0, "spawn_agent", "h".into(), json!(true))
838            .expect("r");
839        assert!(!j.prune_trailing_host_error("boom").expect("p"));
840        assert_eq!(j.len(), 1);
841    }
842
843    #[test]
844    fn torn_tail_at_byte_cap_is_dropped_not_extended() {
845        let dir = tempfile::tempdir().expect("tmp");
846        let path = dir.path().join("cap_torn.jsonl");
847        // Build a file that is exactly MAX_JOURNAL_BYTES ending mid-entry without newline.
848        // Use a tiny payload repeated until near the cap is impractical; instead write a
849        // header + valid entry, then pad with spaces to MAX-5 and a torn '{' without newline.
850        let entry = JournalEntry {
851            seq: 0,
852            kind: "spawn_agent".into(),
853            req_hash: "aa".into(),
854            result: json!(1),
855            at_ms: 0,
856        };
857        let body = serde_json::to_string(&entry).expect("ser");
858        let mut raw = String::new();
859        raw.push_str(JOURNAL_VERSION_HEADER);
860        raw.push('\n');
861        raw.push_str(&body);
862        raw.push('\n');
863        let max_usize = usize::try_from(MAX_JOURNAL_BYTES).unwrap_or(usize::MAX);
864        let pad = max_usize.saturating_sub(raw.len()).saturating_sub(1);
865        raw.extend(std::iter::repeat_n(' ', pad));
866        raw.push('{'); // torn, no newline; file len == MAX
867        assert_eq!(
868            u64::try_from(raw.len()).unwrap_or(u64::MAX),
869            MAX_JOURNAL_BYTES,
870            "fixture must be exactly MAX_JOURNAL_BYTES"
871        );
872        std::fs::write(&path, raw.as_bytes()).expect("write");
873        let loaded = Journal::load(path.clone()).expect("load");
874        assert_eq!(loaded.len(), 1);
875        // Must still load under the cap.
876        let meta = std::fs::metadata(&path).expect("meta");
877        assert!(meta.len() <= MAX_JOURNAL_BYTES);
878    }
879
880    #[test]
881    fn oversized_file_rejected_on_load() {
882        let dir = tempfile::tempdir().expect("tmp");
883        let path = dir.path().join("big.jsonl");
884        // Write a sparse file larger than the cap without filling RAM.
885        let file = std::fs::File::create(&path).expect("create");
886        file.set_len(MAX_JOURNAL_BYTES.saturating_add(1))
887            .expect("set_len");
888        let err = Journal::load(path).expect_err("must reject");
889        assert!(matches!(err, JournalError::UnsafeRestore { .. }));
890    }
891
892    #[test]
893    fn append_respects_byte_cap() {
894        let mut j = Journal::new(None);
895        // Force full by setting bytes near the cap without a path.
896        j.bytes = MAX_JOURNAL_BYTES;
897        let err = j
898            .record(0, "spawn_agent", "h".into(), json!(null))
899            .expect_err("full");
900        assert!(matches!(err, JournalError::Full { .. }));
901    }
902
903    #[cfg(unix)]
904    #[test]
905    fn symlink_journal_rejected() {
906        let dir = tempfile::tempdir().expect("tmp");
907        let target = dir.path().join("real.jsonl");
908        std::fs::write(&target, b"# machi-journal/2\n").expect("write");
909        let link = dir.path().join("link.jsonl");
910        std::os::unix::fs::symlink(&target, &link).expect("symlink");
911        let err = Journal::load(link).expect_err("symlink");
912        assert!(matches!(err, JournalError::UnsafeRestore { .. }));
913    }
914}