use std::path::Path;
use chrono::{DateTime, Utc};
use tokio::sync::mpsc;
use crate::transcript::{self, SubagentMeta};
use super::item::{ReplayItem, date_and_sort, entry_timestamp};
use super::live::{LiveSession, SnapshotSeed, resolve_live_target, tail_loop};
use super::{Flow, Source, TailRequest, UiEvent, Update};
pub(crate) async fn run_replay(
path: &Path,
ui_tx: &mpsc::Sender<UiEvent>,
req_rx: &mut mpsc::Receiver<TailRequest>,
speed: f64,
) -> Flow {
let session_id = transcript::session_id_from_path(path);
let owned_path = path.to_path_buf();
let (items, info, seed) =
match tokio::task::spawn_blocking(move || build_replay(&owned_path)).await {
Ok(loaded) => loaded,
Err(e) => {
let _ = ui_tx
.send(UiEvent::Error(format!("failed to load session: {e}")))
.await;
return Flow::Exit;
}
};
let speed = if speed > 0.0 { speed } else { 1.0 };
if ui_tx
.send(UiEvent::ReplayLoaded {
session_id: session_id.clone(),
items,
speed,
info,
})
.await
.is_err()
{
return Flow::Exit;
}
let Some((project_dir, main_path)) = resolve_live_target(path) else {
return match req_rx.recv().await {
Some(TailRequest::Watch(p)) => Flow::Switch(p),
None => Flow::Exit,
};
};
let mut session = LiveSession::new(project_dir, main_path);
session.project_dir = None; session.seed(seed);
tail_loop(session, session_id, ui_tx, req_rx).await
}
pub(crate) fn build_replay(
main_path: &Path,
) -> (Vec<ReplayItem>, crate::state::SessionInfo, SnapshotSeed) {
let mut items: Vec<ReplayItem> = Vec::new();
let mut seed = SnapshotSeed::default();
let parse =
|path: &Path, source: Source, items: &mut Vec<ReplayItem>, seed: &mut SnapshotSeed| {
let consumed = parse_file_into(path, source, items);
seed.offsets.insert(path.to_path_buf(), consumed);
};
parse(main_path, Source::Main, &mut items, &mut seed);
if let Some(subagents) = transcript::subagents_dir(main_path) {
for f in transcript::scan_subagent_files(&subagents, None) {
if f.meta.is_file()
&& push_meta_item(&f.meta, f.agent_id.clone(), f.workflow, &mut items)
{
seed.seen_meta.insert(f.meta.clone());
}
parse(
&f.transcript,
Source::Sub(f.agent_id),
&mut items,
&mut seed,
);
}
for wf_id in transcript::scan_workflow_ids(&subagents) {
let journal = transcript::workflow_journal(&subagents, &wf_id);
if journal.is_file() {
parse(
&journal,
Source::Journal(wf_id.clone()),
&mut items,
&mut seed,
);
}
let wf_dir = transcript::workflow_dir(&subagents, &wf_id);
for f in transcript::scan_subagent_files(&wf_dir, Some(&wf_id)) {
if f.meta.is_file()
&& push_meta_item(&f.meta, f.agent_id.clone(), f.workflow, &mut items)
{
seed.seen_meta.insert(f.meta.clone());
}
parse(
&f.transcript,
Source::Sub(f.agent_id),
&mut items,
&mut seed,
);
}
}
}
let mut info = crate::state::SessionInfo::default();
items.retain(|item| match &item.update {
Update::Entry { entry, .. } if entry.is_timeline_noise() => {
info.apply(entry);
false
}
_ => true,
});
date_and_sort(&mut items);
(items, info, seed)
}
fn parse_file_into(path: &Path, source: Source, items: &mut Vec<ReplayItem>) -> u64 {
let Ok(bytes) = std::fs::read(path) else {
return 0;
};
let consumed = bytes
.iter()
.rposition(|&b| b == b'\n')
.map(|i| i + 1)
.unwrap_or(0);
let text = String::from_utf8_lossy(&bytes[..consumed]);
let mut last_ts: Option<DateTime<Utc>> = None;
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
let Some(entry) = transcript::parse_line(line) else {
continue;
};
let ts = entry_timestamp(&entry).or(last_ts);
if ts.is_some() {
last_ts = ts;
}
items.push(ReplayItem::at(
ts,
Update::Entry {
source: source.clone(),
entry,
},
));
}
consumed as u64
}
fn push_meta_item(
path: &Path,
agent_id: String,
workflow: Option<String>,
items: &mut Vec<ReplayItem>,
) -> bool {
let Ok(bytes) = std::fs::read(path) else {
return false;
};
let Ok(meta) = serde_json::from_slice::<SubagentMeta>(&bytes) else {
return false;
};
items.push(ReplayItem::at(
None,
Update::SubagentMeta {
agent_id,
workflow,
meta,
},
));
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn replay_dates_metas_to_first_subagent_entry() {
use std::io::Write;
let mut dir = std::env::temp_dir();
dir.push(format!("zoetrope_replay_order_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let session = "22222222-2222-2222-2222-222222222222";
let sub_dir = dir.join(session).join("subagents");
std::fs::create_dir_all(&sub_dir).unwrap();
let main = dir.join(format!("{session}.jsonl"));
std::fs::File::create(&main)
.unwrap()
.write_all(
concat!(
r#"{"type":"user","uuid":"u1","parentUuid":null,"timestamp":"2026-06-05T10:00:00.000Z","message":{"role":"user","content":"start"}}"#, "\n",
r#"{"type":"user","uuid":"u3","parentUuid":"u1","timestamp":"2026-06-05T10:02:00.000Z","message":{"role":"user","content":"later"}}"#, "\n",
)
.as_bytes(),
)
.unwrap();
std::fs::File::create(sub_dir.join("agent-aaaaaaaaaaaaaaaaa.jsonl"))
.unwrap()
.write_all(
concat!(
r#"{"type":"user","uuid":"s1","parentUuid":null,"isSidechain":true,"agentId":"aaaaaaaaaaaaaaaaa","timestamp":"2026-06-05T10:01:00.000Z","message":{"role":"user","content":"task"}}"#, "\n",
)
.as_bytes(),
)
.unwrap();
std::fs::File::create(sub_dir.join("agent-aaaaaaaaaaaaaaaaa.meta.json"))
.unwrap()
.write_all(br#"{"agentType":"guide","toolUseId":"t1"}"#)
.unwrap();
let (items, _info, _seed) = build_replay(&main);
let pos = |pred: &dyn Fn(&ReplayItem) -> bool| items.iter().position(pred);
let meta_pos = pos(&|i| matches!(&i.update, Update::SubagentMeta { .. })).unwrap();
let first_main = pos(&|i| {
matches!(
&i.update,
Update::Entry {
source: Source::Main,
..
}
)
})
.unwrap();
let sub_entry = pos(&|i| {
matches!(
&i.update,
Update::Entry {
source: Source::Sub(_),
..
}
)
})
.unwrap();
assert!(
items[meta_pos].ts().is_some(),
"meta must inherit a timestamp"
);
assert!(
first_main < meta_pos,
"agent must NOT spawn before main starts"
);
assert!(
meta_pos < sub_entry,
"meta must precede the agent's entries"
);
let _ = std::fs::remove_dir_all(&dir);
}
}