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/// The rollout filenames directly inside `dir`.
397fn rollout_names(dir: &Path) -> Vec<String> {
398    let Ok(entries) = std::fs::read_dir(dir) else {
399        return Vec::new();
400    };
401    entries
402        .flatten()
403        .filter_map(|e| e.file_name().into_string().ok())
404        .filter(|n| {
405            n.starts_with("rollout-")
406                && std::path::Path::new(n)
407                    .extension()
408                    .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
409        })
410        .collect()
411}
412
413fn dir_hit(dir: &Path, suffix: &str) -> Option<PathBuf> {
414    rollout_names(dir)
415        .into_iter()
416        .find(|n| n.ends_with(suffix))
417        .map(|n| dir.join(n))
418}
419
420/// Open (or create) a rollout for appending, `0600`, healing a torn tail: if the
421/// last byte isn't `\n`, one is appended first so the next record starts clean —
422/// bounding crash damage to exactly one line (grok's rule).
423pub(crate) fn open_append_private(path: &Path) -> Result<std::fs::File, String> {
424    let mut options = std::fs::OpenOptions::new();
425    options.create(true).append(true);
426    #[cfg(unix)]
427    {
428        use std::os::unix::fs::OpenOptionsExt;
429        options.mode(0o600);
430    }
431    let mut file = options
432        .open(path)
433        .map_err(|e| format!("open {}: {e}", path.display()))?;
434    heal_torn_tail(path, &mut file)?;
435    Ok(file)
436}
437
438fn heal_torn_tail(path: &Path, file: &mut std::fs::File) -> Result<(), String> {
439    use std::io::{Read, Seek, SeekFrom};
440    let len = file
441        .metadata()
442        .map_err(|e| format!("stat {}: {e}", path.display()))?
443        .len();
444    if len == 0 {
445        return Ok(());
446    }
447    // Read the final byte via a fresh read handle (`self` is append-only).
448    let mut reader =
449        std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?;
450    reader
451        .seek(SeekFrom::End(-1))
452        .map_err(|e| format!("seek {}: {e}", path.display()))?;
453    let mut last = [0_u8; 1];
454    reader
455        .read_exact(&mut last)
456        .map_err(|e| format!("read {}: {e}", path.display()))?;
457    if last[0] != b'\n' {
458        file.write_all(b"\n")
459            .map_err(|e| format!("heal {}: {e}", path.display()))?;
460    }
461    Ok(())
462}
463
464/// `mkdir -p` with `0700` on every newly created component (best-effort mode —
465/// pre-existing dirs are left alone).
466pub(crate) fn create_dir_private(dir: &Path) -> Result<(), String> {
467    #[cfg(unix)]
468    {
469        use std::os::unix::fs::DirBuilderExt;
470        std::fs::DirBuilder::new()
471            .recursive(true)
472            .mode(0o700)
473            .create(dir)
474            .map_err(|e| format!("create {}: {e}", dir.display()))
475    }
476    #[cfg(not(unix))]
477    {
478        std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))
479    }
480}
481
482/// The record timestamp: RFC3339 with milliseconds, UTC (codex's shape).
483fn now_rfc3339_millis() -> String {
484    chrono::Utc::now()
485        .format("%Y-%m-%dT%H:%M:%S%.3fZ")
486        .to_string()
487}
488
489/// The filename timestamp: colons → dashes for filesystem compatibility
490/// (codex `recorder.rs:1547-1550`); reverse-chron name-sorting within a dir.
491fn filename_timestamp() -> String {
492    chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string()
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use locode_protocol::{ContentBlock, Role};
499    use serde_json::Value;
500
501    fn init_event(session_id: &str, cwd: &Path) -> Event {
502        Event::Init {
503            session_id: session_id.to_string(),
504            harness: "codex".to_string(),
505            api_schema: "mock".to_string(),
506            model: "mock-1".to_string(),
507            cwd: cwd.to_string_lossy().into_owned(),
508            max_turns: None,
509            preamble: vec![Message {
510                role: Role::System,
511                content: vec![ContentBlock::Text {
512                    text: "base prompt".to_string(),
513                }],
514            }],
515            tools: vec![],
516        }
517    }
518
519    fn message_event(role: Role, text: &str) -> Event {
520        Event::Message {
521            message: Message {
522                role,
523                content: vec![ContentBlock::Text {
524                    text: text.to_string(),
525                }],
526            },
527        }
528    }
529
530    fn read_lines(path: &Path) -> Vec<Value> {
531        std::fs::read_to_string(path)
532            .unwrap()
533            .lines()
534            .map(|l| serde_json::from_str(l).unwrap())
535            .collect()
536    }
537
538    #[test]
539    fn writes_header_preamble_and_messages() {
540        let root = tempfile::tempdir().unwrap();
541        let cwd = tempfile::tempdir().unwrap();
542        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
543        let mut writer = TraceWriter::new(
544            root.path().join("sessions"),
545            TraceExtras {
546                cli_version: "0.1.9".to_string(),
547                git: Some(GitMeta {
548                    branch: Some("main".to_string()),
549                    ..Default::default()
550                }),
551                ..Default::default()
552            },
553        );
554        writer.on_event(&init_event("sess-1", &cwd));
555        writer.on_event(&message_event(Role::User, "hi"));
556        writer.on_event(&Event::MessageDelta {
557            text: "ignored".to_string(),
558        });
559        writer.on_event(&message_event(Role::Assistant, "hello"));
560        assert!(writer.take_error().is_none());
561
562        // The file landed in the encoded-cwd dir with the id in the name.
563        let path = writer.path().unwrap().to_path_buf();
564        assert_eq!(
565            path.parent()
566                .unwrap()
567                .file_name()
568                .unwrap()
569                .to_str()
570                .unwrap(),
571            encode_cwd_dirname(&cwd)
572        );
573        assert!(
574            path.file_name()
575                .unwrap()
576                .to_str()
577                .unwrap()
578                .starts_with("rollout-")
579        );
580        assert!(
581            path.file_name()
582                .unwrap()
583                .to_str()
584                .unwrap()
585                .ends_with("-sess-1.jsonl")
586        );
587
588        let lines = read_lines(&path);
589        assert_eq!(lines.len(), 4, "meta + preamble + 2 messages");
590        assert_eq!(lines[0]["type"], "session_meta");
591        let meta = &lines[0]["payload"];
592        assert_eq!(meta["schema_version"], 1);
593        assert_eq!(meta["session_id"], "sess-1");
594        assert_eq!(meta["kind"], "main");
595        assert_eq!(meta["harness"], "codex");
596        assert_eq!(meta["git"]["branch"], "main");
597        assert!(meta.get("parent_id").is_none(), "absent when None");
598        // Preamble + appended messages are verbatim protocol Messages.
599        assert_eq!(lines[1]["type"], "message");
600        assert_eq!(lines[1]["payload"]["role"], "system");
601        assert_eq!(lines[2]["payload"]["role"], "user");
602        assert_eq!(lines[3]["payload"]["role"], "assistant");
603        // Every line carries a timestamp.
604        for line in &lines {
605            assert!(line["timestamp"].as_str().unwrap().ends_with('Z'));
606        }
607    }
608
609    #[cfg(unix)]
610    #[test]
611    fn modes_are_private() {
612        use std::os::unix::fs::PermissionsExt;
613        let root = tempfile::tempdir().unwrap();
614        let cwd = tempfile::tempdir().unwrap();
615        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
616        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
617        writer.on_event(&init_event("sess-2", &cwd));
618        let path = writer.path().unwrap();
619        let file_mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
620        let dir_mode = std::fs::metadata(path.parent().unwrap())
621            .unwrap()
622            .permissions()
623            .mode()
624            & 0o777;
625        assert_eq!(file_mode, 0o600, "rollout is 0600");
626        assert_eq!(dir_mode, 0o700, "session dir is 0700");
627    }
628
629    #[test]
630    fn torn_tail_is_healed_on_reopen() {
631        let dir = tempfile::tempdir().unwrap();
632        let path = dir.path().join("rollout-x.jsonl");
633        std::fs::write(&path, "{\"ok\":1}\n{\"torn\":").unwrap();
634        let mut file = open_append_private(&path).unwrap();
635        file.write_all(b"{\"next\":2}\n").unwrap();
636        drop(file);
637        let text = std::fs::read_to_string(&path).unwrap();
638        assert_eq!(text, "{\"ok\":1}\n{\"torn\":\n{\"next\":2}\n");
639        // Exactly one line is garbage; the rest parse.
640        let parsed: Vec<_> = text
641            .lines()
642            .filter(|l| serde_json::from_str::<Value>(l).is_ok())
643            .collect();
644        assert_eq!(parsed.len(), 2);
645    }
646
647    #[test]
648    fn no_init_means_no_file_and_errors_disable() {
649        let root = tempfile::tempdir().unwrap();
650        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
651        writer.on_event(&message_event(Role::User, "before init"));
652        assert!(writer.path().is_none(), "nothing written before Init");
653
654        // An unwritable root disables the writer with an error, never a panic.
655        let mut writer = TraceWriter::new(PathBuf::from("/dev/null/nope"), TraceExtras::default());
656        let cwd = std::env::temp_dir();
657        writer.on_event(&init_event("sess-3", &cwd));
658        assert!(writer.take_error().is_some());
659        // Further events are no-ops.
660        writer.on_event(&message_event(Role::User, "x"));
661    }
662
663    #[test]
664    fn read_rollout_replays_and_tolerates() {
665        let root = tempfile::tempdir().unwrap();
666        let cwd = tempfile::tempdir().unwrap();
667        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
668        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
669        writer.on_event(&init_event("sess-r", &cwd));
670        writer.on_event(&message_event(Role::User, "hi"));
671        writer.on_event(&message_event(Role::Assistant, "yo"));
672        let path = writer.path().unwrap().to_path_buf();
673
674        // Sprinkle §2.4 hazards: an unknown record type, a torn tail.
675        {
676            let mut file = open_append_private(&path).unwrap();
677            file.write_all(
678                b"{\"timestamp\":\"x\",\"type\":\"future_thing\",\"payload\":{\"n\":1}}\n",
679            )
680            .unwrap();
681            file.write_all(b"{\"torn").unwrap();
682        }
683        let contents = read_rollout(&path).unwrap();
684        assert_eq!(contents.meta.session_id, "sess-r");
685        // preamble(1) + user + assistant; the unknown type and torn line vanish.
686        assert_eq!(contents.history.len(), 3);
687        assert_eq!(contents.history[0].role, Role::System);
688        assert_eq!(contents.history[2].role, Role::Assistant);
689    }
690
691    #[test]
692    fn read_rollout_folds_compacted() {
693        let dir = tempfile::tempdir().unwrap();
694        let path = dir.path().join("rollout-t-sess-c.jsonl");
695        let meta = serde_json::json!({"timestamp":"t","type":"session_meta","payload":{
696            "schema_version":1,"session_id":"sess-c","kind":"main","cwd":"/x",
697            "cli_version":"0","harness":"grok","api_schema":"mock","model":"m"}});
698        let msg = |role: &str, text: &str| {
699            serde_json::json!({"timestamp":"t","type":"message","payload":
700                {"role":role,"content":[{"type":"text","text":text}]}})
701        };
702        let compacted = serde_json::json!({"timestamp":"t","type":"compacted","payload":{
703            "summary":"s","replacement_history":[
704                {"role":"user","content":[{"type":"text","text":"summary of the past"}]}]}});
705        let lines = [
706            meta.to_string(),
707            msg("user", "old-1").to_string(),
708            msg("assistant", "old-2").to_string(),
709            compacted.to_string(),
710            msg("user", "after").to_string(),
711        ];
712        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
713        let contents = read_rollout(&path).unwrap();
714        // Compaction replaced the two old messages; the post-compaction one follows.
715        assert_eq!(contents.history.len(), 2);
716        assert_eq!(contents.history[1].role, Role::User);
717    }
718
719    #[test]
720    fn resumed_writer_appends_without_a_new_header() {
721        let root = tempfile::tempdir().unwrap();
722        let cwd = tempfile::tempdir().unwrap();
723        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
724        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
725        writer.on_event(&init_event("sess-a", &cwd));
726        writer.on_event(&message_event(Role::User, "first run"));
727        let path = writer.path().unwrap().to_path_buf();
728        let before = read_lines(&path).len();
729        drop(writer);
730
731        let mut resumed = TraceWriter::resume(path.clone(), root.path().join("sessions")).unwrap();
732        resumed.on_event(&init_event("sess-a", &cwd)); // re-Init: ignored
733        resumed.on_event(&message_event(Role::User, "second run"));
734        assert!(resumed.take_error().is_none());
735        let lines = read_lines(&path);
736        assert_eq!(lines.len(), before + 1, "exactly one new message line");
737        assert_eq!(
738            lines.iter().filter(|l| l["type"] == "session_meta").count(),
739            1,
740            "one header, ever"
741        );
742    }
743
744    #[test]
745    fn find_latest_scopes_by_cwd_and_kind() {
746        let root = tempfile::tempdir().unwrap();
747        let sessions = root.path().join("sessions");
748        let cwd_a = tempfile::tempdir().unwrap();
749        let cwd_a = std::fs::canonicalize(cwd_a.path()).unwrap();
750        let cwd_b = tempfile::tempdir().unwrap();
751        let cwd_b = std::fs::canonicalize(cwd_b.path()).unwrap();
752
753        // Older main session in A; then a subagent session in A; a session in B.
754        let mut w1 = TraceWriter::new(sessions.clone(), TraceExtras::default());
755        w1.on_event(&init_event("sess-old", &cwd_a));
756        let mut w2 = TraceWriter::new(
757            sessions.clone(),
758            TraceExtras {
759                kind: Some("subagent".to_string()),
760                ..Default::default()
761            },
762        );
763        w2.on_event(&init_event("sess-sub", &cwd_a));
764        let mut w3 = TraceWriter::new(sessions.clone(), TraceExtras::default());
765        w3.on_event(&init_event("sess-b", &cwd_b));
766
767        // Continue in A: the subagent is skipped; the old main session wins.
768        let latest = find_latest_rollout(&sessions, &cwd_a).unwrap();
769        assert!(latest.to_string_lossy().contains("sess-old"), "{latest:?}");
770        // Continue in B is scoped to B.
771        let latest_b = find_latest_rollout(&sessions, &cwd_b).unwrap();
772        assert!(latest_b.to_string_lossy().contains("sess-b"));
773        // A cwd with no sessions has nothing to continue.
774        let empty = tempfile::tempdir().unwrap();
775        assert!(find_latest_rollout(&sessions, empty.path()).is_none());
776
777        // Resume by id: found from the "wrong" cwd via the global scan — and a
778        // subagent IS resumable by id.
779        let by_id = find_rollout_by_id(&sessions, &cwd_b, "sess-sub").unwrap();
780        assert!(by_id.to_string_lossy().contains("sess-sub"));
781        assert!(find_rollout_by_id(&sessions, &cwd_b, "sess-nope").is_none());
782    }
783
784    #[test]
785    fn usage_records_round_trip_for_exact_resume() {
786        use locode_protocol::{Report, Status, Usage};
787        let root = tempfile::tempdir().unwrap();
788        let cwd = tempfile::tempdir().unwrap();
789        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
790        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
791        writer.on_event(&init_event("sess-u", &cwd));
792        writer.on_event(&message_event(Role::User, "hi"));
793        // Two runs: the LAST usage wins.
794        let report = |input: u64, output: u64| Report {
795            schema_version: 1,
796            status: Status::Completed,
797            harness: "grok".into(),
798            api_schema: "mock".into(),
799            final_message: None,
800            structured_output: None,
801            turns: 1,
802            tool_calls: vec![],
803            // The run's sum — deliberately different from `context_usage`, so the test
804            // proves which one the record carries.
805            usage: Usage {
806                input_tokens: input * 3,
807                output_tokens: output * 3,
808                cache_read_tokens: Some(99),
809                ..Default::default()
810            },
811            context_usage: Usage {
812                input_tokens: input,
813                output_tokens: output,
814                cache_read_tokens: Some(7),
815                cache_creation_tokens: Some(5),
816                ..Default::default()
817            },
818            session_id: "sess-u".into(),
819            stop_reason: None,
820            error: None,
821        };
822        writer.on_event(&locode_protocol::Event::Result {
823            report: report(100, 10),
824        });
825        writer.on_event(&locode_protocol::Event::Result {
826            report: report(200, 20),
827        });
828        assert!(writer.take_error().is_none());
829
830        let contents = read_rollout(writer.path().unwrap()).unwrap();
831        let usage = contents.last_usage.expect("usage recovered");
832        assert_eq!(usage.input_tokens, 200, "the last run's context usage");
833        assert_eq!(usage.output_tokens, 20);
834        assert_eq!(usage.cache_read_tokens, Some(7));
835        // The record is the context occupancy, NOT the run's cost total: resuming from
836        // the sum would over-report a long run's context by a multiple.
837        assert_eq!(
838            usage.context_tokens(),
839            200 + 7 + 5 + 20,
840            "both cache counters are part of the prompt"
841        );
842        // And an old-style rollout (no usage records) still reads fine.
843        let bare = root.path().join("bare.jsonl");
844        let meta_line = std::fs::read_to_string(writer.path().unwrap())
845            .unwrap()
846            .lines()
847            .next()
848            .unwrap()
849            .to_string();
850        std::fs::write(&bare, meta_line + "\n").unwrap();
851        assert!(read_rollout(&bare).unwrap().last_usage.is_none());
852    }
853
854    #[test]
855    fn reserved_kinds_and_parents_serialize() {
856        let root = tempfile::tempdir().unwrap();
857        let cwd = tempfile::tempdir().unwrap();
858        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
859        let mut writer = TraceWriter::new(
860            root.path().join("sessions"),
861            TraceExtras {
862                cli_version: "x".to_string(),
863                kind: Some("subagent".to_string()),
864                parent_id: Some("sess-parent".to_string()),
865                group: Some("wf-1".to_string()),
866                ..Default::default()
867            },
868        );
869        writer.on_event(&init_event("sess-4", &cwd));
870        let lines = read_lines(writer.path().unwrap());
871        let meta = &lines[0]["payload"];
872        assert_eq!(meta["kind"], "subagent");
873        assert_eq!(meta["parent_id"], "sess-parent");
874        assert_eq!(meta["group"], "wf-1");
875        // And the meta round-trips through the typed struct (forward-compat:
876        // unknown fields would be ignored by this same path).
877        let parsed: SessionMeta = serde_json::from_value(meta.clone()).unwrap();
878        assert_eq!(parsed.kind, "subagent");
879    }
880}