strop_engine/editor/trace/drive.rs
1//! The deterministic driver (R11): live drivers record one `Action` before
2//! the reducer or render work runs; replay consumes those actions and
3//! re-runs the SAME production handlers with native launches suppressed by
4//! the tape. Service interleaving is the consumption order at the single
5//! delivery boundary — never worker-completion wall time.
6use std::io;
7
8use serde::{Deserialize, Serialize};
9
10use crate::editor::events::AppEvent;
11use crate::editor::Editor;
12use strop_trace::replay::Tick;
13
14#[derive(Serialize, Deserialize)]
15pub struct StartupOpen {
16 pub target: crate::files::FileTarget,
17 pub intent: super::super::io::OpenIntent,
18}
19
20/// Every external step a replay reproduces. `Event` carries the shared
21/// typed `AppEvent` (terminal input, paste, resize, quit intent, and every
22/// completed worker/service result), so the live handler and the replayed
23/// handler are one function.
24#[derive(Serialize, Deserialize)]
25pub enum Action {
26 /// The explicit start-services step after the seed, with an optional owned
27 /// resource open. Local directories and remote resources share this path.
28 Start {
29 #[serde(default)]
30 open: Option<StartupOpen>,
31 },
32 /// One external delivery, recorded at handler entry — before stale
33 /// filters or acceptance decisions, so rejected results replay too.
34 Event(AppEvent),
35 /// One rendered frame: the dimensions are inputs, the canonical cell
36 /// observation is checked inside the shared draw.
37 Frame { columns: u16, rows: u16 },
38 /// Deliberate shutdown: session persistence is requested here and its
39 /// terminal publication must land as an Event before the tape's `End`.
40 Finish,
41}
42
43impl Editor {
44 /// Live drivers: record the action, then run the shared body.
45 pub fn recorded_action(&mut self, action: Action, tick: Tick) -> io::Result<()> {
46 self.tape.action(tick, &action)?;
47 self.apply_recorded(action)
48 }
49
50 /// The one body both modes execute. Native side effects are confined
51 /// to the tape-gated call sites inside these handlers.
52 pub(crate) fn apply_recorded(&mut self, action: Action) -> io::Result<()> {
53 let frame = matches!(action, Action::Frame { .. });
54 match action {
55 Action::Start { open } => {
56 self.resolution.enabled = true;
57 let startup_message = self.message.clone();
58 if let Some(open) = open {
59 self.lsp_start_services();
60 self.request_target(open.target, open.intent);
61 } else {
62 self.discover_git();
63 self.lsp_start_services();
64 }
65 if !startup_message.is_empty() {
66 self.message = startup_message;
67 }
68 }
69 Action::Event(event) => {
70 self.handle_app_event(event);
71 if !self.docs.is_empty() {
72 self.lsp_sync_changed();
73 }
74 // Logical OSC52 consumption is common to both modes; the
75 // actual escape write stays the terminal adapter's
76 // live-only effect, recorded as a request — never an
77 // AppEvent, never a silent drop in replay.
78 if let Some(text) = self.osc52.take() {
79 if self.tape.request("terminal.osc52", &text)? {
80 self.terminal_output.push(text);
81 }
82 }
83 }
84 Action::Frame { columns, rows } => {
85 if u32::from(columns) * u32::from(rows) > 1_000_000 {
86 return Err(io::Error::other("invalid frame dimensions"));
87 }
88 if !self.should_quit && !self.docs.is_empty() {
89 // Renders through the injected frame renderer: in
90 // replay that draw consumes the recorded cell
91 // observation. The binary installs the hook at its
92 // composition roots (0046).
93 let draw = self
94 .frame_draw
95 .ok_or_else(|| io::Error::other("frame draw hook not installed"))?;
96 draw(self, columns, rows, false)?;
97 }
98 }
99 Action::Finish => {
100 self.finish_background_work();
101 }
102 }
103 self.tape.healthy()?;
104 if !frame && self.tape.observes() {
105 // Frames check the canonical cell grid instead; every other
106 // action checks the full logical observation.
107 self.tape.check(&self.observation())?;
108 }
109 Ok(())
110 }
111
112 /// The logical observation both modes must reproduce bit-for-bit.
113 /// Document content arrives as the pure `BufferSeed` (text, revision,
114 /// history, disk baseline) — diagnostic trace identities and other
115 /// process-local ephemera are deliberately absent.
116 fn observation(&self) -> serde_json::Value {
117 let documents: Vec<_> = self
118 .docs
119 .iter()
120 .map(
121 |(id, document)| serde_json::json!({"document": id, "buffer": document.buf.seed()}),
122 )
123 .collect();
124 serde_json::json!({
125 "documents": documents,
126 "panes": self.panes,
127 "mru": self.mru,
128 "active": self.active_pane,
129 "mode": self.mode.chip(),
130 "quit": self.should_quit,
131 "message": self.message,
132 "headless": crate::editor::state_json(self),
133 "hover": self.hover_card,
134 "hunks": self.hunks,
135 "staged_hunks": self.staged_hunks,
136 "picker_items": self.picker.as_ref().map(|glue| &glue.picker.items),
137 })
138 }
139}
140
141/// Replay a recorded forensic node stream end to end. The seed is taken
142/// first, the pure editor is reconstructed, and every recorded action is
143/// consumed through the shared body until the deliberate `End`; any
144/// divergence, gap or unexpected residue is an error, never a warning.
145/// The frame renderer replay checks recorded cells against; installed by
146/// the caller since cell-grid production lives outside the engine.
147pub fn replay(
148 nodes: Vec<strop_trace::replay::Node>,
149 frame_draw: crate::editor::FrameDraw,
150) -> io::Result<Editor> {
151 let tape = std::rc::Rc::new(strop_trace::replay::Tape::replay(nodes));
152 let seed: super::seed::Seed = tape.take_seed()?;
153 let mut editor = seed.into_editor(tape)?;
154 editor.frame_draw = Some(frame_draw);
155 while let Some(action) = editor.tape.next::<Action>()? {
156 editor.apply_recorded(action)?;
157 }
158 editor.tape.healthy()?;
159 Ok(editor)
160}