1use 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
16const INTERRUPTED_AFTER: Duration = Duration::from_secs(60);
19
20pub struct RunEntry {
21 pub dir: PathBuf,
22 pub manifest: Manifest,
23 pub manifest_raw: Value,
27 pub workflow: Value,
28 pub state_raw: Value,
29 pub events: Vec<Value>,
30 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 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
51fn 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
62fn last_write_instant(paths: &BundlePaths) -> Instant {
66 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 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 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 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 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 fn refresh(&mut self) -> Option<Vec<PatchOp>> {
262 let mut patch: Vec<PatchOp> = Vec::new();
263
264 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 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 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 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: bool,
424}
425
426pub 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 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 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 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 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 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}