1use 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
25pub const TRACE_SCHEMA_VERSION: u32 = 1;
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SessionMeta {
34 pub schema_version: u32,
36 pub session_id: String,
38 #[serde(default = "default_kind")]
41 pub kind: String,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub parent_id: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub group: Option<String>,
48 pub cwd: PathBuf,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub git: Option<GitMeta>,
53 pub cli_version: String,
55 pub harness: String,
58 pub api_schema: String,
60 pub model: String,
62}
63
64fn default_kind() -> String {
65 "main".to_string()
66}
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize)]
70pub struct GitMeta {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub root: Option<PathBuf>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub branch: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub head: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub remote: Option<String>,
83}
84
85#[derive(Debug, Clone, Default)]
87pub struct TraceExtras {
88 pub cli_version: String,
90 pub git: Option<GitMeta>,
92 pub kind: Option<String>,
94 pub parent_id: Option<String>,
96 pub group: Option<String>,
98}
99
100#[derive(Debug)]
105pub struct TraceWriter {
106 sessions_root: PathBuf,
107 extras: TraceExtras,
108 file: Option<std::fs::File>,
109 path: Option<PathBuf>,
111 error: Option<String>,
113 resumed: bool,
115}
116
117impl TraceWriter {
118 #[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 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 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 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 #[must_use]
195 pub fn path(&self) -> Option<&Path> {
196 self.path.as_deref()
197 }
198
199 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 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 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(()); }
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#[derive(Debug)]
282pub struct RolloutContents {
283 pub meta: SessionMeta,
285 pub history: Vec<Message>,
288 pub last_usage: Option<locode_protocol::Usage>,
292}
293
294pub 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 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 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 _ => {}
348 }
349 }
350 Ok(RolloutContents {
351 meta,
352 history,
353 last_usage,
354 })
355}
356
357#[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)); 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#[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#[derive(Debug, Clone)]
404pub struct SessionSummary {
405 pub id: String,
407 pub path: PathBuf,
409 pub cwd: PathBuf,
411 pub harness: String,
415 pub model: String,
417 pub branch: Option<String>,
419 pub last_active: std::time::SystemTime,
421}
422
423#[derive(Debug, Clone, Copy)]
425pub enum SessionScope<'a> {
426 Cwd(&'a Path),
428 All,
430}
431
432#[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 out.sort_by(|a, b| b.last_active.cmp(&a.last_active).then(a.id.cmp(&b.id)));
468 out
469}
470
471#[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
520const TITLE_SCAN_LINES: usize = 64;
524
525fn 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
551fn 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
575pub(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 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
619pub(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
637fn now_rfc3339_millis() -> String {
639 chrono::Utc::now()
640 .format("%Y-%m-%dT%H:%M:%S%.3fZ")
641 .to_string()
642}
643
644fn 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 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 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 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 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 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 writer.on_event(&message_event(Role::User, "x"));
816 }
817
818 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 #[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", &[]); write_rollout(&dir_b, "sess-other-dir", "main", &[]);
872
873 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 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 #[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 {
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 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 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)); 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 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 let latest = find_latest_rollout(&sessions, &cwd_a).unwrap();
1028 assert!(latest.to_string_lossy().contains("sess-old"), "{latest:?}");
1029 let latest_b = find_latest_rollout(&sessions, &cwd_b).unwrap();
1031 assert!(latest_b.to_string_lossy().contains("sess-b"));
1032 let empty = tempfile::tempdir().unwrap();
1034 assert!(find_latest_rollout(&sessions, empty.path()).is_none());
1035
1036 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 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 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 assert_eq!(
1097 usage.context_tokens(),
1098 200 + 7 + 5 + 20,
1099 "both cache counters are part of the prompt"
1100 );
1101 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 let parsed: SessionMeta = serde_json::from_value(meta.clone()).unwrap();
1137 assert_eq!(parsed.kind, "subagent");
1138 }
1139}