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            Event::Result { report } => {
173                if self.file.is_some() {
174                    self.append_record("usage", &report.usage)
175                } else {
176                    Ok(())
177                }
178            }
179            _ => Ok(()),
180        };
181        if let Err(e) = result {
182            self.error = Some(e);
183            self.file = None;
184        }
185    }
186
187    /// The open rollout path (after `Init`).
188    #[must_use]
189    pub fn path(&self) -> Option<&Path> {
190        self.path.as_deref()
191    }
192
193    /// The first IO error, if tracing failed (the caller surfaces it as a warning).
194    pub fn take_error(&mut self) -> Option<String> {
195        self.error.take()
196    }
197
198    fn on_init(
199        &mut self,
200        session_id: &str,
201        harness: &str,
202        api_schema: &str,
203        model: &str,
204        cwd: &str,
205        preamble: &[Message],
206    ) -> Result<(), String> {
207        let cwd_path = PathBuf::from(cwd);
208        let dirname = encode_cwd_dirname(&cwd_path);
209        let dir = self.sessions_root.join(&dirname);
210        create_dir_private(&dir)?;
211        // Hash-fallback dirs get the `.cwd` sidecar recovering the original path
212        // (§2.1 — fallback names never start with `+`).
213        if !dirname.starts_with('+') {
214            let sidecar = dir.join(".cwd");
215            if !sidecar.exists() {
216                std::fs::write(&sidecar, cwd).map_err(|e| format!("write .cwd sidecar: {e}"))?;
217            }
218        }
219        let filename = format!("rollout-{}-{session_id}.jsonl", filename_timestamp());
220        let path = dir.join(&filename);
221        let file = open_append_private(&path)?;
222        self.file = Some(file);
223        self.path = Some(path);
224
225        let meta = SessionMeta {
226            schema_version: TRACE_SCHEMA_VERSION,
227            session_id: session_id.to_string(),
228            kind: self
229                .extras
230                .kind
231                .clone()
232                .unwrap_or_else(|| "main".to_string()),
233            parent_id: self.extras.parent_id.clone(),
234            group: self.extras.group.clone(),
235            cwd: cwd_path,
236            git: self.extras.git.clone(),
237            cli_version: self.extras.cli_version.clone(),
238            harness: harness.to_string(),
239            api_schema: api_schema.to_string(),
240            model: model.to_string(),
241        };
242        self.append_record("session_meta", &meta)?;
243        // The preamble rides as ordinary message lines — the file replays alone.
244        for message in preamble {
245            self.append_record("message", message)?;
246        }
247        Ok(())
248    }
249
250    fn append_message(&mut self, message: &Message) -> Result<(), String> {
251        if self.file.is_none() {
252            return Ok(()); // no Init seen (tracing effectively off)
253        }
254        self.append_record("message", message)
255    }
256
257    fn append_record(&mut self, record_type: &str, payload: &impl Serialize) -> Result<(), String> {
258        let Some(file) = self.file.as_mut() else {
259            return Ok(());
260        };
261        let line = serde_json::json!({
262            "timestamp": now_rfc3339_millis(),
263            "type": record_type,
264            "payload": payload,
265        });
266        let mut buf = serde_json::to_string(&line).map_err(|e| format!("serialize: {e}"))?;
267        buf.push('\n');
268        file.write_all(buf.as_bytes())
269            .and_then(|()| file.flush())
270            .map_err(|e| format!("append trace record: {e}"))
271    }
272}
273
274/// A parsed rollout: the header plus the replayable message history.
275#[derive(Debug)]
276pub struct RolloutContents {
277    /// The line-1 header.
278    pub meta: SessionMeta,
279    /// The conversation, in append order (preamble included) — `compacted`
280    /// records already folded in.
281    pub history: Vec<Message>,
282    /// The **last** run's provider-reported usage, when the rollout carries
283    /// `usage` records (written since 2026-07-24) — the exact basis for the
284    /// resumed context occupancy. `None` on older rollouts (callers estimate).
285    pub last_usage: Option<locode_protocol::Usage>,
286}
287
288/// Read a rollout **tolerantly** (ADR-0024 §2.4 rules 1-2): unknown record
289/// types and unknown payload fields are skipped/ignored; an unparsable line
290/// (the torn tail) is skipped; a `compacted` record replaces all prior
291/// messages with its `replacement_history`.
292///
293/// # Errors
294/// Only when the file is unreadable or line 1 is not a valid `session_meta`
295/// (without a header the file identifies nothing).
296pub fn read_rollout(path: &Path) -> Result<RolloutContents, String> {
297    let text =
298        std::fs::read_to_string(path).map_err(|e| format!("read {}: {e}", path.display()))?;
299    let mut lines = text.lines();
300    let first = lines
301        .next()
302        .ok_or_else(|| format!("{}: empty rollout", path.display()))?;
303    let first: serde_json::Value = serde_json::from_str(first)
304        .map_err(|e| format!("{}: line 1 unparsable: {e}", path.display()))?;
305    if first.get("type").and_then(serde_json::Value::as_str) != Some("session_meta") {
306        return Err(format!("{}: line 1 is not session_meta", path.display()));
307    }
308    let meta: SessionMeta = serde_json::from_value(first["payload"].clone())
309        .map_err(|e| format!("{}: session_meta invalid: {e}", path.display()))?;
310
311    let mut history: Vec<Message> = Vec::new();
312    let mut last_usage: Option<locode_protocol::Usage> = None;
313    for line in lines {
314        // Torn tail / foreign garbage: skip, never fail (§2.4).
315        let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
316            continue;
317        };
318        match value.get("type").and_then(serde_json::Value::as_str) {
319            Some("message") => {
320                if let Ok(message) = serde_json::from_value::<Message>(value["payload"].clone()) {
321                    history.push(message);
322                }
323            }
324            Some("compacted") => {
325                // Fold: the replacement history stands in for everything prior.
326                if let Some(replacement) = value["payload"].get("replacement_history")
327                    && let Ok(messages) =
328                        serde_json::from_value::<Vec<Message>>(replacement.clone())
329                {
330                    history = messages;
331                }
332            }
333            Some("usage") => {
334                if let Ok(usage) =
335                    serde_json::from_value::<locode_protocol::Usage>(value["payload"].clone())
336                {
337                    last_usage = Some(usage);
338                }
339            }
340            // Unknown record types (future features) are invisible to this reader.
341            _ => {}
342        }
343    }
344    Ok(RolloutContents {
345        meta,
346        history,
347        last_usage,
348    })
349}
350
351/// The newest resumable rollout for `cwd` (`--continue`): list the one encoded
352/// directory, take rollouts newest-first by filename (the timestamp prefix
353/// sorts chronologically), and return the first whose header parses with
354/// `kind == "main"` — unknown kinds are not listed (§2.4 rule 3), but remain
355/// reachable by id.
356#[must_use]
357pub fn find_latest_rollout(sessions_root: &Path, cwd: &Path) -> Option<PathBuf> {
358    let dir = sessions_root.join(encode_cwd_dirname(cwd));
359    let mut names: Vec<String> = rollout_names(&dir);
360    names.sort_unstable_by(|a, b| b.cmp(a)); // newest first
361    names
362        .into_iter()
363        .map(|n| dir.join(n))
364        .find(|path| read_rollout(path).is_ok_and(|c| c.meta.kind == "main"))
365}
366
367/// Locate a rollout by session id (`--resume <id>`): the cwd's directory first,
368/// then every other session directory (Claude's scoped-then-global resolver).
369/// Any `kind` is resumable by id.
370#[must_use]
371pub fn find_rollout_by_id(sessions_root: &Path, cwd: &Path, id: &str) -> Option<PathBuf> {
372    let suffix = format!("-{id}.jsonl");
373    let scoped = sessions_root.join(encode_cwd_dirname(cwd));
374    if let Some(hit) = dir_hit(&scoped, &suffix) {
375        return Some(hit);
376    }
377    let entries = std::fs::read_dir(sessions_root).ok()?;
378    for entry in entries.flatten() {
379        let dir = entry.path();
380        if dir == scoped || !dir.is_dir() {
381            continue;
382        }
383        if let Some(hit) = dir_hit(&dir, &suffix) {
384            return Some(hit);
385        }
386    }
387    None
388}
389
390/// The rollout filenames directly inside `dir`.
391fn rollout_names(dir: &Path) -> Vec<String> {
392    let Ok(entries) = std::fs::read_dir(dir) else {
393        return Vec::new();
394    };
395    entries
396        .flatten()
397        .filter_map(|e| e.file_name().into_string().ok())
398        .filter(|n| {
399            n.starts_with("rollout-")
400                && std::path::Path::new(n)
401                    .extension()
402                    .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl"))
403        })
404        .collect()
405}
406
407fn dir_hit(dir: &Path, suffix: &str) -> Option<PathBuf> {
408    rollout_names(dir)
409        .into_iter()
410        .find(|n| n.ends_with(suffix))
411        .map(|n| dir.join(n))
412}
413
414/// Open (or create) a rollout for appending, `0600`, healing a torn tail: if the
415/// last byte isn't `\n`, one is appended first so the next record starts clean —
416/// bounding crash damage to exactly one line (grok's rule).
417pub(crate) fn open_append_private(path: &Path) -> Result<std::fs::File, String> {
418    let mut options = std::fs::OpenOptions::new();
419    options.create(true).append(true);
420    #[cfg(unix)]
421    {
422        use std::os::unix::fs::OpenOptionsExt;
423        options.mode(0o600);
424    }
425    let mut file = options
426        .open(path)
427        .map_err(|e| format!("open {}: {e}", path.display()))?;
428    heal_torn_tail(path, &mut file)?;
429    Ok(file)
430}
431
432fn heal_torn_tail(path: &Path, file: &mut std::fs::File) -> Result<(), String> {
433    use std::io::{Read, Seek, SeekFrom};
434    let len = file
435        .metadata()
436        .map_err(|e| format!("stat {}: {e}", path.display()))?
437        .len();
438    if len == 0 {
439        return Ok(());
440    }
441    // Read the final byte via a fresh read handle (`self` is append-only).
442    let mut reader =
443        std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?;
444    reader
445        .seek(SeekFrom::End(-1))
446        .map_err(|e| format!("seek {}: {e}", path.display()))?;
447    let mut last = [0_u8; 1];
448    reader
449        .read_exact(&mut last)
450        .map_err(|e| format!("read {}: {e}", path.display()))?;
451    if last[0] != b'\n' {
452        file.write_all(b"\n")
453            .map_err(|e| format!("heal {}: {e}", path.display()))?;
454    }
455    Ok(())
456}
457
458/// `mkdir -p` with `0700` on every newly created component (best-effort mode —
459/// pre-existing dirs are left alone).
460pub(crate) fn create_dir_private(dir: &Path) -> Result<(), String> {
461    #[cfg(unix)]
462    {
463        use std::os::unix::fs::DirBuilderExt;
464        std::fs::DirBuilder::new()
465            .recursive(true)
466            .mode(0o700)
467            .create(dir)
468            .map_err(|e| format!("create {}: {e}", dir.display()))
469    }
470    #[cfg(not(unix))]
471    {
472        std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))
473    }
474}
475
476/// The record timestamp: RFC3339 with milliseconds, UTC (codex's shape).
477fn now_rfc3339_millis() -> String {
478    chrono::Utc::now()
479        .format("%Y-%m-%dT%H:%M:%S%.3fZ")
480        .to_string()
481}
482
483/// The filename timestamp: colons → dashes for filesystem compatibility
484/// (codex `recorder.rs:1547-1550`); reverse-chron name-sorting within a dir.
485fn filename_timestamp() -> String {
486    chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S").to_string()
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use locode_protocol::{ContentBlock, Role};
493    use serde_json::Value;
494
495    fn init_event(session_id: &str, cwd: &Path) -> Event {
496        Event::Init {
497            session_id: session_id.to_string(),
498            harness: "codex".to_string(),
499            api_schema: "mock".to_string(),
500            model: "mock-1".to_string(),
501            cwd: cwd.to_string_lossy().into_owned(),
502            max_turns: None,
503            preamble: vec![Message {
504                role: Role::System,
505                content: vec![ContentBlock::Text {
506                    text: "base prompt".to_string(),
507                }],
508            }],
509            tools: vec![],
510        }
511    }
512
513    fn message_event(role: Role, text: &str) -> Event {
514        Event::Message {
515            message: Message {
516                role,
517                content: vec![ContentBlock::Text {
518                    text: text.to_string(),
519                }],
520            },
521        }
522    }
523
524    fn read_lines(path: &Path) -> Vec<Value> {
525        std::fs::read_to_string(path)
526            .unwrap()
527            .lines()
528            .map(|l| serde_json::from_str(l).unwrap())
529            .collect()
530    }
531
532    #[test]
533    fn writes_header_preamble_and_messages() {
534        let root = tempfile::tempdir().unwrap();
535        let cwd = tempfile::tempdir().unwrap();
536        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
537        let mut writer = TraceWriter::new(
538            root.path().join("sessions"),
539            TraceExtras {
540                cli_version: "0.1.9".to_string(),
541                git: Some(GitMeta {
542                    branch: Some("main".to_string()),
543                    ..Default::default()
544                }),
545                ..Default::default()
546            },
547        );
548        writer.on_event(&init_event("sess-1", &cwd));
549        writer.on_event(&message_event(Role::User, "hi"));
550        writer.on_event(&Event::MessageDelta {
551            text: "ignored".to_string(),
552        });
553        writer.on_event(&message_event(Role::Assistant, "hello"));
554        assert!(writer.take_error().is_none());
555
556        // The file landed in the encoded-cwd dir with the id in the name.
557        let path = writer.path().unwrap().to_path_buf();
558        assert_eq!(
559            path.parent()
560                .unwrap()
561                .file_name()
562                .unwrap()
563                .to_str()
564                .unwrap(),
565            encode_cwd_dirname(&cwd)
566        );
567        assert!(
568            path.file_name()
569                .unwrap()
570                .to_str()
571                .unwrap()
572                .starts_with("rollout-")
573        );
574        assert!(
575            path.file_name()
576                .unwrap()
577                .to_str()
578                .unwrap()
579                .ends_with("-sess-1.jsonl")
580        );
581
582        let lines = read_lines(&path);
583        assert_eq!(lines.len(), 4, "meta + preamble + 2 messages");
584        assert_eq!(lines[0]["type"], "session_meta");
585        let meta = &lines[0]["payload"];
586        assert_eq!(meta["schema_version"], 1);
587        assert_eq!(meta["session_id"], "sess-1");
588        assert_eq!(meta["kind"], "main");
589        assert_eq!(meta["harness"], "codex");
590        assert_eq!(meta["git"]["branch"], "main");
591        assert!(meta.get("parent_id").is_none(), "absent when None");
592        // Preamble + appended messages are verbatim protocol Messages.
593        assert_eq!(lines[1]["type"], "message");
594        assert_eq!(lines[1]["payload"]["role"], "system");
595        assert_eq!(lines[2]["payload"]["role"], "user");
596        assert_eq!(lines[3]["payload"]["role"], "assistant");
597        // Every line carries a timestamp.
598        for line in &lines {
599            assert!(line["timestamp"].as_str().unwrap().ends_with('Z'));
600        }
601    }
602
603    #[cfg(unix)]
604    #[test]
605    fn modes_are_private() {
606        use std::os::unix::fs::PermissionsExt;
607        let root = tempfile::tempdir().unwrap();
608        let cwd = tempfile::tempdir().unwrap();
609        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
610        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
611        writer.on_event(&init_event("sess-2", &cwd));
612        let path = writer.path().unwrap();
613        let file_mode = std::fs::metadata(path).unwrap().permissions().mode() & 0o777;
614        let dir_mode = std::fs::metadata(path.parent().unwrap())
615            .unwrap()
616            .permissions()
617            .mode()
618            & 0o777;
619        assert_eq!(file_mode, 0o600, "rollout is 0600");
620        assert_eq!(dir_mode, 0o700, "session dir is 0700");
621    }
622
623    #[test]
624    fn torn_tail_is_healed_on_reopen() {
625        let dir = tempfile::tempdir().unwrap();
626        let path = dir.path().join("rollout-x.jsonl");
627        std::fs::write(&path, "{\"ok\":1}\n{\"torn\":").unwrap();
628        let mut file = open_append_private(&path).unwrap();
629        file.write_all(b"{\"next\":2}\n").unwrap();
630        drop(file);
631        let text = std::fs::read_to_string(&path).unwrap();
632        assert_eq!(text, "{\"ok\":1}\n{\"torn\":\n{\"next\":2}\n");
633        // Exactly one line is garbage; the rest parse.
634        let parsed: Vec<_> = text
635            .lines()
636            .filter(|l| serde_json::from_str::<Value>(l).is_ok())
637            .collect();
638        assert_eq!(parsed.len(), 2);
639    }
640
641    #[test]
642    fn no_init_means_no_file_and_errors_disable() {
643        let root = tempfile::tempdir().unwrap();
644        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
645        writer.on_event(&message_event(Role::User, "before init"));
646        assert!(writer.path().is_none(), "nothing written before Init");
647
648        // An unwritable root disables the writer with an error, never a panic.
649        let mut writer = TraceWriter::new(PathBuf::from("/dev/null/nope"), TraceExtras::default());
650        let cwd = std::env::temp_dir();
651        writer.on_event(&init_event("sess-3", &cwd));
652        assert!(writer.take_error().is_some());
653        // Further events are no-ops.
654        writer.on_event(&message_event(Role::User, "x"));
655    }
656
657    #[test]
658    fn read_rollout_replays_and_tolerates() {
659        let root = tempfile::tempdir().unwrap();
660        let cwd = tempfile::tempdir().unwrap();
661        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
662        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
663        writer.on_event(&init_event("sess-r", &cwd));
664        writer.on_event(&message_event(Role::User, "hi"));
665        writer.on_event(&message_event(Role::Assistant, "yo"));
666        let path = writer.path().unwrap().to_path_buf();
667
668        // Sprinkle §2.4 hazards: an unknown record type, a torn tail.
669        {
670            let mut file = open_append_private(&path).unwrap();
671            file.write_all(
672                b"{\"timestamp\":\"x\",\"type\":\"future_thing\",\"payload\":{\"n\":1}}\n",
673            )
674            .unwrap();
675            file.write_all(b"{\"torn").unwrap();
676        }
677        let contents = read_rollout(&path).unwrap();
678        assert_eq!(contents.meta.session_id, "sess-r");
679        // preamble(1) + user + assistant; the unknown type and torn line vanish.
680        assert_eq!(contents.history.len(), 3);
681        assert_eq!(contents.history[0].role, Role::System);
682        assert_eq!(contents.history[2].role, Role::Assistant);
683    }
684
685    #[test]
686    fn read_rollout_folds_compacted() {
687        let dir = tempfile::tempdir().unwrap();
688        let path = dir.path().join("rollout-t-sess-c.jsonl");
689        let meta = serde_json::json!({"timestamp":"t","type":"session_meta","payload":{
690            "schema_version":1,"session_id":"sess-c","kind":"main","cwd":"/x",
691            "cli_version":"0","harness":"grok","api_schema":"mock","model":"m"}});
692        let msg = |role: &str, text: &str| {
693            serde_json::json!({"timestamp":"t","type":"message","payload":
694                {"role":role,"content":[{"type":"text","text":text}]}})
695        };
696        let compacted = serde_json::json!({"timestamp":"t","type":"compacted","payload":{
697            "summary":"s","replacement_history":[
698                {"role":"user","content":[{"type":"text","text":"summary of the past"}]}]}});
699        let lines = [
700            meta.to_string(),
701            msg("user", "old-1").to_string(),
702            msg("assistant", "old-2").to_string(),
703            compacted.to_string(),
704            msg("user", "after").to_string(),
705        ];
706        std::fs::write(&path, lines.join("\n") + "\n").unwrap();
707        let contents = read_rollout(&path).unwrap();
708        // Compaction replaced the two old messages; the post-compaction one follows.
709        assert_eq!(contents.history.len(), 2);
710        assert_eq!(contents.history[1].role, Role::User);
711    }
712
713    #[test]
714    fn resumed_writer_appends_without_a_new_header() {
715        let root = tempfile::tempdir().unwrap();
716        let cwd = tempfile::tempdir().unwrap();
717        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
718        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
719        writer.on_event(&init_event("sess-a", &cwd));
720        writer.on_event(&message_event(Role::User, "first run"));
721        let path = writer.path().unwrap().to_path_buf();
722        let before = read_lines(&path).len();
723        drop(writer);
724
725        let mut resumed = TraceWriter::resume(path.clone(), root.path().join("sessions")).unwrap();
726        resumed.on_event(&init_event("sess-a", &cwd)); // re-Init: ignored
727        resumed.on_event(&message_event(Role::User, "second run"));
728        assert!(resumed.take_error().is_none());
729        let lines = read_lines(&path);
730        assert_eq!(lines.len(), before + 1, "exactly one new message line");
731        assert_eq!(
732            lines.iter().filter(|l| l["type"] == "session_meta").count(),
733            1,
734            "one header, ever"
735        );
736    }
737
738    #[test]
739    fn find_latest_scopes_by_cwd_and_kind() {
740        let root = tempfile::tempdir().unwrap();
741        let sessions = root.path().join("sessions");
742        let cwd_a = tempfile::tempdir().unwrap();
743        let cwd_a = std::fs::canonicalize(cwd_a.path()).unwrap();
744        let cwd_b = tempfile::tempdir().unwrap();
745        let cwd_b = std::fs::canonicalize(cwd_b.path()).unwrap();
746
747        // Older main session in A; then a subagent session in A; a session in B.
748        let mut w1 = TraceWriter::new(sessions.clone(), TraceExtras::default());
749        w1.on_event(&init_event("sess-old", &cwd_a));
750        let mut w2 = TraceWriter::new(
751            sessions.clone(),
752            TraceExtras {
753                kind: Some("subagent".to_string()),
754                ..Default::default()
755            },
756        );
757        w2.on_event(&init_event("sess-sub", &cwd_a));
758        let mut w3 = TraceWriter::new(sessions.clone(), TraceExtras::default());
759        w3.on_event(&init_event("sess-b", &cwd_b));
760
761        // Continue in A: the subagent is skipped; the old main session wins.
762        let latest = find_latest_rollout(&sessions, &cwd_a).unwrap();
763        assert!(latest.to_string_lossy().contains("sess-old"), "{latest:?}");
764        // Continue in B is scoped to B.
765        let latest_b = find_latest_rollout(&sessions, &cwd_b).unwrap();
766        assert!(latest_b.to_string_lossy().contains("sess-b"));
767        // A cwd with no sessions has nothing to continue.
768        let empty = tempfile::tempdir().unwrap();
769        assert!(find_latest_rollout(&sessions, empty.path()).is_none());
770
771        // Resume by id: found from the "wrong" cwd via the global scan — and a
772        // subagent IS resumable by id.
773        let by_id = find_rollout_by_id(&sessions, &cwd_b, "sess-sub").unwrap();
774        assert!(by_id.to_string_lossy().contains("sess-sub"));
775        assert!(find_rollout_by_id(&sessions, &cwd_b, "sess-nope").is_none());
776    }
777
778    #[test]
779    fn usage_records_round_trip_for_exact_resume() {
780        use locode_protocol::{Report, Status, Usage};
781        let root = tempfile::tempdir().unwrap();
782        let cwd = tempfile::tempdir().unwrap();
783        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
784        let mut writer = TraceWriter::new(root.path().join("sessions"), TraceExtras::default());
785        writer.on_event(&init_event("sess-u", &cwd));
786        writer.on_event(&message_event(Role::User, "hi"));
787        // Two runs: the LAST usage wins.
788        let report = |input: u64, output: u64| Report {
789            schema_version: 1,
790            status: Status::Completed,
791            harness: "grok".into(),
792            api_schema: "mock".into(),
793            final_message: None,
794            structured_output: None,
795            turns: 1,
796            tool_calls: vec![],
797            usage: Usage {
798                input_tokens: input,
799                output_tokens: output,
800                cache_read_tokens: Some(7),
801                ..Default::default()
802            },
803            session_id: "sess-u".into(),
804            stop_reason: None,
805            error: None,
806        };
807        writer.on_event(&locode_protocol::Event::Result {
808            report: report(100, 10),
809        });
810        writer.on_event(&locode_protocol::Event::Result {
811            report: report(200, 20),
812        });
813        assert!(writer.take_error().is_none());
814
815        let contents = read_rollout(writer.path().unwrap()).unwrap();
816        let usage = contents.last_usage.expect("usage recovered");
817        assert_eq!(usage.input_tokens, 200, "the last run's usage");
818        assert_eq!(usage.output_tokens, 20);
819        assert_eq!(usage.cache_read_tokens, Some(7));
820        // And an old-style rollout (no usage records) still reads fine.
821        let bare = root.path().join("bare.jsonl");
822        let meta_line = std::fs::read_to_string(writer.path().unwrap())
823            .unwrap()
824            .lines()
825            .next()
826            .unwrap()
827            .to_string();
828        std::fs::write(&bare, meta_line + "\n").unwrap();
829        assert!(read_rollout(&bare).unwrap().last_usage.is_none());
830    }
831
832    #[test]
833    fn reserved_kinds_and_parents_serialize() {
834        let root = tempfile::tempdir().unwrap();
835        let cwd = tempfile::tempdir().unwrap();
836        let cwd = std::fs::canonicalize(cwd.path()).unwrap();
837        let mut writer = TraceWriter::new(
838            root.path().join("sessions"),
839            TraceExtras {
840                cli_version: "x".to_string(),
841                kind: Some("subagent".to_string()),
842                parent_id: Some("sess-parent".to_string()),
843                group: Some("wf-1".to_string()),
844                ..Default::default()
845            },
846        );
847        writer.on_event(&init_event("sess-4", &cwd));
848        let lines = read_lines(writer.path().unwrap());
849        let meta = &lines[0]["payload"];
850        assert_eq!(meta["kind"], "subagent");
851        assert_eq!(meta["parent_id"], "sess-parent");
852        assert_eq!(meta["group"], "wf-1");
853        // And the meta round-trips through the typed struct (forward-compat:
854        // unknown fields would be ignored by this same path).
855        let parsed: SessionMeta = serde_json::from_value(meta.clone()).unwrap();
856        assert_eq!(parsed.kind, "subagent");
857    }
858}