1use std::io::Write;
30use std::os::unix::fs::OpenOptionsExt;
31use std::path::{Path, PathBuf};
32
33use anyhow::{Context, Result};
34use chrono::{DateTime, Utc};
35use serde::{Deserialize, Serialize};
36
37pub use super::is_session_uuid;
38use super::{Agent, EventStamp, ObserveRequest, SessionEvent};
39use crate::daemon::paths;
40
41pub const MAX_JOURNAL_AGE: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
45
46const SINK_MAX_BYTES: u64 = 512 * 1024;
51
52pub const RECORD_VERSION: u32 = 1;
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct JournalRecord {
60 pub v: u32,
62 pub ts: DateTime<Utc>,
64 pub seq: String,
66 pub agent: Agent,
68 pub session_id: String,
70 #[serde(flatten)]
72 pub body: JournalBody,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(tag = "op", rename_all = "snake_case")]
79pub enum JournalBody {
80 Observe {
82 event: SessionEvent,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 agent_id: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 cwd: Option<PathBuf>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
92 transcript_path: Option<PathBuf>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 model: Option<String>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pid: Option<u32>,
99 },
100 End {
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 reason: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pid: Option<u32>,
109 },
110}
111
112impl JournalRecord {
113 #[must_use]
115 pub fn observe(req: &ObserveRequest, stamp: &EventStamp) -> Self {
116 Self {
117 v: RECORD_VERSION,
118 ts: stamp.ts,
119 seq: stamp.seq.clone(),
120 agent: req.agent,
121 session_id: req.session_id.clone(),
122 body: JournalBody::Observe {
123 event: req.event,
124 agent_id: req.agent_id.clone(),
125 cwd: req.cwd.clone(),
126 transcript_path: req.transcript_path.clone(),
127 model: req.model.clone(),
128 pid: req.pid,
129 },
130 }
131 }
132
133 #[must_use]
135 pub fn end(
136 agent: Agent,
137 session_id: &str,
138 reason: Option<&str>,
139 pid: Option<u32>,
140 stamp: &EventStamp,
141 ) -> Self {
142 Self {
143 v: RECORD_VERSION,
144 ts: stamp.ts,
145 seq: stamp.seq.clone(),
146 agent,
147 session_id: session_id.to_string(),
148 body: JournalBody::End {
149 reason: reason.map(str::to_string),
150 pid,
151 },
152 }
153 }
154
155 #[must_use]
157 pub fn stamp(&self) -> EventStamp {
158 EventStamp {
159 ts: self.ts,
160 seq: self.seq.clone(),
161 }
162 }
163
164 #[must_use]
166 pub fn pid(&self) -> Option<u32> {
167 match &self.body {
168 JournalBody::Observe { pid, .. } | JournalBody::End { pid, .. } => *pid,
169 }
170 }
171
172 #[must_use]
174 pub fn to_observe_request(&self) -> Option<ObserveRequest> {
175 let JournalBody::Observe {
176 event,
177 agent_id,
178 cwd,
179 transcript_path,
180 model,
181 pid,
182 } = &self.body
183 else {
184 return None;
185 };
186 Some(ObserveRequest {
187 agent_id: agent_id.clone(),
188 session_id: self.session_id.clone(),
189 cwd: cwd.clone(),
190 transcript_path: transcript_path.clone(),
191 event: *event,
192 repo: None,
193 model: model.clone(),
194 agent: self.agent,
195 pid: *pid,
196 })
197 }
198
199 #[must_use]
203 pub fn parse(line: &str) -> Option<Self> {
204 let record: Self = serde_json::from_str(line.trim()).ok()?;
205 (record.v == RECORD_VERSION
206 && is_session_uuid(&record.session_id)
207 && agent_dir_name(record.agent).is_some())
208 .then_some(record)
209 }
210}
211
212#[must_use]
214pub fn new_stamp() -> EventStamp {
215 let ts = Utc::now();
216 EventStamp {
217 seq: format!(
218 "{}-{}",
219 std::process::id(),
220 ts.timestamp_nanos_opt().unwrap_or_default()
221 ),
222 ts,
223 }
224}
225
226#[must_use]
229pub fn agent_dir_name(agent: Agent) -> Option<&'static str> {
230 match agent {
231 Agent::Claude => Some("claude"),
232 Agent::Codex => Some("codex"),
233 Agent::Pi => None,
234 }
235}
236
237#[must_use]
241pub fn journal_path(dir: &Path, agent: Agent, session_id: &str) -> Option<PathBuf> {
242 let agent_dir = agent_dir_name(agent)?;
243 is_session_uuid(session_id).then(|| {
244 dir.join(agent_dir)
245 .join(format!("{}.jsonl", session_id.to_ascii_lowercase()))
246 })
247}
248
249pub fn append(dir: &Path, record: &JournalRecord) -> Result<()> {
270 let path = journal_path(dir, record.agent, &record.session_id)
271 .context("the session id or agent cannot be journaled")?;
272 let agent_dir = path
273 .parent()
274 .context("a journal path always has a parent")?;
275 anyhow::ensure!(
276 dir.parent().is_some_and(Path::is_dir),
277 "the daemon's runtime directory does not exist yet"
278 );
279 paths::ensure_dir_0700(dir)?;
280 paths::ensure_dir_0700(agent_dir)?;
281 let mut line = serde_json::to_vec(record).context("failed to serialize the journal record")?;
282 line.push(b'\n');
283 let file = std::fs::OpenOptions::new()
284 .create(true)
285 .append(true)
286 .mode(0o600)
287 .open(&path)
288 .with_context(|| format!("failed to open journal {}", path.display()))?;
289 paths::ensure_handle_0600(&file)?;
290 let len = file.metadata().context("failed to stat the journal")?.len();
291 anyhow::ensure!(len < SINK_MAX_BYTES, "the journal is over its size cap");
292 if len == 0 {
293 sweep_stale(agent_dir, &path);
294 }
295 (&file)
296 .write_all(&line)
297 .with_context(|| format!("failed to append to journal {}", path.display()))
298}
299
300fn sweep_stale(agent_dir: &Path, keep: &Path) {
304 let Ok(entries) = std::fs::read_dir(agent_dir) else {
305 return;
306 };
307 for entry in entries.flatten() {
308 let path = entry.path();
309 let is_journal = path
310 .file_name()
311 .and_then(|n| n.to_str())
312 .and_then(|n| n.strip_suffix(".jsonl"))
313 .is_some_and(is_session_uuid);
314 if path == keep || !is_journal {
315 continue;
316 }
317 let stale = std::fs::symlink_metadata(&path).is_ok_and(|meta| {
320 meta.is_file()
321 && meta
322 .modified()
323 .ok()
324 .and_then(|m| m.elapsed().ok())
325 .is_some_and(|age| age > MAX_JOURNAL_AGE)
326 });
327 if stale {
328 if let Err(error) = std::fs::remove_file(&path) {
331 tracing::debug!(%error, "session_journal_sweep_failed");
332 }
333 }
334 }
335}
336
337#[cfg(test)]
338#[allow(clippy::unwrap_used, clippy::expect_used)]
339mod tests {
340 use super::*;
341 use crate::sessions::NotificationKind;
342 use std::os::unix::fs::PermissionsExt;
343
344 const ID: &str = "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4c";
345
346 fn stamp(ts: &str, seq: &str) -> EventStamp {
347 EventStamp {
348 ts: ts.parse().unwrap(),
349 seq: seq.to_string(),
350 }
351 }
352
353 fn observe_req(event: SessionEvent) -> ObserveRequest {
354 ObserveRequest {
355 agent_id: None,
356 session_id: ID.to_string(),
357 cwd: Some(PathBuf::from("/work/repo")),
358 transcript_path: Some(PathBuf::from("/home/me/.claude/projects/x/t.jsonl")),
359 event,
360 repo: None,
361 model: Some("claude-x".to_string()),
362 agent: Agent::Claude,
363 pid: Some(4242),
364 }
365 }
366
367 #[test]
368 fn an_observe_record_round_trips_through_a_line() {
369 let st = stamp("2026-10-03T03:40:23.186123456Z", "7-1");
370 let req = observe_req(SessionEvent::Notification(
371 NotificationKind::PermissionPrompt,
372 ));
373 let record = JournalRecord::observe(&req, &st);
374 let line = serde_json::to_string(&record).unwrap();
375 assert!(!line.contains('\n'), "one record is one line");
376 let parsed = JournalRecord::parse(&line).unwrap();
377 assert_eq!(parsed, record);
378 assert_eq!(parsed.stamp(), st);
379 assert_eq!(parsed.pid(), Some(4242));
380 let back = parsed.to_observe_request().unwrap();
381 assert_eq!(back.session_id, ID);
382 assert_eq!(back.event, req.event);
383 assert_eq!(back.cwd, req.cwd);
384 assert_eq!(back.pid, Some(4242));
385 assert_eq!(back.repo, None, "repo is the daemon's to enrich");
386 }
387
388 #[test]
389 fn the_line_shape_is_the_documented_one() {
390 let st = stamp("2026-10-03T03:40:23.186Z", "7-1");
391 let mut req = observe_req(SessionEvent::PostToolUse);
392 req.agent = Agent::Codex;
393 req.transcript_path = None;
394 req.model = None;
395 let value: serde_json::Value = serde_json::from_str(
396 &serde_json::to_string(&JournalRecord::observe(&req, &st)).unwrap(),
397 )
398 .unwrap();
399 assert_eq!(
400 value,
401 serde_json::json!({
402 "v": 1,
403 "ts": "2026-10-03T03:40:23.186Z",
404 "seq": "7-1",
405 "agent": "codex",
406 "session_id": ID,
407 "op": "observe",
408 "event": "post_tool_use",
409 "cwd": "/work/repo",
410 "pid": 4242,
411 })
412 );
413 }
414
415 #[test]
416 fn an_end_record_round_trips_and_is_not_an_observe() {
417 let st = stamp("2026-10-03T03:41:00Z", "7-2");
418 let record = JournalRecord::end(Agent::Claude, ID, Some("clear"), Some(9), &st);
419 let parsed = JournalRecord::parse(&serde_json::to_string(&record).unwrap()).unwrap();
420 assert_eq!(parsed, record);
421 assert_eq!(parsed.pid(), Some(9));
422 assert!(parsed.to_observe_request().is_none());
423 }
424
425 #[test]
426 fn parse_skips_junk_newer_versions_and_unjournalable_ids() {
427 let st = stamp("2026-10-03T03:41:00Z", "7-2");
428 let good = JournalRecord::observe(&observe_req(SessionEvent::Stop), &st);
429 let line = serde_json::to_string(&good).unwrap();
430 assert!(JournalRecord::parse(&line).is_some());
431 assert!(JournalRecord::parse(&line[..line.len() / 2]).is_none());
433 assert!(JournalRecord::parse("not json").is_none());
434 assert!(JournalRecord::parse("").is_none());
435 let newer = line.replacen("\"v\":1", "\"v\":2", 1);
437 assert!(JournalRecord::parse(&newer).is_none());
438 let odd_id = line.replacen(ID, "../../etc/passwd", 1);
440 assert!(JournalRecord::parse(&odd_id).is_none());
441 let pi = line.replacen("\"agent\":\"claude\"", "\"agent\":\"pi\"", 1);
442 assert!(JournalRecord::parse(&pi).is_none());
443 let extra = line.replacen("{\"v\":1", "{\"future\":true,\"v\":1", 1);
445 assert!(JournalRecord::parse(&extra).is_some());
446 }
447
448 #[test]
449 fn only_canonical_uuids_name_a_journal() {
450 assert!(is_session_uuid(ID));
451 assert!(is_session_uuid(&ID.to_uppercase()));
452 for bad in [
453 "",
454 "s1",
455 "..",
456 "../x",
457 "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4", "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4cc", "0b7e6c1a2f4d4a8e9c3b5d1e7f9a2b4c", "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4g", "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4/",
462 "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4c\n",
463 ] {
464 assert!(!is_session_uuid(bad), "{bad:?}");
465 }
466 let dir = Path::new("/j");
467 assert_eq!(
468 journal_path(dir, Agent::Claude, ID),
469 Some(PathBuf::from(format!("/j/claude/{ID}.jsonl")))
470 );
471 assert_eq!(
472 journal_path(dir, Agent::Codex, &ID.to_uppercase()),
473 Some(PathBuf::from(format!("/j/codex/{ID}.jsonl"))),
474 "the same session maps to one file whatever the case"
475 );
476 assert_eq!(journal_path(dir, Agent::Pi, ID), None);
477 assert_eq!(journal_path(dir, Agent::Claude, "../../x"), None);
478 }
479
480 #[test]
481 fn new_stamps_are_unique_per_event() {
482 let a = new_stamp();
483 let b = new_stamp();
484 assert_ne!(a.seq, b.seq);
485 assert!(a.seq.starts_with(&format!("{}-", std::process::id())));
486 }
487
488 #[test]
489 fn append_creates_a_private_tree_and_adds_whole_lines() {
490 let tmp = tempfile::tempdir().unwrap();
491 let dir = tmp.path().join("sessions");
492 let first = JournalRecord::observe(
493 &observe_req(SessionEvent::SessionStart),
494 &stamp("2026-10-03T03:40:00Z", "7-1"),
495 );
496 let second = JournalRecord::end(
497 Agent::Claude,
498 ID,
499 None,
500 Some(4242),
501 &stamp("2026-10-03T03:41:00Z", "7-2"),
502 );
503 append(&dir, &first).unwrap();
504 append(&dir, &second).unwrap();
505
506 let path = journal_path(&dir, Agent::Claude, ID).unwrap();
507 let text = std::fs::read_to_string(&path).unwrap();
508 let records: Vec<JournalRecord> = text
509 .lines()
510 .map(|l| JournalRecord::parse(l).unwrap())
511 .collect();
512 assert_eq!(records, vec![first, second]);
513 let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
514 assert_eq!(mode(&path), 0o600);
515 assert_eq!(mode(path.parent().unwrap()), 0o700);
516 assert_eq!(mode(&dir), 0o700);
517 }
518
519 #[test]
520 fn append_tightens_a_looser_existing_file() {
521 let tmp = tempfile::tempdir().unwrap();
522 let dir = tmp.path().join("sessions");
523 let path = journal_path(&dir, Agent::Claude, ID).unwrap();
524 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
525 std::fs::write(&path, "").unwrap();
526 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
527 append(
528 &dir,
529 &JournalRecord::observe(
530 &observe_req(SessionEvent::Stop),
531 &stamp("2026-10-03T03:40:00Z", "7-1"),
532 ),
533 )
534 .unwrap();
535 assert_eq!(
536 std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
537 0o600
538 );
539 }
540
541 #[test]
542 fn append_refuses_an_unjournalable_session_and_reports_an_unwritable_root() {
543 let tmp = tempfile::tempdir().unwrap();
544 let st = stamp("2026-10-03T03:40:00Z", "7-1");
545 let mut bad = observe_req(SessionEvent::Stop);
546 bad.session_id = "../../escape".to_string();
547 assert!(append(tmp.path(), &JournalRecord::observe(&bad, &st)).is_err());
548 assert!(
549 std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
550 "a refused id must not create anything"
551 );
552 let file_root = tmp.path().join("sessions");
555 std::fs::write(&file_root, "").unwrap();
556 let ok = observe_req(SessionEvent::Stop);
557 assert!(append(&file_root, &JournalRecord::observe(&ok, &st)).is_err());
558 }
559
560 #[test]
561 fn concurrent_appenders_never_split_a_line() {
562 let tmp = tempfile::tempdir().unwrap();
563 let dir = tmp.path().join("sessions");
564 let threads: Vec<_> = (0..8)
565 .map(|t| {
566 let dir = dir.clone();
567 std::thread::spawn(move || {
568 for i in 0..50 {
569 let record = JournalRecord::observe(
570 &observe_req(SessionEvent::PreToolUse),
571 &stamp("2026-10-03T03:40:00Z", &format!("{t}-{i}")),
572 );
573 append(&dir, &record).unwrap();
574 }
575 })
576 })
577 .collect();
578 for t in threads {
579 t.join().unwrap();
580 }
581 let text = std::fs::read_to_string(journal_path(&dir, Agent::Claude, ID).unwrap()).unwrap();
582 assert_eq!(text.lines().count(), 400);
583 assert!(text.lines().all(|l| JournalRecord::parse(l).is_some()));
584 }
585
586 #[test]
587 fn a_record_never_carries_conversation_content() {
588 let req = observe_req(SessionEvent::UserPromptSubmit);
592 let value = serde_json::to_value(JournalRecord::observe(
593 &req,
594 &stamp("2026-10-03T03:40:00Z", "7-1"),
595 ))
596 .unwrap();
597 let mut keys: Vec<&str> = value
598 .as_object()
599 .unwrap()
600 .keys()
601 .map(String::as_str)
602 .collect();
603 keys.sort_unstable();
604 assert_eq!(
605 keys,
606 [
607 "agent",
608 "cwd",
609 "event",
610 "model",
611 "op",
612 "pid",
613 "seq",
614 "session_id",
615 "transcript_path",
616 "ts",
617 "v"
618 ]
619 );
620 }
621
622 fn age_file(path: &Path, secs: u64) {
623 let when = std::time::SystemTime::now() - std::time::Duration::from_secs(secs);
624 std::fs::OpenOptions::new()
625 .write(true)
626 .open(path)
627 .unwrap()
628 .set_modified(when)
629 .unwrap();
630 }
631
632 #[test]
633 fn the_sink_does_not_journal_until_the_daemons_runtime_directory_exists() {
634 let tmp = tempfile::tempdir().unwrap();
637 let dir = tmp.path().join("never-ran").join("sessions");
638 let record = JournalRecord::observe(
639 &observe_req(SessionEvent::Stop),
640 &stamp("2026-10-03T03:40:00Z", "7-1"),
641 );
642 let err = append(&dir, &record).unwrap_err();
643 assert!(err.to_string().contains("runtime directory"), "{err}");
644 assert!(!tmp.path().join("never-ran").exists());
645 }
646
647 #[test]
648 fn the_sink_stops_appending_to_a_journal_nothing_is_tending() {
649 let tmp = tempfile::tempdir().unwrap();
650 let dir = tmp.path().join("sessions");
651 let record = JournalRecord::observe(
652 &observe_req(SessionEvent::Stop),
653 &stamp("2026-10-03T03:40:00Z", "7-1"),
654 );
655 append(&dir, &record).unwrap();
656 let path = journal_path(&dir, Agent::Claude, ID).unwrap();
657 std::fs::OpenOptions::new()
658 .write(true)
659 .open(&path)
660 .unwrap()
661 .set_len(SINK_MAX_BYTES)
662 .unwrap();
663 let err = append(&dir, &record).unwrap_err();
664 assert!(err.to_string().contains("size cap"), "{err}");
665 assert_eq!(std::fs::metadata(&path).unwrap().len(), SINK_MAX_BYTES);
666 }
667
668 #[test]
669 fn starting_a_journal_sweeps_week_old_siblings_only() {
670 let tmp = tempfile::tempdir().unwrap();
671 let dir = tmp.path().join("sessions");
672 let agent_dir = dir.join("claude");
673 std::fs::create_dir_all(&agent_dir).unwrap();
674 let stale = agent_dir.join("1c8f7d2b-3a5e-4b9f-8d4c-6e2f8a0b3c5d.jsonl");
675 let fresh = agent_dir.join("2d9a8e3c-4b6f-4c0a-9e5d-7f3a9b1c4d6e.jsonl");
676 let foreign = agent_dir.join("notes.jsonl");
677 for p in [&stale, &fresh, &foreign] {
678 std::fs::write(p, "x\n").unwrap();
679 }
680 age_file(&stale, 8 * 24 * 3600);
681 age_file(&foreign, 8 * 24 * 3600);
682 let outside = tmp.path().join("outside.txt");
683 std::fs::write(&outside, "keep").unwrap();
684 let link = agent_dir.join("3e0b9f4d-5c7a-4d1b-8f6e-8a4b0c2d5e7f.jsonl");
685 std::os::unix::fs::symlink(&outside, &link).unwrap();
686
687 let record = JournalRecord::observe(
688 &observe_req(SessionEvent::SessionStart),
689 &stamp("2026-10-03T03:40:00Z", "7-1"),
690 );
691 append(&dir, &record).unwrap();
692 assert!(!stale.exists(), "a week-old journal is swept");
693 assert!(
694 fresh.exists() && foreign.exists(),
695 "only stale journals by name"
696 );
697 assert!(
698 link.symlink_metadata().is_ok() && outside.exists(),
699 "no symlink followed"
700 );
701
702 age_file(&fresh, 8 * 24 * 3600);
704 append(&dir, &record).unwrap();
705 assert!(fresh.exists());
706 }
707
708 #[test]
709 fn sweeping_a_directory_that_is_not_there_does_nothing() {
710 let tmp = tempfile::tempdir().unwrap();
711 let missing = tmp.path().join("claude");
712 sweep_stale(&missing, &missing.join(format!("{ID}.jsonl")));
713 assert!(!missing.exists());
714 }
715
716 #[test]
717 fn a_stale_journal_that_cannot_be_deleted_is_left_for_the_daemons_sweep() {
718 let tmp = tempfile::tempdir().unwrap();
719 let agent_dir = tmp.path().join("claude");
720 std::fs::create_dir_all(&agent_dir).unwrap();
721 let stale = agent_dir.join("1c8f7d2b-3a5e-4b9f-8d4c-6e2f8a0b3c5d.jsonl");
722 let keep = agent_dir.join("2d9a8e3c-4b6f-4c0a-9e5d-7f3a9b1c4d6e.jsonl");
723 std::fs::write(&stale, "x\n").unwrap();
724 age_file(&stale, 8 * 24 * 3600);
725
726 std::fs::set_permissions(&agent_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
728 let privileged = std::fs::write(agent_dir.join("probe"), "").is_ok();
730 sweep_stale(&agent_dir, &keep);
731 std::fs::set_permissions(&agent_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
732 assert_eq!(stale.exists(), !privileged, "privileged: {privileged}");
733 }
734}