Skip to main content

locode_host/
trace.rs

1//! The resumable session trace: one append-only JSONL rollout per session
2//! (ADR-0024 §2). Every run leaves `~/.locode/sessions/<encoded-cwd>/`
3//! `rollout-<timestamp>-<session_id>.jsonl` — line 1 a `session_meta` header,
4//! every other line a `{timestamp, type, payload}` record. The file is a
5//! **self-sufficient replay source**: the preamble and every appended message
6//! land as `message` lines in append order.
7//!
8//! Design invariants (the §2.4 extension contract):
9//! - the writer only ever *adds* record types / optional header fields;
10//! - a torn tail is healed on reopen (a missing final newline gets one before
11//!   the next append — grok `jsonl/mod.rs:225-251`), so a crash corrupts at
12//!   most one line, which tolerant readers (S4) skip;
13//! - dirs `0700`, files `0600`;
14//! - tracing failures never kill a run: the writer disables itself on the
15//!   first IO error and surfaces it via [`TraceWriter::take_error`].
16
17use std::io::Write;
18use std::path::{Path, PathBuf};
19
20use locode_protocol::{Event, Message};
21use serde::{Deserialize, Serialize};
22
23use crate::session_dirs::encode_cwd_dirname;
24
25/// The trace schema version (`session_meta.schema_version`). Bumped only by a
26/// breaking change the §2.4 rules exist to make unnecessary.
27pub const TRACE_SCHEMA_VERSION: u32 = 1;
28
29/// Line-1 payload: the session header (ADR-0024 §2.3). An **open, growing
30/// record** — every field beyond the identity core is optional-with-default so
31/// old and new binaries share one on-disk population (§2.3 rules).
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SessionMeta {
34    /// The trace schema version.
35    pub schema_version: u32,
36    /// The session id (also in the filename).
37    pub session_id: String,
38    /// The session kind — an **open** string: `"main"` today; `"subagent"`/
39    /// `"workflow"` later. Listers skip kinds they don't know.
40    #[serde(default = "default_kind")]
41    pub kind: String,
42    /// The parent session id (subagents/forks — §2.4 rule 4). `None` = a root session.
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub parent_id: Option<String>,
45    /// The grouping id (a workflow run — §2.4 rule 5).
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub group: Option<String>,
48    /// The canonical start cwd (the directory key — immutable, §2.1).
49    pub cwd: PathBuf,
50    /// Git context at session start, `None` outside a repo.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub git: Option<GitMeta>,
53    /// The CLI version that wrote the trace.
54    pub cli_version: String,
55    /// The harness pack — recorded so resume rehydrates the right pack
56    /// independent of the model catalog (grok's `agent_name` rationale).
57    pub harness: String,
58    /// The provider wire schema.
59    pub api_schema: String,
60    /// The model id.
61    pub model: String,
62}
63
64fn default_kind() -> String {
65    "main".to_string()
66}
67
68/// Git provenance for the header (grok `summary.json`'s git fields).
69#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct GitMeta {
71    /// The repository root.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub root: Option<PathBuf>,
74    /// The checked-out branch.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub branch: Option<String>,
77    /// The HEAD commit hash.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub head: Option<String>,
80    /// The `origin` remote URL.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub remote: Option<String>,
83}
84
85/// Construction-time inputs the [`Event::Init`] event doesn't carry.
86#[derive(Debug, Clone, Default)]
87pub struct TraceExtras {
88    /// The CLI version (`env!("CARGO_PKG_VERSION")` at the call site).
89    pub cli_version: String,
90    /// Git provenance, best-effort.
91    pub git: Option<GitMeta>,
92    /// The session kind (`"main"` unless spawning a subagent).
93    pub kind: Option<String>,
94    /// The parent session id, if any.
95    pub parent_id: Option<String>,
96    /// The workflow/group id, if any.
97    pub group: Option<String>,
98}
99
100/// The rollout writer: feed it every engine [`Event`]; it materializes the file
101/// on `Init` (header + preamble `message` lines) and appends one `message` line
102/// per appended [`Message`]. All other event types are ignored — deltas are
103/// display-only and the report already has its own artifact (ADR-0014).
104#[derive(Debug)]
105pub struct TraceWriter {
106    sessions_root: PathBuf,
107    extras: TraceExtras,
108    file: Option<std::fs::File>,
109    /// The path of the open rollout (exposed for diagnostics/tests).
110    path: Option<PathBuf>,
111    /// The first IO error — the writer is disabled once set.
112    error: Option<String>,
113    /// Resumed writers ignore `Init` (the header/history are already on disk).
114    resumed: bool,
115}
116
117impl TraceWriter {
118    /// A writer that will place rollouts under `sessions_root` (usually
119    /// `<locode home>/sessions`). Nothing touches the disk until `Init`.
120    #[must_use]
121    pub fn new(sessions_root: PathBuf, extras: TraceExtras) -> Self {
122        Self {
123            sessions_root,
124            extras,
125            file: None,
126            path: None,
127            error: None,
128            resumed: false,
129        }
130    }
131
132    /// Reopen an existing rollout for appending (`--continue`/`--resume`,
133    /// ADR-0024 §2.5): the torn tail is healed, and the session's `Init`
134    /// re-emission is **ignored** — the header and history are already on disk;
135    /// only newly appended messages land (codex's reopen-for-append).
136    ///
137    /// # Errors
138    /// When the file cannot be opened for appending.
139    pub fn resume(path: PathBuf, sessions_root: PathBuf) -> Result<Self, String> {
140        let file = open_append_private(&path)?;
141        Ok(Self {
142            sessions_root,
143            extras: TraceExtras::default(),
144            file: Some(file),
145            path: Some(path),
146            error: None,
147            resumed: true,
148        })
149    }
150
151    /// Feed one engine event. Never fails — on the first IO error the writer
152    /// disables itself (fetch the message via [`Self::take_error`]).
153    pub fn on_event(&mut self, event: &Event) {
154        if self.error.is_some() {
155            return;
156        }
157        let result = match event {
158            Event::Init {
159                session_id,
160                harness,
161                api_schema,
162                model,
163                cwd,
164                preamble,
165                ..
166            } if !self.resumed => {
167                self.on_init(session_id, harness, api_schema, model, cwd, preamble)
168            }
169            Event::Message { message } => self.append_message(message),
170            // Per-run usage (additive record type, §2.4): lets resume
171            // reconstruct the exact context occupancy instead of estimating.
172            //
173            // The **final turn's** usage, not the run's sum: this record exists to
174            // answer "how full is the context?" on resume, and a sum counts the same
175            // conversation once per turn. (Traces written before 2026-07-25 hold the
176            // sum here; a session resumed from one over-reports until its first run
177            // replaces the number.)
178            Event::Result { report } => {
179                if self.file.is_some() {
180                    self.append_record("usage", &report.context_usage)
181                } else {
182                    Ok(())
183                }
184            }
185            _ => Ok(()),
186        };
187        if let Err(e) = result {
188            self.error = Some(e);
189            self.file = None;
190        }
191    }
192
193    /// The open rollout path (after `Init`).
194    #[must_use]
195    pub fn path(&self) -> Option<&Path> {
196        self.path.as_deref()
197    }
198
199    /// The first IO error, if tracing failed (the caller surfaces it as a warning).
200    pub fn take_error(&mut self) -> Option<String> {
201        self.error.take()
202    }
203
204    fn on_init(
205        &mut self,
206        session_id: &str,
207        harness: &str,
208        api_schema: &str,
209        model: &str,
210        cwd: &str,
211        preamble: &[Message],
212    ) -> Result<(), String> {
213        let cwd_path = PathBuf::from(cwd);
214        let dirname = encode_cwd_dirname(&cwd_path);
215        let dir = self.sessions_root.join(&dirname);
216        create_dir_private(&dir)?;
217        // Hash-fallback dirs get the `.cwd` sidecar recovering the original path
218        // (§2.1 — fallback names never start with `+`).
219        if !dirname.starts_with('+') {
220            let sidecar = dir.join(".cwd");
221            if !sidecar.exists() {
222                std::fs::write(&sidecar, cwd).map_err(|e| format!("write .cwd sidecar: {e}"))?;
223            }
224        }
225        let filename = format!("rollout-{}-{session_id}.jsonl", filename_timestamp());
226        let path = dir.join(&filename);
227        let file = open_append_private(&path)?;
228        self.file = Some(file);
229        self.path = Some(path);
230
231        let meta = SessionMeta {
232            schema_version: TRACE_SCHEMA_VERSION,
233            session_id: session_id.to_string(),
234            kind: self
235                .extras
236                .kind
237                .clone()
238                .unwrap_or_else(|| "main".to_string()),
239            parent_id: self.extras.parent_id.clone(),
240            group: self.extras.group.clone(),
241            cwd: cwd_path,
242            git: self.extras.git.clone(),
243            cli_version: self.extras.cli_version.clone(),
244            harness: harness.to_string(),
245            api_schema: api_schema.to_string(),
246            model: model.to_string(),
247        };
248        self.append_record("session_meta", &meta)?;
249        // The preamble rides as ordinary message lines — the file replays alone.
250        for message in preamble {
251            self.append_record("message", message)?;
252        }
253        Ok(())
254    }
255
256    fn append_message(&mut self, message: &Message) -> Result<(), String> {
257        if self.file.is_none() {
258            return Ok(()); // no Init seen (tracing effectively off)
259        }
260        self.append_record("message", message)
261    }
262
263    fn append_record(&mut self, record_type: &str, payload: &impl Serialize) -> Result<(), String> {
264        let Some(file) = self.file.as_mut() else {
265            return Ok(());
266        };
267        let line = serde_json::json!({
268            "timestamp": now_rfc3339_millis(),
269            "type": record_type,
270            "payload": payload,
271        });
272        let mut buf = serde_json::to_string(&line).map_err(|e| format!("serialize: {e}"))?;
273        buf.push('\n');
274        file.write_all(buf.as_bytes())
275            .and_then(|()| file.flush())
276            .map_err(|e| format!("append trace record: {e}"))
277    }
278}
279
280/// A parsed rollout: the header plus the replayable message history.
281#[derive(Debug)]
282pub struct RolloutContents {
283    /// The line-1 header.
284    pub meta: SessionMeta,
285    /// The conversation, in append order (preamble included) — `compacted`
286    /// records already folded in.
287    pub history: Vec<Message>,
288    /// The **last** run's provider-reported usage, when the rollout carries
289    /// `usage` records (written since 2026-07-24) — the exact basis for the
290    /// resumed context occupancy. `None` on older rollouts (callers estimate).
291    pub last_usage: Option<locode_protocol::Usage>,
292}
293
294/// Read a rollout **tolerantly** (ADR-0024 §2.4 rules 1-2): unknown record
295/// types and unknown payload fields are skipped/ignored; an unparsable line
296/// (the torn tail) is skipped; a `compacted` record replaces all prior
297/// messages with its `replacement_history`.
298///
299/// # Errors
300/// Only when the file is unreadable or line 1 is not a valid `session_meta`
301/// (without a header the file identifies nothing).
302pub fn read_rollout(path: &Path) -> Result<RolloutContents, String> {
303    let text =
304        std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
305    let mut lines = text.lines();
306    let first = lines
307        .next()
308        .ok_or_else(|| format!("{}: empty rollout", path.display()))?;
309    let first: serde_json::Value = serde_json::from_str(first)
310        .map_err(|e| format!("{}: line 1 unparsable: {e}", path.display()))?;
311    if first.get("type").and_then(serde_json::Value::as_str) != Some("session_meta") {
312        return Err(format!("{}: line 1 is not session_meta", path.display()));
313    }
314    let meta: SessionMeta = serde_json::from_value(first["payload"].clone())
315        .map_err(|e| format!("{}: session_meta invalid: {e}", path.display()))?;
316
317    let mut history: Vec<Message> = Vec::new();
318    let mut last_usage: Option<locode_protocol::Usage> = None;
319    for line in lines {
320        // Torn tail / foreign garbage: skip, never fail (§2.4).
321        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
322            continue;
323        };
324        match value.get("type").and_then(serde_json::Value::as_str) {
325            Some("message") => {
326                if let Ok(message) = serde_json::from_value::<Message>(value["payload"].clone()) {
327                    history.push(message);
328                }
329            }
330            Some("compacted") => {
331                // Fold: the replacement history stands in for everything prior.
332                if let Some(replacement) = value["payload"].get("replacement_history")
333                    && let Ok(messages) =
334                        serde_json::from_value::<Vec<Message>>(replacement.clone())
335                {
336                    history = messages;
337                }
338            }
339            Some("usage") => {
340                if let Ok(usage) =
341                    serde_json::from_value::<locode_protocol::Usage>(value["payload"].clone())
342                {
343                    last_usage = Some(usage);
344                }
345            }
346            // Unknown record types (future features) are invisible to this reader.
347            _ => {}
348        }
349    }
350    Ok(RolloutContents {
351        meta,
352        history,
353        last_usage,
354    })
355}
356
357/// The newest resumable rollout for `cwd` (`--continue`): list the one encoded
358/// directory, take rollouts newest-first by filename (the timestamp prefix
359/// sorts chronologically), and return the first whose header parses with
360/// `kind == "main"` — unknown kinds are not listed (§2.4 rule 3), but remain
361/// reachable by id.
362#[must_use]
363pub fn find_latest_rollout(sessions_root: &Path, cwd: &Path) -> Option<PathBuf> {
364    let dir = sessions_root.join(encode_cwd_dirname(cwd));
365    let mut names: Vec<String> = rollout_names(&dir);
366    names.sort_unstable_by(|a, b| b.cmp(a)); // newest first
367    names
368        .into_iter()
369        .map(|n| dir.join(n))
370        .find(|path| read_rollout(path).is_ok_and(|c| c.meta.kind == "main"))
371}
372
373/// Locate a rollout by session id (`--resume <id>`): the cwd's directory first,
374/// then every other session directory (Claude's scoped-then-global resolver).
375/// Any `kind` is resumable by id.
376#[must_use]
377pub fn find_rollout_by_id(sessions_root: &Path, cwd: &Path, id: &str) -> Option<PathBuf> {
378    let suffix = format!("-{id}.jsonl");
379    let scoped = sessions_root.join(encode_cwd_dirname(cwd));
380    if let Some(hit) = dir_hit(&scoped, &suffix) {
381        return Some(hit);
382    }
383    let entries = std::fs::read_dir(sessions_root).ok()?;
384    for entry in entries.flatten() {
385        let dir = entry.path();
386        if dir == scoped || !dir.is_dir() {
387            continue;
388        }
389        if let Some(hit) = dir_hit(&dir, &suffix) {
390            return Some(hit);
391        }
392    }
393    None
394}
395
396/// One row of the session picker (ADR-0029): everything a chooser needs, read
397/// from the rollout's **line-1 header plus its mtime** — never the whole file.
398///
399/// The conversation title is deliberately absent: it lives in the first user
400/// message, which costs a second read of a few records, so the picker paints from
401/// these and fills titles in afterwards (codex's lazy-preview pattern,
402/// `tui/src/resume_picker.rs:772`, minus the preview pane).
403#[derive(Debug, Clone)]
404pub struct SessionSummary {
405    /// The session id, as it appears in the filename and the header.
406    pub id: String,
407    /// The rollout to resume.
408    pub path: PathBuf,
409    /// The session's start cwd (the directory key).
410    pub cwd: PathBuf,
411    /// The harness pack that wrote it. Rows are **not** filtered by it: resuming
412    /// switches the pack from here, so a session started under another harness is
413    /// still listed and still resumable (ADR-0029 resolution 1).
414    pub harness: String,
415    /// The model at session start.
416    pub model: String,
417    /// The git branch at session start, when the session began inside a repo.
418    pub branch: Option<String>,
419    /// File mtime — the session's last activity, and the sort key.
420    pub last_active: std::time::SystemTime,
421}
422
423/// Which sessions [`list_sessions`] returns.
424#[derive(Debug, Clone, Copy)]
425pub enum SessionScope<'a> {
426    /// Only sessions started in this directory (the picker's default).
427    Cwd(&'a Path),
428    /// Every session under the root, across directories.
429    All,
430}
431
432/// List resumable sessions, newest activity first (ADR-0029).
433///
434/// Reads **line 1 and the mtime** of each `rollout-*.jsonl`, so the cost is one
435/// open per session rather than one parse of every transcript. ADR-0024 reserved a
436/// listing index for this; it stays reserved, because a directory scan cannot
437/// disagree with the directory while an index can — revisit on a measurement, not
438/// a guess.
439///
440/// Skips, silently and by design:
441/// - rollouts whose header will not parse (ADR-0024 §2.4 tolerance — a torn file is
442///   invisible in the picker, not an error row the user must dismiss);
443/// - `kind != "main"`, so subagent and workflow transcripts never appear as
444///   something to resume into (ADR-0029 resolution 2). They stay reachable by id.
445#[must_use]
446pub fn list_sessions(sessions_root: &Path, scope: SessionScope<'_>) -> Vec<SessionSummary> {
447    let dirs: Vec<PathBuf> = match scope {
448        SessionScope::Cwd(cwd) => vec![sessions_root.join(encode_cwd_dirname(cwd))],
449        SessionScope::All => std::fs::read_dir(sessions_root)
450            .into_iter()
451            .flatten()
452            .flatten()
453            .map(|e| e.path())
454            .filter(|p| p.is_dir())
455            .collect(),
456    };
457    let mut out: Vec<SessionSummary> = dirs
458        .iter()
459        .flat_map(|dir| {
460            rollout_names(dir)
461                .into_iter()
462                .filter_map(|name| summarize_rollout(&dir.join(name)))
463        })
464        .collect();
465    // Newest activity first. Ties keep a stable order so a redraw cannot reshuffle
466    // rows under the cursor.
467    out.sort_by(|a, b| b.last_active.cmp(&a.last_active).then(a.id.cmp(&b.id)));
468    out
469}
470
471/// The one-line title for a picker row: the first **real** user message in the
472/// rollout, first line only.
473///
474/// Not in the header, so it costs a second read — which is why the picker fills
475/// titles in for the rows it is about to draw rather than for the whole list
476/// (ADR-0029; codex loads previews the same way,
477/// `tui/src/resume_picker.rs:772`). Reads at most `TITLE_SCAN_LINES` records, so
478/// a session that opens with a long injected preamble does not turn into a full
479/// parse.
480///
481/// Injected framing is skipped: an `AGENTS.md` reminder or a skills listing is a
482/// `User` message the user never typed, and titling a session with it would make
483/// every session in a project look identical.
484#[must_use]
485pub fn read_session_title(path: &Path) -> Option<String> {
486    use std::io::BufRead as _;
487    let file = std::fs::File::open(path).ok()?;
488    for line in std::io::BufReader::new(file).lines().take(TITLE_SCAN_LINES) {
489        let Ok(line) = line else { break };
490        let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
491            continue;
492        };
493        if value.get("type").and_then(serde_json::Value::as_str) != Some("message") {
494            continue;
495        }
496        let Ok(message) = serde_json::from_value::<Message>(value.get("payload")?.clone()) else {
497            continue;
498        };
499        if message.role != locode_protocol::Role::User {
500            continue;
501        }
502        let text: String = message
503            .content
504            .iter()
505            .filter_map(|block| match block {
506                locode_protocol::ContentBlock::Text { text } => Some(text.as_str()),
507                _ => None,
508            })
509            .collect::<Vec<_>>()
510            .join(" ");
511        let first_line = text.lines().find(|l| !l.trim().is_empty())?.trim();
512        if first_line.starts_with("<system-reminder>") {
513            continue;
514        }
515        return Some(first_line.to_string());
516    }
517    None
518}
519
520/// How far into a rollout [`read_session_title`] looks for the first user
521/// message before giving up — a bound, so one pathological file cannot stall a
522/// paint.
523const TITLE_SCAN_LINES: usize = 64;
524
525/// Read one rollout's header + mtime into a summary, or `None` if it is not a
526/// listable session.
527fn summarize_rollout(path: &Path) -> Option<SessionSummary> {
528    let file = std::fs::File::open(path).ok()?;
529    let last_active = file.metadata().ok()?.modified().ok()?;
530    let mut first = String::new();
531    std::io::BufRead::read_line(&mut std::io::BufReader::new(file), &mut first).ok()?;
532    let value: serde_json::Value = serde_json::from_str(first.trim_end()).ok()?;
533    if value.get("type").and_then(serde_json::Value::as_str) != Some("session_meta") {
534        return None;
535    }
536    let meta: SessionMeta = serde_json::from_value(value.get("payload")?.clone()).ok()?;
537    if meta.kind != "main" {
538        return None;
539    }
540    Some(SessionSummary {
541        id: meta.session_id,
542        path: path.to_path_buf(),
543        cwd: meta.cwd,
544        harness: meta.harness,
545        model: meta.model,
546        branch: meta.git.and_then(|g| g.branch),
547        last_active,
548    })
549}
550
551/// The rollout filenames directly inside `dir`.
552fn rollout_names(dir: &Path) -> Vec<String> {
553    let Ok(entries) = std::fs::read_dir(dir) else {
554        return Vec::new();
555    };
556    entries
557        .flatten()
558        .filter_map(|e| e.file_name().into_string().ok())
559        .filter(|n| {
560            n.starts_with("rollout-")
561                && std::path::Path::new(n)
562                    .extension()
563                    .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
564        })
565        .collect()
566}
567
568fn dir_hit(dir: &Path, suffix: &str) -> Option<PathBuf> {
569    rollout_names(dir)
570        .into_iter()
571        .find(|n| n.ends_with(suffix))
572        .map(|n| dir.join(n))
573}
574
575/// Open (or create) a rollout for appending, `0600`, healing a torn tail: if the
576/// last byte isn't `\n`, one is appended first so the next record starts clean —
577/// bounding crash damage to exactly one line (grok's rule).
578pub(crate) fn open_append_private(path: &Path) -> Result<std::fs::File, String> {
579    let mut options = std::fs::OpenOptions::new();
580    options.create(true).append(true);
581    #[cfg(unix)]
582    {
583        use std::os::unix::fs::OpenOptionsExt;
584        options.mode(0o600);
585    }
586    let mut file = options
587        .open(path)
588        .map_err(|e| format!("open {}: {e}", path.display()))?;
589    heal_torn_tail(path, &mut file)?;
590    Ok(file)
591}
592
593fn heal_torn_tail(path: &Path, file: &mut std::fs::File) -> Result<(), String> {
594    use std::io::{Read, Seek, SeekFrom};
595    let len = file
596        .metadata()
597        .map_err(|e| format!("stat {}: {e}", path.display()))?
598        .len();
599    if len == 0 {
600        return Ok(());
601    }
602    // Read the final byte via a fresh read handle (`self` is append-only).
603    let mut reader =
604        std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?;
605    reader
606        .seek(SeekFrom::End(-1))
607        .map_err(|e| format!("seek {}: {e}", path.display()))?;
608    let mut last = [0_u8; 1];
609    reader
610        .read_exact(&mut last)
611        .map_err(|e| format!("read {}: {e}", path.display()))?;
612    if last[0] != b'\n' {
613        file.write_all(b"\n")
614            .map_err(|e| format!("heal {}: {e}", path.display()))?;
615    }
616    Ok(())
617}
618
619/// `mkdir -p` with `0700` on every newly created component (best-effort mode —
620/// pre-existing dirs are left alone).
621pub(crate) fn create_dir_private(dir: &Path) -> Result<(), String> {
622    #[cfg(unix)]
623    {
624        use std::os::unix::fs::DirBuilderExt;
625        std::fs::DirBuilder::new()
626            .recursive(true)
627            .mode(0o700)
628            .create(dir)
629            .map_err(|e| format!("create {}: {e}", dir.display()))
630    }
631    #[cfg(not(unix))]
632    {
633        std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))
634    }
635}
636
637/// The record timestamp: RFC3339 with milliseconds, UTC (codex's shape).
638fn now_rfc3339_millis() -> String {
639    chrono::Utc::now()
640        .format("%Y-%m-%dT%H:%M:%S%.3fZ")
641        .to_string()
642}
643
644/// The filename timestamp: colons → dashes for filesystem compatibility
645/// (codex `recorder.rs:1547-1550`); reverse-chron name-sorting within a dir.
646fn filename_timestamp() -> String {
647    chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string()
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use locode_protocol::{ContentBlock, Role};
654    use serde_json::Value;
655
656    fn init_event(session_id: &str, cwd: &Path) -> Event {
657        Event::Init {
658            session_id: session_id.to_string(),
659            harness: "codex".to_string(),
660            api_schema: "mock".to_string(),
661            model: "mock-1".to_string(),
662            cwd: cwd.to_string_lossy().into_owned(),
663            max_turns: None,
664            preamble: vec![Message {
665                role: Role::System,
666                content: vec![ContentBlock::Text {
667                    text: "base prompt".to_string(),
668                }],
669            }],
670            tools: vec![],
671        }
672    }
673
674    fn message_event(role: Role, text: &str) -> Event {
675        Event::Message {
676            message: Message {
677                role,
678                content: vec![ContentBlock::Text {
679                    text: text.to_string(),
680                }],
681            },
682        }
683    }
684
685    fn read_lines(path: &Path) -> Vec<Value> {
686        std::fs::read_to_string(path)
687            .unwrap()
688            .lines()
689            .map(|l| serde_json::from_str(l).unwrap())
690            .collect()
691    }
692
693    #[test]
694    fn writes_header_preamble_and_messages() {
695        let root = tempfile::tempdir().unwrap();
696        let cwd = tempfile::tempdir().unwrap();
697        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
698        let mut writer = TraceWriter::new(
699            root.path().join("sessions"),
700            TraceExtras {
701                cli_version: "0.1.9".to_string(),
702                git: Some(GitMeta {
703                    branch: Some("main".to_string()),
704                    ..Default::default()
705                }),
706                ..Default::default()
707            },
708        );
709        writer.on_event(&init_event("sess-1", &cwd));
710        writer.on_event(&message_event(Role::User, "hi"));
711        writer.on_event(&Event::MessageDelta {
712            text: "ignored".to_string(),
713        });
714        writer.on_event(&message_event(Role::Assistant, "hello"));
715        assert!(writer.take_error().is_none());
716
717        // The file landed in the encoded-cwd dir with the id in the name.
718        let path = writer.path().unwrap().to_path_buf();
719        assert_eq!(
720            path.parent()
721                .unwrap()
722                .file_name()
723                .unwrap()
724                .to_str()
725                .unwrap(),
726            encode_cwd_dirname(&cwd)
727        );
728        assert!(
729            path.file_name()
730                .unwrap()
731                .to_str()
732                .unwrap()
733                .starts_with("rollout-")
734        );
735        assert!(
736            path.file_name()
737                .unwrap()
738                .to_str()
739                .unwrap()
740                .ends_with("-sess-1.jsonl")
741        );
742
743        let lines = read_lines(&path);
744        assert_eq!(lines.len(), 4, "meta + preamble + 2 messages");
745        assert_eq!(lines[0]["type"], "session_meta");
746        let meta = &lines[0]["payload"];
747        assert_eq!(meta["schema_version"], 1);
748        assert_eq!(meta["session_id"], "sess-1");
749        assert_eq!(meta["kind"], "main");
750        assert_eq!(meta["harness"], "codex");
751        assert_eq!(meta["git"]["branch"], "main");
752        assert!(meta.get("parent_id").is_none(), "absent when None");
753        // Preamble + appended messages are verbatim protocol Messages.
754        assert_eq!(lines[1]["type"], "message");
755        assert_eq!(lines[1]["payload"]["role"], "system");
756        assert_eq!(lines[2]["payload"]["role"], "user");
757        assert_eq!(lines[3]["payload"]["role"], "assistant");
758        // Every line carries a timestamp.
759        for line in &lines {
760            assert!(line["timestamp"].as_str().unwrap().ends_with('Z'));
761        }
762    }
763
764    #[cfg(unix)]
765    #[test]
766    fn modes_are_private() {
767        use std::os::unix::fs::PermissionsExt;
768        let root = tempfile::tempdir().unwrap();
769        let cwd = tempfile::tempdir().unwrap();
770        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
771        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
772        writer.on_event(&init_event("sess-2", &cwd));
773        let path = writer.path().unwrap();
774        let file_mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
775        let dir_mode = std::fs::metadata(path.parent().unwrap())
776            .unwrap()
777            .permissions()
778            .mode()
779            & 0o777;
780        assert_eq!(file_mode, 0o600, "rollout is 0600");
781        assert_eq!(dir_mode, 0o700, "session dir is 0700");
782    }
783
784    #[test]
785    fn torn_tail_is_healed_on_reopen() {
786        let dir = tempfile::tempdir().unwrap();
787        let path = dir.path().join("rollout-x.jsonl");
788        std::fs::write(&path, "{\"ok\":1}\n{\"torn\":").unwrap();
789        let mut file = open_append_private(&path).unwrap();
790        file.write_all(b"{\"next\":2}\n").unwrap();
791        drop(file);
792        let text = std::fs::read_to_string(&path).unwrap();
793        assert_eq!(text, "{\"ok\":1}\n{\"torn\":\n{\"next\":2}\n");
794        // Exactly one line is garbage; the rest parse.
795        let parsed: Vec<_> = text
796            .lines()
797            .filter(|l| serde_json::from_str::<Value>(l).is_ok())
798            .collect();
799        assert_eq!(parsed.len(), 2);
800    }
801
802    #[test]
803    fn no_init_means_no_file_and_errors_disable() {
804        let root = tempfile::tempdir().unwrap();
805        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
806        writer.on_event(&message_event(Role::User, "before init"));
807        assert!(writer.path().is_none(), "nothing written before Init");
808
809        // An unwritable root disables the writer with an error, never a panic.
810        let mut writer = TraceWriter::new(PathBuf::from("/dev/null/nope"), TraceExtras::default());
811        let cwd = std::env::temp_dir();
812        writer.on_event(&init_event("sess-3", &cwd));
813        assert!(writer.take_error().is_some());
814        // Further events are no-ops.
815        writer.on_event(&message_event(Role::User, "x"));
816    }
817
818    /// Write a rollout by hand so a test can control the header exactly (kinds and
819    /// torn files the writer would never produce).
820    fn write_rollout(dir: &Path, id: &str, kind: &str, extra_lines: &[&str]) -> PathBuf {
821        std::fs::create_dir_all(dir).unwrap();
822        let path = dir.join(format!("rollout-2026-07-29T00-00-00-{id}.jsonl"));
823        let meta = serde_json::json!({
824            "timestamp": "2026-07-29T00:00:00Z",
825            "type": "session_meta",
826            "payload": {
827                "schema_version": 1,
828                "session_id": id,
829                "kind": kind,
830                "cwd": dir.to_string_lossy(),
831                "git": { "branch": "main" },
832                "cli_version": "0.1.17",
833                "harness": "claude",
834                "api_schema": "anthropic",
835                "model": "test-model",
836            }
837        });
838        let mut body = meta.to_string();
839        for line in extra_lines {
840            body.push('\n');
841            body.push_str(line);
842        }
843        body.push('\n');
844        std::fs::write(&path, body).unwrap();
845        path
846    }
847
848    fn set_mtime(path: &Path, when: std::time::SystemTime) {
849        std::fs::OpenOptions::new()
850            .write(true)
851            .open(path)
852            .unwrap()
853            .set_modified(when)
854            .unwrap();
855    }
856
857    /// The picker's listing (ADR-0029): newest activity first, scoped to one cwd by
858    /// default, and quietly skipping what is not a resumable main session.
859    #[test]
860    fn list_sessions_orders_scopes_and_skips() {
861        let root = tempfile::tempdir().unwrap();
862        let sessions = root.path().join("sessions");
863        let cwd_a = PathBuf::from("/tmp/project-a");
864        let cwd_b = PathBuf::from("/tmp/project-b");
865        let dir_a = sessions.join(encode_cwd_dirname(&cwd_a));
866        let dir_b = sessions.join(encode_cwd_dirname(&cwd_b));
867
868        let older = write_rollout(&dir_a, "sess-old", "main", &[]);
869        let newer = write_rollout(&dir_a, "sess-new", "main", &[]);
870        write_rollout(&dir_a, "sess-sub", "subagent", &[]); // not resumable-into
871        write_rollout(&dir_b, "sess-other-dir", "main", &[]);
872
873        // Distinct mtimes, oldest first, so ordering is not filename luck (and no
874        // sleeping: `File::set_modified` is std, no dependency).
875        let base = std::time::SystemTime::now() - std::time::Duration::from_mins(10);
876        set_mtime(&older, base);
877        set_mtime(&newer, base + std::time::Duration::from_mins(5));
878
879        // Unlistable rollouts: empty, non-JSON line 1, and a line 1 that is not a header.
880        std::fs::write(dir_a.join("rollout-2026-07-29T00-00-00-empty.jsonl"), "").unwrap();
881        std::fs::write(
882            dir_a.join("rollout-2026-07-29T00-00-00-garbage.jsonl"),
883            "not json\n",
884        )
885        .unwrap();
886        std::fs::write(
887            dir_a.join("rollout-2026-07-29T00-00-00-nohdr.jsonl"),
888            "{\"type\":\"message\",\"payload\":{}}\n",
889        )
890        .unwrap();
891
892        let scoped = list_sessions(&sessions, SessionScope::Cwd(&cwd_a));
893        let ids: Vec<&str> = scoped.iter().map(|s| s.id.as_str()).collect();
894        assert_eq!(
895            ids,
896            vec!["sess-new", "sess-old"],
897            "newest first; subagent, torn, and headerless rollouts are invisible"
898        );
899        assert_eq!(scoped[0].harness, "claude", "header fields survive");
900        assert_eq!(scoped[0].branch.as_deref(), Some("main"));
901        assert_eq!(scoped[0].model, "test-model");
902
903        let all = list_sessions(&sessions, SessionScope::All);
904        assert!(
905            all.iter().any(|s| s.id == "sess-other-dir"),
906            "All crosses directories: {:?}",
907            all.iter().map(|s| &s.id).collect::<Vec<_>>()
908        );
909        assert_eq!(all.len(), 3, "still no subagent or torn rows: {all:?}");
910    }
911
912    /// A missing sessions root is an empty picker, not an error — a first run has
913    /// no directory at all.
914    #[test]
915    fn list_sessions_on_a_missing_root_is_empty() {
916        let root = tempfile::tempdir().unwrap();
917        let absent = root.path().join("nope");
918        assert!(list_sessions(&absent, SessionScope::All).is_empty());
919        assert!(list_sessions(&absent, SessionScope::Cwd(Path::new("/tmp/x"))).is_empty());
920    }
921
922    #[test]
923    fn read_rollout_replays_and_tolerates() {
924        let root = tempfile::tempdir().unwrap();
925        let cwd = tempfile::tempdir().unwrap();
926        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
927        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
928        writer.on_event(&init_event("sess-r", &cwd));
929        writer.on_event(&message_event(Role::User, "hi"));
930        writer.on_event(&message_event(Role::Assistant, "yo"));
931        let path = writer.path().unwrap().to_path_buf();
932
933        // Sprinkle §2.4 hazards: an unknown record type, a torn tail.
934        {
935            let mut file = open_append_private(&path).unwrap();
936            file.write_all(
937                b"{\"timestamp\":\"x\",\"type\":\"future_thing\",\"payload\":{\"n\":1}}\n",
938            )
939            .unwrap();
940            file.write_all(b"{\"torn").unwrap();
941        }
942        let contents = read_rollout(&path).unwrap();
943        assert_eq!(contents.meta.session_id, "sess-r");
944        // preamble(1) + user + assistant; the unknown type and torn line vanish.
945        assert_eq!(contents.history.len(), 3);
946        assert_eq!(contents.history[0].role, Role::System);
947        assert_eq!(contents.history[2].role, Role::Assistant);
948    }
949
950    #[test]
951    fn read_rollout_folds_compacted() {
952        let dir = tempfile::tempdir().unwrap();
953        let path = dir.path().join("rollout-t-sess-c.jsonl");
954        let meta = serde_json::json!({"timestamp":"t","type":"session_meta","payload":{
955            "schema_version":1,"session_id":"sess-c","kind":"main","cwd":"/x",
956            "cli_version":"0","harness":"grok","api_schema":"mock","model":"m"}});
957        let msg = |role: &str, text: &str| {
958            serde_json::json!({"timestamp":"t","type":"message","payload":
959                {"role":role,"content":[{"type":"text","text":text}]}})
960        };
961        let compacted = serde_json::json!({"timestamp":"t","type":"compacted","payload":{
962            "summary":"s","replacement_history":[
963                {"role":"user","content":[{"type":"text","text":"summary of the past"}]}]}});
964        let lines = [
965            meta.to_string(),
966            msg("user", "old-1").to_string(),
967            msg("assistant", "old-2").to_string(),
968            compacted.to_string(),
969            msg("user", "after").to_string(),
970        ];
971        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
972        let contents = read_rollout(&path).unwrap();
973        // Compaction replaced the two old messages; the post-compaction one follows.
974        assert_eq!(contents.history.len(), 2);
975        assert_eq!(contents.history[1].role, Role::User);
976    }
977
978    #[test]
979    fn resumed_writer_appends_without_a_new_header() {
980        let root = tempfile::tempdir().unwrap();
981        let cwd = tempfile::tempdir().unwrap();
982        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
983        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
984        writer.on_event(&init_event("sess-a", &cwd));
985        writer.on_event(&message_event(Role::User, "first run"));
986        let path = writer.path().unwrap().to_path_buf();
987        let before = read_lines(&path).len();
988        drop(writer);
989
990        let mut resumed = TraceWriter::resume(path.clone(), root.path().join("sessions")).unwrap();
991        resumed.on_event(&init_event("sess-a", &cwd)); // re-Init: ignored
992        resumed.on_event(&message_event(Role::User, "second run"));
993        assert!(resumed.take_error().is_none());
994        let lines = read_lines(&path);
995        assert_eq!(lines.len(), before + 1, "exactly one new message line");
996        assert_eq!(
997            lines.iter().filter(|l| l["type"] == "session_meta").count(),
998            1,
999            "one header, ever"
1000        );
1001    }
1002
1003    #[test]
1004    fn find_latest_scopes_by_cwd_and_kind() {
1005        let root = tempfile::tempdir().unwrap();
1006        let sessions = root.path().join("sessions");
1007        let cwd_a = tempfile::tempdir().unwrap();
1008        let cwd_a = std::fs::canonicalize(cwd_a.path()).unwrap();
1009        let cwd_b = tempfile::tempdir().unwrap();
1010        let cwd_b = std::fs::canonicalize(cwd_b.path()).unwrap();
1011
1012        // Older main session in A; then a subagent session in A; a session in B.
1013        let mut w1 = TraceWriter::new(sessions.clone(), TraceExtras::default());
1014        w1.on_event(&init_event("sess-old", &cwd_a));
1015        let mut w2 = TraceWriter::new(
1016            sessions.clone(),
1017            TraceExtras {
1018                kind: Some("subagent".to_string()),
1019                ..Default::default()
1020            },
1021        );
1022        w2.on_event(&init_event("sess-sub", &cwd_a));
1023        let mut w3 = TraceWriter::new(sessions.clone(), TraceExtras::default());
1024        w3.on_event(&init_event("sess-b", &cwd_b));
1025
1026        // Continue in A: the subagent is skipped; the old main session wins.
1027        let latest = find_latest_rollout(&sessions, &cwd_a).unwrap();
1028        assert!(latest.to_string_lossy().contains("sess-old"), "{latest:?}");
1029        // Continue in B is scoped to B.
1030        let latest_b = find_latest_rollout(&sessions, &cwd_b).unwrap();
1031        assert!(latest_b.to_string_lossy().contains("sess-b"));
1032        // A cwd with no sessions has nothing to continue.
1033        let empty = tempfile::tempdir().unwrap();
1034        assert!(find_latest_rollout(&sessions, empty.path()).is_none());
1035
1036        // Resume by id: found from the "wrong" cwd via the global scan — and a
1037        // subagent IS resumable by id.
1038        let by_id = find_rollout_by_id(&sessions, &cwd_b, "sess-sub").unwrap();
1039        assert!(by_id.to_string_lossy().contains("sess-sub"));
1040        assert!(find_rollout_by_id(&sessions, &cwd_b, "sess-nope").is_none());
1041    }
1042
1043    #[test]
1044    fn usage_records_round_trip_for_exact_resume() {
1045        use locode_protocol::{Report, Status, Usage};
1046        let root = tempfile::tempdir().unwrap();
1047        let cwd = tempfile::tempdir().unwrap();
1048        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
1049        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
1050        writer.on_event(&init_event("sess-u", &cwd));
1051        writer.on_event(&message_event(Role::User, "hi"));
1052        // Two runs: the LAST usage wins.
1053        let report = |input: u64, output: u64| Report {
1054            schema_version: 1,
1055            status: Status::Completed,
1056            harness: "grok".into(),
1057            api_schema: "mock".into(),
1058            final_message: None,
1059            structured_output: None,
1060            turns: 1,
1061            tool_calls: vec![],
1062            // The run's sum — deliberately different from `context_usage`, so the test
1063            // proves which one the record carries.
1064            usage: Usage {
1065                input_tokens: input * 3,
1066                output_tokens: output * 3,
1067                cache_read_tokens: Some(99),
1068                ..Default::default()
1069            },
1070            context_usage: Usage {
1071                input_tokens: input,
1072                output_tokens: output,
1073                cache_read_tokens: Some(7),
1074                cache_creation_tokens: Some(5),
1075                ..Default::default()
1076            },
1077            session_id: "sess-u".into(),
1078            stop_reason: None,
1079            error: None,
1080        };
1081        writer.on_event(&locode_protocol::Event::Result {
1082            report: report(100, 10),
1083        });
1084        writer.on_event(&locode_protocol::Event::Result {
1085            report: report(200, 20),
1086        });
1087        assert!(writer.take_error().is_none());
1088
1089        let contents = read_rollout(writer.path().unwrap()).unwrap();
1090        let usage = contents.last_usage.expect("usage recovered");
1091        assert_eq!(usage.input_tokens, 200, "the last run's context usage");
1092        assert_eq!(usage.output_tokens, 20);
1093        assert_eq!(usage.cache_read_tokens, Some(7));
1094        // The record is the context occupancy, NOT the run's cost total: resuming from
1095        // the sum would over-report a long run's context by a multiple.
1096        assert_eq!(
1097            usage.context_tokens(),
1098            200 + 7 + 5 + 20,
1099            "both cache counters are part of the prompt"
1100        );
1101        // And an old-style rollout (no usage records) still reads fine.
1102        let bare = root.path().join("bare.jsonl");
1103        let meta_line = std::fs::read_to_string(writer.path().unwrap())
1104            .unwrap()
1105            .lines()
1106            .next()
1107            .unwrap()
1108            .to_string();
1109        std::fs::write(&bare, meta_line + "\n").unwrap();
1110        assert!(read_rollout(&bare).unwrap().last_usage.is_none());
1111    }
1112
1113    #[test]
1114    fn reserved_kinds_and_parents_serialize() {
1115        let root = tempfile::tempdir().unwrap();
1116        let cwd = tempfile::tempdir().unwrap();
1117        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
1118        let mut writer = TraceWriter::new(
1119            root.path().join("sessions"),
1120            TraceExtras {
1121                cli_version: "x".to_string(),
1122                kind: Some("subagent".to_string()),
1123                parent_id: Some("sess-parent".to_string()),
1124                group: Some("wf-1".to_string()),
1125                ..Default::default()
1126            },
1127        );
1128        writer.on_event(&init_event("sess-4", &cwd));
1129        let lines = read_lines(writer.path().unwrap());
1130        let meta = &lines[0]["payload"];
1131        assert_eq!(meta["kind"], "subagent");
1132        assert_eq!(meta["parent_id"], "sess-parent");
1133        assert_eq!(meta["group"], "wf-1");
1134        // And the meta round-trips through the typed struct (forward-compat:
1135        // unknown fields would be ignored by this same path).
1136        let parsed: SessionMeta = serde_json::from_value(meta.clone()).unwrap();
1137        assert_eq!(parsed.kind, "subagent");
1138    }
1139}