Skip to main content

piw/
source.rs

1//! The run source: reads bundles from a runs directory and maintains one
2//! semantic *run view* per run (see docs/live-replay-protocol.md), producing
3//! JSON patches as bundles grow. Both the in-process TUI and the WebSocket
4//! server consume this; the protocol is just its network form.
5
6use crate::bundle::reader::{list_bundles, read_manifest_value, BundlePaths};
7use crate::bundle::tail::NdjsonTailer;
8use crate::bundle::types::{DefinitionSnapshot, Manifest, RunState};
9use crate::protocol::PatchOp;
10use anyhow::Result;
11use serde_json::{json, Value};
12use std::collections::BTreeMap;
13use std::path::{Path, PathBuf};
14use std::time::{Duration, Instant};
15
16/// A run whose bundle stopped changing for this long while status is
17/// `running` is flagged as possibly interrupted (writer crashed).
18const INTERRUPTED_AFTER: Duration = Duration::from_secs(60);
19
20pub struct RunEntry {
21    pub dir: PathBuf,
22    pub manifest: Manifest,
23    /// Bundle documents verbatim, as sent over the wire. The raw manifest is
24    /// kept beside the typed one because views must carry it unmodified,
25    /// including forward-compatible fields this build does not know about.
26    pub manifest_raw: Value,
27    pub workflow: Value,
28    pub state_raw: Value,
29    pub events: Vec<Value>,
30    /// Tailed trace events whose `seq` is still ahead of `state.traceSeq`
31    /// (the writer appends the trace before rewriting the state).
32    pending_events: Vec<Value>,
33    pub session_binding: Option<Value>,
34    pub session_entries: Vec<Value>,
35    pub session_events: Vec<Value>,
36    pub session_events_malformed: bool,
37    pub session_events_torn_tail: bool,
38    pub session_capture: Option<Value>,
39    /// Typed forms for rendering.
40    pub state: RunState,
41    pub snapshot: Option<DefinitionSnapshot>,
42    pub live: bool,
43    pub possibly_interrupted: bool,
44    pub revision: u64,
45    trace_tailer: NdjsonTailer,
46    session_tailer: Option<NdjsonTailer>,
47    session_event_tailer: Option<NdjsonTailer>,
48    last_growth: Instant,
49}
50
51/// Parse and schema-check a state document; unsupported schemas are
52/// rejected so incompatible layouts never render.
53fn parse_state(raw: &str) -> Option<(Value, RunState)> {
54    let state_raw: Value = serde_json::from_str(raw).ok()?;
55    let state: RunState = serde_json::from_value(state_raw.clone()).ok()?;
56    if state.schema != crate::bundle::types::RUN_STATE_SCHEMA {
57        return None;
58    }
59    Some((state_raw, state))
60}
61
62/// The instant corresponding to the newest modification time among the
63/// bundle's mutable files, so a run that stalled before we started watching
64/// is flagged as possibly interrupted immediately.
65fn last_write_instant(paths: &BundlePaths) -> Instant {
66    // Appends to files inside session/ do not bump the directory mtime, so
67    // the session documents are listed individually: a run mid-conversation
68    // must not open as possibly interrupted.
69    let newest = [
70        Some(paths.state.clone()),
71        Some(paths.trace.clone()),
72        paths.session_binding(),
73        paths.session_entries(),
74        paths.session_events(),
75        paths.session_capture(),
76    ]
77    .into_iter()
78    .flatten()
79    .filter_map(|path| std::fs::metadata(path).ok())
80    .filter_map(|metadata| metadata.modified().ok())
81    .max();
82    let age = newest
83        .and_then(|mtime| std::time::SystemTime::now().duration_since(mtime).ok())
84        .unwrap_or_default();
85    Instant::now().checked_sub(age).unwrap_or_else(Instant::now)
86}
87
88impl RunEntry {
89    fn open(dir: &Path) -> Result<Self> {
90        let (manifest_raw, manifest) = read_manifest_value(dir)?;
91        let paths = BundlePaths::from_manifest(dir, &manifest);
92        let state_text = crate::bundle::reader::read_contained(dir, &paths.state)
93            .ok_or_else(|| anyhow::anyhow!("unreadable state in {}", dir.display()))?;
94        let (state_raw, state) = parse_state(&state_text)
95            .ok_or_else(|| anyhow::anyhow!("unsupported state schema in {}", dir.display()))?;
96        let workflow: Value = crate::bundle::reader::read_contained(dir, &paths.workflow)
97            .and_then(|raw| serde_json::from_str(&raw).ok())
98            .unwrap_or(Value::Null);
99        let snapshot: Option<DefinitionSnapshot> = serde_json::from_value(workflow.clone())
100            .ok()
101            .filter(|snapshot: &DefinitionSnapshot| {
102                snapshot.schema == crate::bundle::types::DEFINITION_SNAPSHOT_SCHEMA
103            });
104        let last_growth = last_write_instant(&paths);
105        let mut entry = Self {
106            dir: dir.to_path_buf(),
107            trace_tailer: NdjsonTailer::contained(&paths.trace, dir),
108            session_tailer: paths
109                .session_entries()
110                .map(|path| NdjsonTailer::contained(&path, dir)),
111            session_event_tailer: paths
112                .session_events()
113                .map(|path| NdjsonTailer::contained(&path, dir)),
114            manifest,
115            manifest_raw,
116            workflow,
117            state_raw,
118            events: Vec::new(),
119            pending_events: Vec::new(),
120            session_binding: None,
121            session_entries: Vec::new(),
122            session_events: Vec::new(),
123            session_events_malformed: false,
124            session_events_torn_tail: false,
125            session_capture: None,
126            state,
127            snapshot,
128            live: true,
129            possibly_interrupted: false,
130            revision: 0,
131            last_growth,
132        };
133        entry.pending_events = entry.trace_tailer.poll().unwrap_or_default();
134        entry.events = entry.drain_ready_events();
135        entry.read_session_binding();
136        if let Some(tailer) = entry.session_tailer.as_mut() {
137            entry.session_entries = tailer.poll().unwrap_or_default();
138        }
139        if let Some(tailer) = entry.session_event_tailer.as_mut() {
140            entry.session_events = tailer.poll().unwrap_or_default();
141            entry.session_events_malformed = tailer.malformed();
142            entry.session_events_torn_tail = tailer.has_partial_line();
143        }
144        entry.read_session_capture();
145        entry.live = !entry.settled();
146        entry.possibly_interrupted = entry.live
147            && entry.state.status == crate::bundle::types::RunStatus::Running
148            && entry.last_growth.elapsed() >= INTERRUPTED_AFTER;
149        Ok(entry)
150    }
151
152    /// A bundle is settled (immutable, safe to stop watching) only when the
153    /// terminal status has propagated through every document we track: a
154    /// terminal manifest alone can race a refresh that still holds the old
155    /// state or an undrained trace tail. The tail must also have reached the
156    /// state's `traceSeq`: the final append can land between our trace poll
157    /// and the terminal state read, and settling then would lose it.
158    fn settled(&self) -> bool {
159        self.manifest.status.is_terminal()
160            && self.state.status.is_terminal()
161            && self.pending_events.is_empty()
162            && self.last_seen_seq() >= self.state.trace_seq
163    }
164
165    /// Highest trace sequence this entry has observed (published or pending).
166    fn last_seen_seq(&self) -> u64 {
167        self.pending_events
168            .last()
169            .or_else(|| self.events.last())
170            .and_then(|event| event.get("seq").and_then(Value::as_u64))
171            .unwrap_or(0)
172    }
173
174    /// Take the pending trace events whose `seq` the state projection has
175    /// caught up with. Publishing a trace tail ahead of its state would make
176    /// the panes disagree mid-transition (trace is written first).
177    fn drain_ready_events(&mut self) -> Vec<Value> {
178        let ready_count = self
179            .pending_events
180            .iter()
181            .take_while(|event| {
182                event
183                    .get("seq")
184                    .and_then(Value::as_u64)
185                    .is_none_or(|seq| seq <= self.state.trace_seq)
186            })
187            .count();
188        self.pending_events.drain(..ready_count).collect()
189    }
190
191    fn read_session_binding(&mut self) {
192        if self.session_binding.is_some() {
193            return;
194        }
195        let paths = BundlePaths::from_manifest(&self.dir, &self.manifest);
196        if let Some(path) = paths.session_binding() {
197            if let Some(raw) = crate::bundle::reader::read_contained(&self.dir, &path) {
198                self.session_binding = serde_json::from_str(&raw).ok();
199            }
200        }
201        // The session directory can appear after the manifest was first
202        // written (it is recorded in manifest.paths from the start), so the
203        // tailer may need to be created late.
204        if self.session_tailer.is_none() {
205            self.session_tailer = paths
206                .session_entries()
207                .map(|path| NdjsonTailer::contained(&path, &self.dir));
208        }
209        if self.session_event_tailer.is_none() {
210            self.session_event_tailer = paths
211                .session_events()
212                .map(|path| NdjsonTailer::contained(&path, &self.dir));
213        }
214    }
215
216    fn read_session_capture(&mut self) {
217        let paths = BundlePaths::from_manifest(&self.dir, &self.manifest);
218        if let Some(path) = paths.session_capture() {
219            if let Some(raw) = crate::bundle::reader::read_contained(&self.dir, &path) {
220                self.session_capture = serde_json::from_str(&raw).ok();
221            }
222        }
223    }
224
225    fn session_value(&self) -> Value {
226        match &self.session_binding {
227            Some(binding) => json!({
228                "binding": binding,
229                "entries": self.session_entries,
230                "events": self.session_events,
231                "eventsMalformed": self.session_events_malformed,
232                "eventsTornTail": self.session_events_torn_tail,
233                "capture": self.session_capture,
234            }),
235            None => Value::Null,
236        }
237    }
238
239    pub fn view(&self) -> Value {
240        json!({
241            "manifest": self.manifest_raw,
242            "workflow": self.workflow,
243            "state": self.state_raw,
244            "events": self.events,
245            "session": self.session_value(),
246            "live": self.live,
247            "possiblyInterrupted": self.possibly_interrupted,
248        })
249    }
250
251    pub fn summary(&self) -> Value {
252        json!({
253            "manifest": self.manifest_raw,
254            "live": self.live,
255            "possiblyInterrupted": self.possibly_interrupted,
256        })
257    }
258
259    /// Re-read changed files and return the patch from the previous view to
260    /// the current one. `None` means nothing changed.
261    fn refresh(&mut self) -> Option<Vec<PatchOp>> {
262        let mut patch: Vec<PatchOp> = Vec::new();
263
264        // Tail the trace first, but publish only after the state below has
265        // been re-read: events past `state.traceSeq` wait in `pending_events`
266        // so a mid-transition read never shows a trace tail ahead of the
267        // projection.
268        let newly_polled = self.trace_tailer.poll().unwrap_or_default();
269        let mut trace_grew = !newly_polled.is_empty();
270        self.pending_events.extend(newly_polled);
271
272        let paths = BundlePaths::from_manifest(&self.dir, &self.manifest);
273        if let Some(raw) = crate::bundle::reader::read_contained(&self.dir, &paths.state) {
274            if let Some((state_raw, state)) = parse_state(&raw) {
275                if state_raw != self.state_raw {
276                    self.state = state;
277                    self.state_raw = state_raw;
278                    patch.push(PatchOp::Replace {
279                        path: "/state".into(),
280                        value: self.state_raw.clone(),
281                    });
282                }
283            }
284        }
285        // The writer appends the trace before rewriting the state, so a
286        // freshly read state can reference sequences that landed after the
287        // poll above; re-poll rather than wait for a change notification the
288        // finished writer will never produce again.
289        if self.state.status.is_terminal() && self.last_seen_seq() < self.state.trace_seq {
290            let late = self.trace_tailer.poll().unwrap_or_default();
291            trace_grew = trace_grew || !late.is_empty();
292            self.pending_events.extend(late);
293        }
294        let ready_events = self.drain_ready_events();
295        if !ready_events.is_empty() {
296            patch.push(PatchOp::Append {
297                path: "/events".into(),
298                value: ready_events.clone(),
299            });
300            self.events.extend(ready_events);
301        }
302        if let Ok((manifest_raw, manifest)) = read_manifest_value(&self.dir) {
303            // Compare the raw document: a change confined to a field this
304            // build does not know must still produce a patch.
305            if manifest_raw != self.manifest_raw {
306                self.manifest = manifest;
307                self.manifest_raw = manifest_raw;
308                patch.push(PatchOp::Replace {
309                    path: "/manifest".into(),
310                    value: self.manifest_raw.clone(),
311                });
312            }
313        }
314
315        let had_binding = self.session_binding.is_some();
316        let previous_capture = self.session_capture.clone();
317        let previous_events_malformed = self.session_events_malformed;
318        let previous_events_torn_tail = self.session_events_torn_tail;
319        self.read_session_binding();
320        // Tail session journals before publishing a newly discovered binding,
321        // so the first session value is already internally consistent.
322        let new_entries: Vec<Value> = self
323            .session_tailer
324            .as_mut()
325            .map(|tailer| tailer.poll().unwrap_or_default())
326            .unwrap_or_default();
327        let new_session_events: Vec<Value> = self
328            .session_event_tailer
329            .as_mut()
330            .map(|tailer| tailer.poll().unwrap_or_default())
331            .unwrap_or_default();
332        if let Some(tailer) = self.session_event_tailer.as_ref() {
333            self.session_events_malformed = tailer.malformed();
334            self.session_events_torn_tail = tailer.has_partial_line();
335        }
336        let session_grew = !new_entries.is_empty() || !new_session_events.is_empty();
337        self.session_entries.extend(new_entries.clone());
338        self.session_events.extend(new_session_events.clone());
339        self.read_session_capture();
340        let capture_changed = self.session_capture != previous_capture;
341        if !had_binding && self.session_binding.is_some() {
342            patch.push(PatchOp::Replace {
343                path: "/session".into(),
344                value: self.session_value(),
345            });
346        } else if self.session_binding.is_some() {
347            if !new_entries.is_empty() {
348                patch.push(PatchOp::Append {
349                    path: "/session/entries".into(),
350                    value: new_entries,
351                });
352            }
353            if !new_session_events.is_empty() {
354                patch.push(PatchOp::Append {
355                    path: "/session/events".into(),
356                    value: new_session_events,
357                });
358            }
359            if self.session_events_malformed != previous_events_malformed {
360                patch.push(PatchOp::Replace {
361                    path: "/session/eventsMalformed".into(),
362                    value: json!(self.session_events_malformed),
363                });
364            }
365            if self.session_events_torn_tail != previous_events_torn_tail {
366                patch.push(PatchOp::Replace {
367                    path: "/session/eventsTornTail".into(),
368                    value: json!(self.session_events_torn_tail),
369                });
370            }
371            if capture_changed {
372                patch.push(PatchOp::Replace {
373                    path: "/session/capture".into(),
374                    value: self.session_capture.clone().unwrap_or(Value::Null),
375                });
376            }
377        }
378
379        let session_integrity_changed = self.session_events_malformed != previous_events_malformed
380            || self.session_events_torn_tail != previous_events_torn_tail;
381        if !patch.is_empty()
382            || trace_grew
383            || session_grew
384            || session_integrity_changed
385            || capture_changed
386        {
387            self.last_growth = Instant::now();
388        }
389        let live = !self.settled();
390        if live != self.live {
391            self.live = live;
392            patch.push(PatchOp::Replace {
393                path: "/live".into(),
394                value: json!(live),
395            });
396        }
397        let possibly_interrupted = self.live
398            && self.state.status == crate::bundle::types::RunStatus::Running
399            && self.last_growth.elapsed() >= INTERRUPTED_AFTER;
400        if possibly_interrupted != self.possibly_interrupted {
401            self.possibly_interrupted = possibly_interrupted;
402            patch.push(PatchOp::Replace {
403                path: "/possiblyInterrupted".into(),
404                value: json!(possibly_interrupted),
405            });
406        }
407
408        if patch.is_empty() {
409            None
410        } else {
411            self.revision += 1;
412            Some(patch)
413        }
414    }
415}
416
417pub struct RunSource {
418    runs_dir: PathBuf,
419    runs: BTreeMap<String, RunEntry>,
420    /// Single-bundle mode: `runs_dir` is the bundle itself, so directory
421    /// scanning must not run (it would treat the bundle as an empty listing
422    /// and drop the run).
423    single: bool,
424}
425
426/// One refresh round: patches per changed run, and whether the listing
427/// (order, membership, summaries) changed.
428pub struct RefreshOutcome {
429    pub patches: Vec<(String, u64, Vec<PatchOp>)>,
430    pub listing_changed: bool,
431}
432
433impl RunSource {
434    pub fn new(runs_dir: &Path) -> Self {
435        let mut source = Self {
436            runs_dir: runs_dir.to_path_buf(),
437            runs: BTreeMap::new(),
438            single: false,
439        };
440        source.scan();
441        source
442    }
443
444    /// Open a source for a single bundle directory (no listing).
445    pub fn single(bundle_dir: &Path) -> Result<Self> {
446        let entry = RunEntry::open(bundle_dir)?;
447        let mut runs = BTreeMap::new();
448        let run_id = entry.manifest.run_id.clone();
449        runs.insert(run_id, entry);
450        Ok(Self {
451            runs_dir: bundle_dir.to_path_buf(),
452            runs,
453            single: true,
454        })
455    }
456
457    pub fn runs_dir(&self) -> &Path {
458        &self.runs_dir
459    }
460
461    pub fn get(&self, run_id: &str) -> Option<&RunEntry> {
462        self.runs.get(run_id)
463    }
464
465    /// Run ids ordered newest first (startedAt desc, then run id desc).
466    pub fn ordered_run_ids(&self) -> Vec<String> {
467        let mut ids: Vec<&RunEntry> = self.runs.values().collect();
468        ids.sort_by(|a, b| {
469            b.manifest
470                .started_at
471                .cmp(&a.manifest.started_at)
472                .then_with(|| b.manifest.run_id.cmp(&a.manifest.run_id))
473        });
474        ids.into_iter()
475            .map(|entry| entry.manifest.run_id.clone())
476            .collect()
477    }
478
479    pub fn summaries(&self) -> Vec<Value> {
480        self.ordered_run_ids()
481            .iter()
482            .filter_map(|id| self.runs.get(id))
483            .map(RunEntry::summary)
484            .collect()
485    }
486
487    /// Discover new bundles and drop deleted ones. Returns whether the run
488    /// listing membership changed. No-op in single-bundle mode.
489    pub fn scan(&mut self) -> bool {
490        if self.single {
491            return false;
492        }
493        let found = list_bundles(&self.runs_dir);
494        let mut changed = false;
495        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
496        for (dir, manifest) in found {
497            seen.insert(manifest.run_id.clone());
498            if !self.runs.contains_key(&manifest.run_id) {
499                if let Ok(entry) = RunEntry::open(&dir) {
500                    self.runs.insert(manifest.run_id.clone(), entry);
501                    changed = true;
502                }
503            }
504        }
505        let stale: Vec<String> = self
506            .runs
507            .keys()
508            .filter(|id| !seen.contains(*id))
509            .cloned()
510            .collect();
511        for id in stale {
512            self.runs.remove(&id);
513            changed = true;
514        }
515        changed
516    }
517
518    /// Rescan and refresh every run, collecting patches.
519    pub fn refresh_all(&mut self) -> RefreshOutcome {
520        let mut listing_changed = self.scan();
521        let mut patches = Vec::new();
522        for (run_id, entry) in self.runs.iter_mut() {
523            // Terminal bundles are immutable per the format contract; stop
524            // re-reading them (discovery of new runs still happens above).
525            if !entry.live {
526                continue;
527            }
528            let live_before = entry.live;
529            let interrupted_before = entry.possibly_interrupted;
530            let status_before = entry.manifest.status;
531            if let Some(patch) = entry.refresh() {
532                patches.push((run_id.clone(), entry.revision, patch));
533                if entry.live != live_before
534                    || entry.possibly_interrupted != interrupted_before
535                    || entry.manifest.status != status_before
536                {
537                    listing_changed = true;
538                }
539            }
540        }
541        RefreshOutcome {
542            patches,
543            listing_changed,
544        }
545    }
546}