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