1use 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
19pub const MAX_JOURNAL_BYTES: u64 = 64 * 1024 * 1024;
21
22#[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
29pub const JOURNAL_VERSION_HEADER: &str = "# machi-journal/2";
31
32pub const HOST_ERROR_KEY: &str = "__machi_host_error";
34
35#[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 pub seq: u64,
44 pub kind: String,
46 pub req_hash: String,
48 pub result: serde_json::Value,
50 pub at_ms: u64,
52}
53
54#[derive(Debug, thiserror::Error)]
56pub enum JournalError {
57 #[error("journal io: {0}")]
59 Io(#[from] std::io::Error),
60 #[error("journal parse at line {line}: {error}")]
62 Parse {
63 line: usize,
65 error: String,
67 },
68 #[error("journal restore rejected (limit {limit}): {reason}")]
70 UnsafeRestore {
71 limit: u64,
73 reason: String,
75 },
76 #[error("journal is not dense at entry {index}: expected sequence {expected}, found {actual}")]
78 Sequence {
79 index: usize,
81 expected: u64,
83 actual: u64,
85 },
86 #[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 seq: u64,
94 kind: String,
96 },
97 #[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 seq: u64,
105 limit: u64,
107 },
108}
109
110#[derive(Debug, Default)]
114pub struct Journal {
115 entries: Vec<JournalEntry>,
116 path: Option<PathBuf>,
117 bytes: u64,
118 line_starts: Vec<u64>,
121 has_header: bool,
123}
124
125impl Journal {
126 #[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 #[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 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 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 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 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 #[must_use]
275 pub const fn len(&self) -> usize {
276 self.entries.len()
277 }
278
279 #[must_use]
281 pub const fn is_empty(&self) -> bool {
282 self.entries.is_empty()
283 }
284
285 #[must_use]
287 pub const fn bytes(&self) -> u64 {
288 self.bytes
289 }
290
291 #[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 #[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 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 #[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 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 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 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 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 #[must_use]
471 pub fn path(&self) -> Option<&Path> {
472 self.path.as_deref()
473 }
474
475 #[must_use]
477 pub fn entries(&self) -> &[JournalEntry] {
478 &self.entries
479 }
480}
481
482#[must_use]
484pub fn host_error_sentinel(message: &str) -> serde_json::Value {
485 serde_json::json!({ HOST_ERROR_KEY: message })
486}
487
488#[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#[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#[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#[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 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 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 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 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 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('{'); 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 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 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 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}