Skip to main content

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}