Skip to main content

mermaid_cli/app/
recorder.rs

1//! `--record` / `--replay` support.
2//!
3//! The Elm/MVU architecture makes deterministic replay nearly free:
4//! if you capture every `Msg` the reducer sees, you can reconstruct
5//! the exact final `State` by folding over that log. This module
6//! implements both sides.
7//!
8//! Wire format: one JSON object per line (JSONL). Each object is
9//! `{ts, kind, body}`:
10//!   - `ts`: RFC3339 timestamp (for debugging, not replay).
11//!   - `kind`: `MsgKind` variant tag (matches `Msg::kind().into()`).
12//!   - `body`: best-effort structured payload from `record_msg_body`.
13//!
14//! Not every `Msg` field is safely serializable today — raw image
15//! bytes in `Paste::Image`, for example. Unsupported payloads are
16//! marked with `"recordable": false` and compact metadata. Replay is
17//! a best-effort reconstruction.
18//!
19//! For C6 this ships the on-disk shape + a `Recorder` type that
20//! writes; replay reading is available but opt-in (serialize
21//! support is wired on a subset of Msg variants that don't carry
22//! binary payloads). C9 rounds out coverage with the parity
23//! harness.
24
25use std::fs::{File, OpenOptions};
26use std::io::{BufRead, BufReader, BufWriter, Write};
27use std::path::{Path, PathBuf};
28
29use anyhow::{Context, Result};
30use chrono::Local;
31
32use crate::domain::{KeyCode, Msg, MsgKind, Paste, SlashCmd, ToolOutcome, TurnId};
33use crate::providers::{ProgressEvent, SubagentPhase};
34
35/// Append-only recorder. Writes one JSONL line per `Msg` the main
36/// loop chooses to log.
37pub struct Recorder {
38    writer: BufWriter<File>,
39    path: PathBuf,
40}
41
42impl Recorder {
43    /// Open `path` for append. Creates the file if it doesn't exist.
44    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
45        let path = path.into();
46        let file = OpenOptions::new()
47            .create(true)
48            .append(true)
49            .open(&path)
50            .with_context(|| format!("open {} for recording", path.display()))?;
51        Ok(Self {
52            writer: BufWriter::new(file),
53            path,
54        })
55    }
56
57    pub fn path(&self) -> &Path {
58        &self.path
59    }
60
61    /// Record a single `MsgKind` + optional body JSON. Meant for the
62    /// narrow subset of variants that survive round-trip. Full
63    /// Msg-graph coverage comes in C9.
64    pub fn record_kind(
65        &mut self,
66        kind: MsgKind,
67        turn: Option<TurnId>,
68        body: serde_json::Value,
69    ) -> Result<()> {
70        let entry = serde_json::json!({
71            "ts": Local::now().to_rfc3339(),
72            "kind": format!("{:?}", kind),
73            "turn": turn.map(|t| t.0),
74            "body": body,
75        });
76        writeln!(self.writer, "{}", entry).context("write jsonl line")?;
77        Ok(())
78    }
79
80    pub fn flush(&mut self) -> Result<()> {
81        self.writer.flush().context("flush recorder")
82    }
83}
84
85impl Drop for Recorder {
86    fn drop(&mut self) {
87        let _ = self.writer.flush();
88    }
89}
90
91/// Compact, best-effort JSON body for a reducer `Msg`.
92///
93/// This deliberately records useful payloads without claiming the full
94/// `Msg` graph can round-trip through serde today. Runtime-only or
95/// binary-heavy variants are marked explicitly so replay tooling can
96/// decide how to handle them.
97pub fn record_msg_body(msg: &Msg) -> serde_json::Value {
98    match msg {
99        Msg::Key(key) => serde_json::json!({
100            "code": key_code_body(key.code),
101            "modifiers": {
102                "ctrl": key.modifiers.ctrl,
103                "alt": key.modifiers.alt,
104                "shift": key.modifiers.shift,
105            },
106        }),
107        Msg::Paste(Paste::Text(text)) => serde_json::json!({
108            "type": "text",
109            "text": text,
110        }),
111        Msg::Paste(Paste::Image { bytes, format }) => serde_json::json!({
112            "recordable": false,
113            "reason": "binary paste image omitted",
114            "type": "image",
115            "format": format,
116            "size_bytes": bytes.len(),
117        }),
118        Msg::SubmitPrompt {
119            text,
120            attachment_ids,
121        } => serde_json::json!({
122            "text": text,
123            "attachment_ids": attachment_ids,
124        }),
125        Msg::Slash(cmd) => slash_body(cmd),
126        Msg::CancelTurn => serde_json::json!({}),
127        Msg::ConfirmAccepted => serde_json::json!({"accepted": true}),
128        Msg::ConfirmDeclined => serde_json::json!({"accepted": false}),
129        Msg::Quit => serde_json::json!({}),
130        Msg::RuntimeSignal(signal) => serde_json::json!({
131            "signal": signal.as_str(),
132        }),
133        Msg::StreamText { chunk, .. } => serde_json::json!({"chunk": chunk}),
134        Msg::StreamReasoning { chunk, .. } => serde_json::json!({
135            "text": chunk.text,
136            "signature": chunk.signature,
137        }),
138        Msg::StreamToolCall { call, .. } => serde_json::to_value(call)
139            .unwrap_or_else(|_| unsupported("tool call was not serializable")),
140        Msg::ContextUsageEstimated { snapshot, .. } => serde_json::json!({
141            "used_tokens": snapshot.used_tokens,
142            "max_tokens": snapshot.max_tokens,
143            "remaining_tokens": snapshot.remaining_tokens,
144            "used_percent": snapshot.used_percent,
145            "source": format!("{:?}", snapshot.source),
146            "breakdown": snapshot.breakdown.as_ref().map(|b| serde_json::json!({
147                "system_tokens": b.system_tokens,
148                "instructions_tokens": b.instructions_tokens,
149                "message_tokens": b.message_tokens,
150                "tool_schema_tokens": b.tool_schema_tokens,
151                "image_count": b.image_count,
152                "message_count": b.message_count,
153                "tool_count": b.tool_count,
154            })),
155        }),
156        Msg::CompactionFinished { result, .. } => serde_json::json!({
157            "id": result.record.id,
158            "trigger": result.record.trigger.as_str(),
159            "before_tokens": result.record.before_tokens,
160            "after_tokens": result.record.after_tokens,
161            "archived_message_count": result.record.archived_message_count,
162            "preserved_message_count": result.record.preserved_message_count,
163            "duration_secs": result.record.duration_secs,
164        }),
165        Msg::CompactionFailed {
166            trigger,
167            message,
168            kind,
169            ..
170        } => serde_json::json!({
171            "trigger": trigger.as_str(),
172            "message": message,
173            "kind": format!("{:?}", kind),
174        }),
175        Msg::StreamDone {
176            usage,
177            thinking_signature,
178            ..
179        } => serde_json::json!({
180            "usage": usage.as_ref().map(|u| serde_json::json!({
181                "prompt_tokens": u.prompt_tokens,
182                "completion_tokens": u.completion_tokens,
183                "total_tokens": u.total_tokens,
184                "cached_input_tokens": u.cached_input_tokens,
185                "cache_creation_input_tokens": u.cache_creation_input_tokens,
186                "reasoning_output_tokens": u.reasoning_output_tokens,
187                "source": format!("{:?}", u.source),
188            })),
189            "thinking_signature": thinking_signature,
190        }),
191        Msg::UpstreamError { error, .. } => serde_json::to_value(error)
192            .unwrap_or_else(|_| unsupported("upstream error was not serializable")),
193        Msg::TurnCancelled(_) => serde_json::json!({}),
194        Msg::ToolStarted { call_id, .. } => serde_json::json!({"call_id": call_id.0}),
195        Msg::ToolProgress { call_id, event, .. } => serde_json::json!({
196            "call_id": call_id.0,
197            "event": progress_body(event),
198        }),
199        Msg::ToolFinished {
200            call_id, outcome, ..
201        } => serde_json::json!({
202            "call_id": call_id.0,
203            "outcome": outcome_body(outcome),
204        }),
205        Msg::McpServerReady { name, tools } => serde_json::json!({
206            "name": name,
207            "tools": tools.iter().map(|tool| serde_json::json!({
208                "name": tool.name,
209                "description": tool.description,
210                "input_schema": tool.input_schema,
211            })).collect::<Vec<_>>(),
212        }),
213        Msg::McpServerErrored { name, reason } => serde_json::json!({
214            "name": name,
215            "reason": reason,
216        }),
217        Msg::McpServerStopped { name } => serde_json::json!({"name": name}),
218        Msg::InstructionsChanged(loaded) => match loaded {
219            Some(loaded) => serde_json::json!({
220                "path": loaded.path,
221                "byte_len": loaded.byte_len,
222                "truncated": loaded.truncated,
223            }),
224            None => serde_json::json!({"path": null}),
225        },
226        Msg::SessionSaved => serde_json::json!({}),
227        Msg::ConversationLoaded(history) => serde_json::json!({
228            "id": history.id,
229            "message_count": history.messages.len(),
230            "title": history.title,
231        }),
232        Msg::ConversationsListed(summaries) => serde_json::json!({
233            "count": summaries.len(),
234            "ids": summaries.iter().map(|summary| summary.id.as_str()).collect::<Vec<_>>(),
235        }),
236        Msg::ModelPullFinished { model } => serde_json::json!({"model": model}),
237        Msg::ModelPullProgress(line) => serde_json::json!({"line": line}),
238        Msg::Tick => serde_json::json!({}),
239        Msg::StatusDismiss => serde_json::json!({}),
240        Msg::Resize { width, height } => serde_json::json!({
241            "width": width,
242            "height": height,
243        }),
244        Msg::TransientStatus {
245            text,
246            kind,
247            dismiss_ms,
248        } => serde_json::json!({
249            "text": text,
250            "kind": format!("{:?}", kind),
251            "dismiss_ms": dismiss_ms,
252        }),
253        Msg::MouseScroll { delta } => serde_json::json!({"delta": delta}),
254        Msg::OpenImageAt {
255            message_index,
256            image_index,
257        } => serde_json::json!({
258            "message_index": message_index,
259            "image_index": image_index,
260        }),
261    }
262}
263
264fn key_code_body(code: KeyCode) -> serde_json::Value {
265    match code {
266        KeyCode::Char(c) => serde_json::json!({"char": c.to_string()}),
267        KeyCode::F(n) => serde_json::json!({"f": n}),
268        other => serde_json::json!(format!("{:?}", other)),
269    }
270}
271
272fn slash_body(cmd: &SlashCmd) -> serde_json::Value {
273    match cmd {
274        SlashCmd::Model(model) => serde_json::json!({"command": "model", "arg": model}),
275        SlashCmd::Reasoning(level) => serde_json::json!({
276            "command": "reasoning",
277            "arg": level.map(|level| level.as_str()),
278        }),
279        SlashCmd::Clear => serde_json::json!({"command": "clear"}),
280        SlashCmd::Save(name) => serde_json::json!({"command": "save", "arg": name}),
281        SlashCmd::Load(name) => serde_json::json!({"command": "load", "arg": name}),
282        SlashCmd::List => serde_json::json!({"command": "list"}),
283        SlashCmd::Usage => serde_json::json!({"command": "usage"}),
284        SlashCmd::Context => serde_json::json!({"command": "context"}),
285        SlashCmd::Compact(instructions) => {
286            serde_json::json!({"command": "compact", "arg": instructions})
287        },
288        SlashCmd::CloudSetup => serde_json::json!({"command": "cloud-setup"}),
289        SlashCmd::Help => serde_json::json!({"command": "help"}),
290        SlashCmd::Quit => serde_json::json!({"command": "quit"}),
291        SlashCmd::Unknown(name) => serde_json::json!({"command": "unknown", "name": name}),
292    }
293}
294
295fn progress_body(event: &ProgressEvent) -> serde_json::Value {
296    match event {
297        ProgressEvent::Output(text) => serde_json::json!({"type": "output", "text": text}),
298        ProgressEvent::Status(text) => serde_json::json!({"type": "status", "text": text}),
299        ProgressEvent::Bytes { done, total } => serde_json::json!({
300            "type": "bytes",
301            "done": done,
302            "total": total,
303        }),
304        ProgressEvent::Artifact {
305            mime,
306            data,
307            caption,
308        } => serde_json::json!({
309            "recordable": false,
310            "type": "artifact",
311            "mime": mime,
312            "caption": caption,
313            "size_bytes": data.len(),
314        }),
315        ProgressEvent::SubagentToolCall {
316            child_call_id,
317            tool_name,
318            phase,
319        } => serde_json::json!({
320            "type": "subagent_tool_call",
321            "child_call_id": child_call_id.0,
322            "tool_name": tool_name,
323            "phase": subagent_phase(*phase),
324        }),
325        ProgressEvent::SubagentText(text) => {
326            serde_json::json!({"type": "subagent_text", "text": text})
327        },
328    }
329}
330
331fn subagent_phase(phase: SubagentPhase) -> &'static str {
332    match phase {
333        SubagentPhase::Started => "started",
334        SubagentPhase::Finished => "finished",
335        SubagentPhase::Errored => "errored",
336    }
337}
338
339fn outcome_body(outcome: &ToolOutcome) -> serde_json::Value {
340    serde_json::json!({
341        "status": match outcome.status {
342            crate::domain::ToolStatus::Success => "success",
343            crate::domain::ToolStatus::Error => "error",
344            crate::domain::ToolStatus::Cancelled => "cancelled",
345        },
346        "summary": outcome.summary,
347        "model_content": outcome.model_content,
348        "error": outcome.error,
349        "image_count": outcome.images().map(|images| images.len()).unwrap_or(0),
350        "duration_secs": outcome.duration_secs,
351        "metadata": outcome.metadata,
352        "artifacts": outcome.artifacts,
353    })
354}
355
356fn unsupported(reason: &str) -> serde_json::Value {
357    serde_json::json!({
358        "recordable": false,
359        "reason": reason,
360    })
361}
362
363/// Read a JSONL log back. Iterates one line at a time so a huge
364/// replay doesn't allocate the whole file upfront.
365pub struct Replay {
366    lines: std::io::Lines<BufReader<File>>,
367    path: PathBuf,
368}
369
370impl Replay {
371    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
372        let path = path.into();
373        let file =
374            File::open(&path).with_context(|| format!("open {} for replay", path.display()))?;
375        Ok(Self {
376            lines: BufReader::new(file).lines(),
377            path,
378        })
379    }
380
381    pub fn path(&self) -> &Path {
382        &self.path
383    }
384}
385
386impl Iterator for Replay {
387    type Item = Result<ReplayEntry>;
388
389    fn next(&mut self) -> Option<Self::Item> {
390        let line = self.lines.next()?;
391        Some(match line {
392            Ok(raw) => serde_json::from_str::<ReplayEntry>(&raw)
393                .with_context(|| format!("parse replay line: {}", raw)),
394            Err(e) => Err(anyhow::Error::from(e)),
395        })
396    }
397}
398
399/// Parsed JSONL entry. Fields mirror what `Recorder::record_kind`
400/// writes.
401#[derive(Debug, serde::Serialize, serde::Deserialize)]
402pub struct ReplayEntry {
403    pub ts: String,
404    pub kind: String,
405    pub turn: Option<u64>,
406    pub body: serde_json::Value,
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    fn tmpfile(name: &str) -> PathBuf {
414        let dir = std::env::temp_dir().join("mermaid_recorder_tests");
415        let _ = std::fs::create_dir_all(&dir);
416        dir.join(name)
417    }
418
419    #[test]
420    fn record_and_replay_roundtrip() {
421        let path = tmpfile("roundtrip.jsonl");
422        let _ = std::fs::remove_file(&path);
423
424        {
425            let mut r = Recorder::open(&path).expect("open");
426            r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
427                .expect("record");
428            r.record_kind(
429                MsgKind::SubmitPrompt,
430                None,
431                serde_json::json!({"text": "hello"}),
432            )
433            .expect("record");
434            r.record_kind(
435                MsgKind::StreamText,
436                Some(TurnId(7)),
437                serde_json::json!({"chunk": "partial"}),
438            )
439            .expect("record");
440            r.flush().expect("flush");
441        }
442
443        let replay = Replay::open(&path).expect("open replay");
444        let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
445        assert_eq!(entries.len(), 3);
446        assert_eq!(entries[0].kind, "Tick");
447        assert_eq!(entries[1].body["text"], "hello");
448        assert_eq!(entries[2].turn, Some(7));
449
450        let _ = std::fs::remove_file(&path);
451    }
452
453    #[test]
454    fn replay_parses_malformed_line_as_err() {
455        let path = tmpfile("bad.jsonl");
456        std::fs::write(&path, "not-json\n").expect("write");
457        let mut replay = Replay::open(&path).expect("open");
458        let first = replay.next().expect("first entry");
459        assert!(first.is_err());
460        let _ = std::fs::remove_file(&path);
461    }
462
463    #[test]
464    fn record_creates_file_on_open() {
465        let path = tmpfile("creates.jsonl");
466        let _ = std::fs::remove_file(&path);
467        assert!(!path.exists());
468        let _ = Recorder::open(&path).expect("open");
469        assert!(path.exists());
470        let _ = std::fs::remove_file(&path);
471    }
472
473    #[test]
474    fn record_append_preserves_existing_lines() {
475        let path = tmpfile("append.jsonl");
476        let _ = std::fs::remove_file(&path);
477        {
478            let mut r = Recorder::open(&path).expect("open");
479            r.record_kind(MsgKind::Tick, None, serde_json::json!({}))
480                .expect("record");
481        }
482        {
483            let mut r = Recorder::open(&path).expect("reopen");
484            r.record_kind(MsgKind::Quit, None, serde_json::json!({}))
485                .expect("record");
486        }
487        let replay = Replay::open(&path).expect("replay");
488        let entries: Vec<_> = replay.collect::<Result<_>>().expect("all parse");
489        assert_eq!(entries.len(), 2);
490        assert_eq!(entries[0].kind, "Tick");
491        assert_eq!(entries[1].kind, "Quit");
492        let _ = std::fs::remove_file(&path);
493    }
494
495    #[test]
496    fn record_msg_body_submit_prompt_keeps_text_and_attachments() {
497        let body = record_msg_body(&crate::domain::Msg::SubmitPrompt {
498            text: "explain main.rs".to_string(),
499            attachment_ids: vec![3, 9],
500        });
501        assert_eq!(body["text"], "explain main.rs");
502        assert_eq!(body["attachment_ids"][0], 3);
503        assert_eq!(body["attachment_ids"][1], 9);
504    }
505
506    #[test]
507    fn record_msg_body_slash_model_keeps_command_and_arg() {
508        let body = record_msg_body(&crate::domain::Msg::Slash(crate::domain::SlashCmd::Model(
509            Some("anthropic/opus".to_string()),
510        )));
511        assert_eq!(body["command"], "model");
512        assert_eq!(body["arg"], "anthropic/opus");
513    }
514
515    #[test]
516    fn record_msg_body_runtime_signal_keeps_signal_name() {
517        let body = record_msg_body(&crate::domain::Msg::RuntimeSignal(
518            crate::domain::RuntimeSignal::Terminate,
519        ));
520        assert_eq!(body["signal"], "terminate");
521    }
522
523    #[test]
524    fn record_msg_body_marks_binary_paste_image_unrecordable() {
525        let body = record_msg_body(&crate::domain::Msg::Paste(crate::domain::Paste::Image {
526            bytes: vec![1, 2, 3],
527            format: "png".to_string(),
528        }));
529        assert_eq!(body["recordable"], false);
530        assert_eq!(body["type"], "image");
531        assert_eq!(body["size_bytes"], 3);
532    }
533}