use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
pub use super::is_session_uuid;
use super::{Agent, EventStamp, ObserveRequest, SessionEvent};
use crate::daemon::paths;
pub const MAX_JOURNAL_AGE: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 60 * 60);
const SINK_MAX_BYTES: u64 = 512 * 1024;
pub const RECORD_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JournalRecord {
pub v: u32,
pub ts: DateTime<Utc>,
pub seq: String,
pub agent: Agent,
pub session_id: String,
#[serde(flatten)]
pub body: JournalBody,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "op", rename_all = "snake_case")]
pub enum JournalBody {
Observe {
event: SessionEvent,
#[serde(default, skip_serializing_if = "Option::is_none")]
agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
cwd: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
transcript_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pid: Option<u32>,
},
End {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pid: Option<u32>,
},
}
impl JournalRecord {
#[must_use]
pub fn observe(req: &ObserveRequest, stamp: &EventStamp) -> Self {
Self {
v: RECORD_VERSION,
ts: stamp.ts,
seq: stamp.seq.clone(),
agent: req.agent,
session_id: req.session_id.clone(),
body: JournalBody::Observe {
event: req.event,
agent_id: req.agent_id.clone(),
cwd: req.cwd.clone(),
transcript_path: req.transcript_path.clone(),
model: req.model.clone(),
pid: req.pid,
},
}
}
#[must_use]
pub fn end(
agent: Agent,
session_id: &str,
reason: Option<&str>,
pid: Option<u32>,
stamp: &EventStamp,
) -> Self {
Self {
v: RECORD_VERSION,
ts: stamp.ts,
seq: stamp.seq.clone(),
agent,
session_id: session_id.to_string(),
body: JournalBody::End {
reason: reason.map(str::to_string),
pid,
},
}
}
#[must_use]
pub fn stamp(&self) -> EventStamp {
EventStamp {
ts: self.ts,
seq: self.seq.clone(),
}
}
#[must_use]
pub fn pid(&self) -> Option<u32> {
match &self.body {
JournalBody::Observe { pid, .. } | JournalBody::End { pid, .. } => *pid,
}
}
#[must_use]
pub fn to_observe_request(&self) -> Option<ObserveRequest> {
let JournalBody::Observe {
event,
agent_id,
cwd,
transcript_path,
model,
pid,
} = &self.body
else {
return None;
};
Some(ObserveRequest {
agent_id: agent_id.clone(),
session_id: self.session_id.clone(),
cwd: cwd.clone(),
transcript_path: transcript_path.clone(),
event: *event,
repo: None,
model: model.clone(),
agent: self.agent,
pid: *pid,
})
}
#[must_use]
pub fn parse(line: &str) -> Option<Self> {
let record: Self = serde_json::from_str(line.trim()).ok()?;
(record.v == RECORD_VERSION
&& is_session_uuid(&record.session_id)
&& agent_dir_name(record.agent).is_some())
.then_some(record)
}
}
#[must_use]
pub fn new_stamp() -> EventStamp {
let ts = Utc::now();
EventStamp {
seq: format!(
"{}-{}",
std::process::id(),
ts.timestamp_nanos_opt().unwrap_or_default()
),
ts,
}
}
#[must_use]
pub fn agent_dir_name(agent: Agent) -> Option<&'static str> {
match agent {
Agent::Claude => Some("claude"),
Agent::Codex => Some("codex"),
Agent::Pi => None,
}
}
#[must_use]
pub fn journal_path(dir: &Path, agent: Agent, session_id: &str) -> Option<PathBuf> {
let agent_dir = agent_dir_name(agent)?;
is_session_uuid(session_id).then(|| {
dir.join(agent_dir)
.join(format!("{}.jsonl", session_id.to_ascii_lowercase()))
})
}
pub fn append(dir: &Path, record: &JournalRecord) -> Result<()> {
let path = journal_path(dir, record.agent, &record.session_id)
.context("the session id or agent cannot be journaled")?;
let agent_dir = path
.parent()
.context("a journal path always has a parent")?;
anyhow::ensure!(
dir.parent().is_some_and(Path::is_dir),
"the daemon's runtime directory does not exist yet"
);
paths::ensure_dir_0700(dir)?;
paths::ensure_dir_0700(agent_dir)?;
let mut line = serde_json::to_vec(record).context("failed to serialize the journal record")?;
line.push(b'\n');
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(&path)
.with_context(|| format!("failed to open journal {}", path.display()))?;
paths::ensure_handle_0600(&file)?;
let len = file.metadata().context("failed to stat the journal")?.len();
anyhow::ensure!(len < SINK_MAX_BYTES, "the journal is over its size cap");
if len == 0 {
sweep_stale(agent_dir, &path);
}
(&file)
.write_all(&line)
.with_context(|| format!("failed to append to journal {}", path.display()))
}
fn sweep_stale(agent_dir: &Path, keep: &Path) {
let Ok(entries) = std::fs::read_dir(agent_dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let is_journal = path
.file_name()
.and_then(|n| n.to_str())
.and_then(|n| n.strip_suffix(".jsonl"))
.is_some_and(is_session_uuid);
if path == keep || !is_journal {
continue;
}
let stale = std::fs::symlink_metadata(&path).is_ok_and(|meta| {
meta.is_file()
&& meta
.modified()
.ok()
.and_then(|m| m.elapsed().ok())
.is_some_and(|age| age > MAX_JOURNAL_AGE)
});
if stale {
if let Err(error) = std::fs::remove_file(&path) {
tracing::debug!(%error, "session_journal_sweep_failed");
}
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::sessions::NotificationKind;
use std::os::unix::fs::PermissionsExt;
const ID: &str = "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4c";
fn stamp(ts: &str, seq: &str) -> EventStamp {
EventStamp {
ts: ts.parse().unwrap(),
seq: seq.to_string(),
}
}
fn observe_req(event: SessionEvent) -> ObserveRequest {
ObserveRequest {
agent_id: None,
session_id: ID.to_string(),
cwd: Some(PathBuf::from("/work/repo")),
transcript_path: Some(PathBuf::from("/home/me/.claude/projects/x/t.jsonl")),
event,
repo: None,
model: Some("claude-x".to_string()),
agent: Agent::Claude,
pid: Some(4242),
}
}
#[test]
fn an_observe_record_round_trips_through_a_line() {
let st = stamp("2026-10-03T03:40:23.186123456Z", "7-1");
let req = observe_req(SessionEvent::Notification(
NotificationKind::PermissionPrompt,
));
let record = JournalRecord::observe(&req, &st);
let line = serde_json::to_string(&record).unwrap();
assert!(!line.contains('\n'), "one record is one line");
let parsed = JournalRecord::parse(&line).unwrap();
assert_eq!(parsed, record);
assert_eq!(parsed.stamp(), st);
assert_eq!(parsed.pid(), Some(4242));
let back = parsed.to_observe_request().unwrap();
assert_eq!(back.session_id, ID);
assert_eq!(back.event, req.event);
assert_eq!(back.cwd, req.cwd);
assert_eq!(back.pid, Some(4242));
assert_eq!(back.repo, None, "repo is the daemon's to enrich");
}
#[test]
fn the_line_shape_is_the_documented_one() {
let st = stamp("2026-10-03T03:40:23.186Z", "7-1");
let mut req = observe_req(SessionEvent::PostToolUse);
req.agent = Agent::Codex;
req.transcript_path = None;
req.model = None;
let value: serde_json::Value = serde_json::from_str(
&serde_json::to_string(&JournalRecord::observe(&req, &st)).unwrap(),
)
.unwrap();
assert_eq!(
value,
serde_json::json!({
"v": 1,
"ts": "2026-10-03T03:40:23.186Z",
"seq": "7-1",
"agent": "codex",
"session_id": ID,
"op": "observe",
"event": "post_tool_use",
"cwd": "/work/repo",
"pid": 4242,
})
);
}
#[test]
fn an_end_record_round_trips_and_is_not_an_observe() {
let st = stamp("2026-10-03T03:41:00Z", "7-2");
let record = JournalRecord::end(Agent::Claude, ID, Some("clear"), Some(9), &st);
let parsed = JournalRecord::parse(&serde_json::to_string(&record).unwrap()).unwrap();
assert_eq!(parsed, record);
assert_eq!(parsed.pid(), Some(9));
assert!(parsed.to_observe_request().is_none());
}
#[test]
fn parse_skips_junk_newer_versions_and_unjournalable_ids() {
let st = stamp("2026-10-03T03:41:00Z", "7-2");
let good = JournalRecord::observe(&observe_req(SessionEvent::Stop), &st);
let line = serde_json::to_string(&good).unwrap();
assert!(JournalRecord::parse(&line).is_some());
assert!(JournalRecord::parse(&line[..line.len() / 2]).is_none());
assert!(JournalRecord::parse("not json").is_none());
assert!(JournalRecord::parse("").is_none());
let newer = line.replacen("\"v\":1", "\"v\":2", 1);
assert!(JournalRecord::parse(&newer).is_none());
let odd_id = line.replacen(ID, "../../etc/passwd", 1);
assert!(JournalRecord::parse(&odd_id).is_none());
let pi = line.replacen("\"agent\":\"claude\"", "\"agent\":\"pi\"", 1);
assert!(JournalRecord::parse(&pi).is_none());
let extra = line.replacen("{\"v\":1", "{\"future\":true,\"v\":1", 1);
assert!(JournalRecord::parse(&extra).is_some());
}
#[test]
fn only_canonical_uuids_name_a_journal() {
assert!(is_session_uuid(ID));
assert!(is_session_uuid(&ID.to_uppercase()));
for bad in [
"",
"s1",
"..",
"../x",
"0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4", "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4cc", "0b7e6c1a2f4d4a8e9c3b5d1e7f9a2b4c", "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4g", "0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4/",
"0b7e6c1a-2f4d-4a8e-9c3b-5d1e7f9a2b4c\n",
] {
assert!(!is_session_uuid(bad), "{bad:?}");
}
let dir = Path::new("/j");
assert_eq!(
journal_path(dir, Agent::Claude, ID),
Some(PathBuf::from(format!("/j/claude/{ID}.jsonl")))
);
assert_eq!(
journal_path(dir, Agent::Codex, &ID.to_uppercase()),
Some(PathBuf::from(format!("/j/codex/{ID}.jsonl"))),
"the same session maps to one file whatever the case"
);
assert_eq!(journal_path(dir, Agent::Pi, ID), None);
assert_eq!(journal_path(dir, Agent::Claude, "../../x"), None);
}
#[test]
fn new_stamps_are_unique_per_event() {
let a = new_stamp();
let b = new_stamp();
assert_ne!(a.seq, b.seq);
assert!(a.seq.starts_with(&format!("{}-", std::process::id())));
}
#[test]
fn append_creates_a_private_tree_and_adds_whole_lines() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("sessions");
let first = JournalRecord::observe(
&observe_req(SessionEvent::SessionStart),
&stamp("2026-10-03T03:40:00Z", "7-1"),
);
let second = JournalRecord::end(
Agent::Claude,
ID,
None,
Some(4242),
&stamp("2026-10-03T03:41:00Z", "7-2"),
);
append(&dir, &first).unwrap();
append(&dir, &second).unwrap();
let path = journal_path(&dir, Agent::Claude, ID).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let records: Vec<JournalRecord> = text
.lines()
.map(|l| JournalRecord::parse(l).unwrap())
.collect();
assert_eq!(records, vec![first, second]);
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
assert_eq!(mode(&path), 0o600);
assert_eq!(mode(path.parent().unwrap()), 0o700);
assert_eq!(mode(&dir), 0o700);
}
#[test]
fn append_tightens_a_looser_existing_file() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("sessions");
let path = journal_path(&dir, Agent::Claude, ID).unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
append(
&dir,
&JournalRecord::observe(
&observe_req(SessionEvent::Stop),
&stamp("2026-10-03T03:40:00Z", "7-1"),
),
)
.unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn append_refuses_an_unjournalable_session_and_reports_an_unwritable_root() {
let tmp = tempfile::tempdir().unwrap();
let st = stamp("2026-10-03T03:40:00Z", "7-1");
let mut bad = observe_req(SessionEvent::Stop);
bad.session_id = "../../escape".to_string();
assert!(append(tmp.path(), &JournalRecord::observe(&bad, &st)).is_err());
assert!(
std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
"a refused id must not create anything"
);
let file_root = tmp.path().join("sessions");
std::fs::write(&file_root, "").unwrap();
let ok = observe_req(SessionEvent::Stop);
assert!(append(&file_root, &JournalRecord::observe(&ok, &st)).is_err());
}
#[test]
fn concurrent_appenders_never_split_a_line() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("sessions");
let threads: Vec<_> = (0..8)
.map(|t| {
let dir = dir.clone();
std::thread::spawn(move || {
for i in 0..50 {
let record = JournalRecord::observe(
&observe_req(SessionEvent::PreToolUse),
&stamp("2026-10-03T03:40:00Z", &format!("{t}-{i}")),
);
append(&dir, &record).unwrap();
}
})
})
.collect();
for t in threads {
t.join().unwrap();
}
let text = std::fs::read_to_string(journal_path(&dir, Agent::Claude, ID).unwrap()).unwrap();
assert_eq!(text.lines().count(), 400);
assert!(text.lines().all(|l| JournalRecord::parse(l).is_some()));
}
#[test]
fn a_record_never_carries_conversation_content() {
let req = observe_req(SessionEvent::UserPromptSubmit);
let value = serde_json::to_value(JournalRecord::observe(
&req,
&stamp("2026-10-03T03:40:00Z", "7-1"),
))
.unwrap();
let mut keys: Vec<&str> = value
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect();
keys.sort_unstable();
assert_eq!(
keys,
[
"agent",
"cwd",
"event",
"model",
"op",
"pid",
"seq",
"session_id",
"transcript_path",
"ts",
"v"
]
);
}
fn age_file(path: &Path, secs: u64) {
let when = std::time::SystemTime::now() - std::time::Duration::from_secs(secs);
std::fs::OpenOptions::new()
.write(true)
.open(path)
.unwrap()
.set_modified(when)
.unwrap();
}
#[test]
fn the_sink_does_not_journal_until_the_daemons_runtime_directory_exists() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("never-ran").join("sessions");
let record = JournalRecord::observe(
&observe_req(SessionEvent::Stop),
&stamp("2026-10-03T03:40:00Z", "7-1"),
);
let err = append(&dir, &record).unwrap_err();
assert!(err.to_string().contains("runtime directory"), "{err}");
assert!(!tmp.path().join("never-ran").exists());
}
#[test]
fn the_sink_stops_appending_to_a_journal_nothing_is_tending() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("sessions");
let record = JournalRecord::observe(
&observe_req(SessionEvent::Stop),
&stamp("2026-10-03T03:40:00Z", "7-1"),
);
append(&dir, &record).unwrap();
let path = journal_path(&dir, Agent::Claude, ID).unwrap();
std::fs::OpenOptions::new()
.write(true)
.open(&path)
.unwrap()
.set_len(SINK_MAX_BYTES)
.unwrap();
let err = append(&dir, &record).unwrap_err();
assert!(err.to_string().contains("size cap"), "{err}");
assert_eq!(std::fs::metadata(&path).unwrap().len(), SINK_MAX_BYTES);
}
#[test]
fn starting_a_journal_sweeps_week_old_siblings_only() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("sessions");
let agent_dir = dir.join("claude");
std::fs::create_dir_all(&agent_dir).unwrap();
let stale = agent_dir.join("1c8f7d2b-3a5e-4b9f-8d4c-6e2f8a0b3c5d.jsonl");
let fresh = agent_dir.join("2d9a8e3c-4b6f-4c0a-9e5d-7f3a9b1c4d6e.jsonl");
let foreign = agent_dir.join("notes.jsonl");
for p in [&stale, &fresh, &foreign] {
std::fs::write(p, "x\n").unwrap();
}
age_file(&stale, 8 * 24 * 3600);
age_file(&foreign, 8 * 24 * 3600);
let outside = tmp.path().join("outside.txt");
std::fs::write(&outside, "keep").unwrap();
let link = agent_dir.join("3e0b9f4d-5c7a-4d1b-8f6e-8a4b0c2d5e7f.jsonl");
std::os::unix::fs::symlink(&outside, &link).unwrap();
let record = JournalRecord::observe(
&observe_req(SessionEvent::SessionStart),
&stamp("2026-10-03T03:40:00Z", "7-1"),
);
append(&dir, &record).unwrap();
assert!(!stale.exists(), "a week-old journal is swept");
assert!(
fresh.exists() && foreign.exists(),
"only stale journals by name"
);
assert!(
link.symlink_metadata().is_ok() && outside.exists(),
"no symlink followed"
);
age_file(&fresh, 8 * 24 * 3600);
append(&dir, &record).unwrap();
assert!(fresh.exists());
}
#[test]
fn sweeping_a_directory_that_is_not_there_does_nothing() {
let tmp = tempfile::tempdir().unwrap();
let missing = tmp.path().join("claude");
sweep_stale(&missing, &missing.join(format!("{ID}.jsonl")));
assert!(!missing.exists());
}
#[test]
fn a_stale_journal_that_cannot_be_deleted_is_left_for_the_daemons_sweep() {
let tmp = tempfile::tempdir().unwrap();
let agent_dir = tmp.path().join("claude");
std::fs::create_dir_all(&agent_dir).unwrap();
let stale = agent_dir.join("1c8f7d2b-3a5e-4b9f-8d4c-6e2f8a0b3c5d.jsonl");
let keep = agent_dir.join("2d9a8e3c-4b6f-4c0a-9e5d-7f3a9b1c4d6e.jsonl");
std::fs::write(&stale, "x\n").unwrap();
age_file(&stale, 8 * 24 * 3600);
std::fs::set_permissions(&agent_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let privileged = std::fs::write(agent_dir.join("probe"), "").is_ok();
sweep_stale(&agent_dir, &keep);
std::fs::set_permissions(&agent_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(stale.exists(), !privileged, "privileged: {privileged}");
}
}