Skip to main content

omni_dev/sessions/
journal.rs

1//! The durable per-session hook journals (#2108): the record format, the paths,
2//! and the writer the hook sink uses.
3//!
4//! The registry is in memory, and the hook sink is fail-open: it gives up after
5//! two seconds when the daemon is unreachable. So an event fired while the daemon
6//! was restarting, or before a cold socket-activated daemon answered, used to be
7//! lost for good, and a restart forgot every session. The sink now appends each
8//! event to a journal **before** it posts it, and the daemon replays and tails
9//! those journals (`journal_watcher`), taking whatever
10//! the socket missed.
11//!
12//! **Hooks own the events; the daemon owns the state.** A hook never computes a
13//! state: [`SessionState::for_event`](super::SessionState::for_event) depends on
14//! the current state, subagent waits roll up per session, a replaced process's
15//! stragglers are filtered (#1948), and two hooks racing a read-modify-write
16//! would lose one. So a journal holds raw events, one JSON object per line, and
17//! the daemon runs them through the same state machine as a socket event.
18//!
19//! A record carries only what the sink already sends over the socket — ids, `cwd`,
20//! the transcript *path*, the model id, the pid and the event kind — never a
21//! prompt, a tool input or any transcript content.
22//!
23//! Layout, beside the control socket so the hook and the daemon resolve the same
24//! directory by construction (a `--socket` override moves the journals with it):
25//! `<socket dir>/sessions/<agent>/<session_id>.jsonl`, directories `0700`, files
26//! `0600`. Writers append with `O_APPEND` single-line writes, so concurrent hook
27//! processes need no lock. Cleanup is the daemon's.
28
29use 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
41/// How long a journal may sit unwritten before it is deleted, and the oldest last
42/// event a replay will accept. Generous: a session left open and idle for days is
43/// real, and a live pid is cheap proof.
44pub const MAX_JOURNAL_AGE: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
45
46/// The size past which the hook sink stops appending to a journal. The daemon
47/// compacts a journal long before this, so reaching it means nothing is reading
48/// or tending the journals (the sessions service is not running), and the sink
49/// must not grow a file without bound on its own.
50const SINK_MAX_BYTES: u64 = 512 * 1024;
51
52/// The record format version. A reader skips a record with a newer version
53/// rather than guess at its fields; unknown *fields* of a known version are
54/// ignored, as everywhere on this wire.
55pub const RECORD_VERSION: u32 = 1;
56
57/// One journal line: a hook event, stamped.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct JournalRecord {
60    /// The format version, [`RECORD_VERSION`].
61    pub v: u32,
62    /// When the hook fired (see [`EventStamp::ts`]).
63    pub ts: DateTime<Utc>,
64    /// The event's nonce (see [`EventStamp::seq`]).
65    pub seq: String,
66    /// Which agent's hook wrote it; also names the journal's directory.
67    pub agent: Agent,
68    /// The session the event belongs to; also names the journal file.
69    pub session_id: String,
70    /// What happened.
71    #[serde(flatten)]
72    pub body: JournalBody,
73}
74
75/// What a [`JournalRecord`] reports, tagged `op` on the wire like the socket ops
76/// it mirrors.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(tag = "op", rename_all = "snake_case")]
79pub enum JournalBody {
80    /// An `observe` sighting.
81    Observe {
82        /// The hook event, already mapped to a [`SessionEvent`] by the sink.
83        event: SessionEvent,
84        /// Claude subagent identity, for a subagent's hook.
85        #[serde(default, skip_serializing_if = "Option::is_none")]
86        agent_id: Option<String>,
87        /// The session's working directory.
88        #[serde(default, skip_serializing_if = "Option::is_none")]
89        cwd: Option<PathBuf>,
90        /// The transcript's path, never its content.
91        #[serde(default, skip_serializing_if = "Option::is_none")]
92        transcript_path: Option<PathBuf>,
93        /// The model id, when the payload carried one.
94        #[serde(default, skip_serializing_if = "Option::is_none")]
95        model: Option<String>,
96        /// The agent process's pid (the hook's parent).
97        #[serde(default, skip_serializing_if = "Option::is_none")]
98        pid: Option<u32>,
99    },
100    /// A `SessionEnd`.
101    End {
102        /// Why the session ended, when the payload said.
103        #[serde(default, skip_serializing_if = "Option::is_none")]
104        reason: Option<String>,
105        /// The agent process's pid, which tells a replaced process's late end
106        /// from the owner's (#1948).
107        #[serde(default, skip_serializing_if = "Option::is_none")]
108        pid: Option<u32>,
109    },
110}
111
112impl JournalRecord {
113    /// The record for an `observe` the sink is about to send.
114    #[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    /// The record for a `SessionEnd` the sink is about to send.
134    #[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    /// This record's [`EventStamp`].
156    #[must_use]
157    pub fn stamp(&self) -> EventStamp {
158        EventStamp {
159            ts: self.ts,
160            seq: self.seq.clone(),
161        }
162    }
163
164    /// The pid the record names, if any.
165    #[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    /// The `observe` this record replays as, or `None` for an `end`.
173    #[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    /// Parses one journal line. `None` for anything this reader should skip: a
200    /// torn or garbled line, a record of a newer format version, or one naming
201    /// an id or agent no journal could hold.
202    #[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/// A fresh stamp for an event firing now.
213#[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/// The directory name an agent's journals live under, or `None` for an agent
227/// that has no hook feed (pi.dev reports through its own extension).
228#[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/// The journal file for a session, under `dir` (the journal root), or `None`
238/// when the agent has no journal or the id is not a UUID. The id is lower-cased,
239/// so the same session never gets two files.
240#[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
249/// Appends `record` to its session's journal under `dir`, creating the
250/// directories `0700` and the file `0600` as needed.
251///
252/// One `write` of the whole line to an `O_APPEND` file, so concurrent hook
253/// processes need no lock. The caller is the fail-open hook sink, which swallows
254/// the error: a journal that cannot be written costs the durability of one event,
255/// never a turn.
256///
257/// The sink cannot tell whether a daemon is reading, so it bounds itself: it does
258/// not journal at all until the daemon's runtime directory (`dir`'s parent)
259/// exists, stops appending to a file past `SINK_MAX_BYTES`, and, whenever it
260/// starts a new journal, deletes sibling journals untouched for
261/// [`MAX_JOURNAL_AGE`]. A daemon-less install therefore cannot accumulate
262/// journals without limit.
263///
264/// # Errors
265///
266/// When the session id or agent cannot be journaled, the runtime directory does
267/// not exist, a directory or the file cannot be created or opened, the journal is
268/// over its cap, or the write fails.
269pub 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
300/// Deletes the other `<uuid>.jsonl` journals in `agent_dir` that nothing has
301/// written to for [`MAX_JOURNAL_AGE`]. Best-effort, and only regular files with a
302/// journal's name; a symlink is never followed.
303fn 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        // `symlink_metadata`, so a link is never followed; an entry that vanished
318        // since the listing is simply not stale.
319        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            // Best-effort housekeeping: a failure only leaves the file for the
329            // daemon's sweep.
330            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        // A torn line (the writer was killed mid-write) and plain junk.
432        assert!(JournalRecord::parse(&line[..line.len() / 2]).is_none());
433        assert!(JournalRecord::parse("not json").is_none());
434        assert!(JournalRecord::parse("").is_none());
435        // A format this reader does not know.
436        let newer = line.replacen("\"v\":1", "\"v\":2", 1);
437        assert!(JournalRecord::parse(&newer).is_none());
438        // An id no journal is named for, and an agent with no journal.
439        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        // An unknown extra field is ignored.
444        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", // one short
458            "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4cc", // one long
459            "0b7e6c1a2f4d4a8e9c3b5d1e7f9a2b4c",    // no hyphens
460            "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4g", // not hex
461            "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        // The root is a file: the directory cannot be made, so it errors
553        // (the sink swallows it).
554        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        // The sink's payload has prompts and tool inputs; `ObserveRequest` and so
589        // the record have no field for them. Pin that the line holds only the
590        // documented keys.
591        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        // The journal root is `<runtime dir>/sessions`: a machine that has never
635        // run the daemon has no runtime dir, and the hook must not create one.
636        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        // An append to an existing journal does not sweep again.
703        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        // A directory nobody may write to refuses the unlink.
727        std::fs::set_permissions(&agent_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
728        // Root ignores directory permissions, so only then is the unlink allowed.
729        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}