Skip to main content

somatize_runtime/tracking/
reader.rs

1//! Read-side of run tracking: list run directories and aggregate their
2//! logs into chart-ready data.
3//!
4//! [`RunReader`] is the reader counterpart of [`LocalTracker`](super::LocalTracker):
5//! it consumes the files a tracker writes (`manifest.json`, `status.json`,
6//! `events.jsonl`, `metrics.jsonl`, `study.json`) and never writes anything.
7//! Every aggregate it produces is a plain serde struct so the same shapes
8//! serve Python bindings, CLI output, and any future front-end.
9//!
10//! Wall-clock times come from the envelope `ts` stamped by the sink at
11//! emit time (sinks are synchronous); start events themselves carry no
12//! timestamp. Unparseable lines — a torn tail from a crash, or an event
13//! kind written by a newer soma — are skipped, never an error.
14
15use crate::study_io::StudyIo;
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use somatize_core::error::{Result, SomaError};
19use somatize_core::event::Event;
20use somatize_core::graph::Graph;
21use somatize_core::study::{Study, TrialState};
22use somatize_core::tracking::{EventEnvelope, RunManifest, RunState, RunStatus};
23use somatize_core::viz::{GraphOverlay, NodeStatus};
24use std::collections::BTreeMap;
25use std::fs;
26use std::io::{BufRead, BufReader};
27use std::path::{Path, PathBuf};
28
29use super::local_tracker::{load_manifest, load_status};
30
31/// A `Running` status whose heartbeat is older than this is reported as
32/// crashed: the process died without finalizing.
33pub const STALE_HEARTBEAT_SECS: i64 = 300;
34
35/// Reader over one run directory.
36pub struct RunReader {
37    dir: PathBuf,
38}
39
40/// Listing entry for one run: manifest identity plus derived liveness.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct RunInfo {
43    /// Run identifier from the manifest; also tags every event of the run.
44    pub run_id: String,
45    /// Manifest `kind` as its snake_case string (`fit`, `train`, `study`, …).
46    pub kind: String,
47    /// Human-readable name from the manifest.
48    pub name: String,
49    /// `running` | `completed` | `failed` | `crashed`.
50    pub state: String,
51    /// When the run directory was created.
52    pub created_at: DateTime<Utc>,
53    /// When the run finalized, if it did.
54    pub finished_at: Option<DateTime<Utc>>,
55    /// Wall time from creation to finish, when finished.
56    pub duration_ms: Option<u64>,
57    /// Free-form tags from the manifest.
58    pub tags: Vec<String>,
59    /// Absolute path of the run directory.
60    pub dir: String,
61}
62
63/// One execution span of a node, in event order. A node appears once
64/// per execution (re-runs and stream chunks produce separate spans).
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66pub struct NodeSpan {
67    /// Graph node this span belongs to.
68    pub node_id: String,
69    /// Envelope timestamp of the opening event.
70    pub started_ts: Option<DateTime<Utc>>,
71    /// Envelope timestamp of the closing event.
72    pub finished_ts: Option<DateTime<Utc>>,
73    /// Wall time between the two envelope timestamps.
74    pub duration_ms: Option<u64>,
75    /// `completed` | `failed` | `cache_hit` | `running`.
76    pub outcome: String,
77    /// Cache tier that served a hit (`memory`, `local`, …).
78    pub cache_tier: Option<String>,
79    /// Failure message when `outcome` is `failed`.
80    pub error: Option<String>,
81    /// The node was a step (from `NodeStarted`); defaults for spans
82    /// reconstructed from logs that predate the field.
83    #[serde(default)]
84    pub effectful: bool,
85}
86
87/// Per-run cache effectiveness, reconstructed from hit/miss events.
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct CacheActivity {
90    /// Cache hits across the whole run.
91    pub hits: u64,
92    /// Cache misses across the whole run.
93    pub misses: u64,
94    /// Per-node breakdown, keyed by node id.
95    pub by_node: BTreeMap<String, NodeCacheCounts>,
96}
97
98/// One node's share of [`CacheActivity`].
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100pub struct NodeCacheCounts {
101    /// Hits recorded for this node.
102    pub hits: u64,
103    /// Misses recorded for this node.
104    pub misses: u64,
105    /// Tier that served the most recent hit (`memory`, `local`, …).
106    pub last_tier: Option<String>,
107}
108
109/// One line of `metrics.jsonl` (also derivable from events).
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct MetricPoint {
112    /// Wall time the point was logged.
113    pub ts: DateTime<Utc>,
114    /// Metric name (`loss`, `accuracy`, …).
115    pub name: String,
116    /// The recorded scalar.
117    pub value: f64,
118    /// Logger-supplied step index within the run.
119    pub step: u64,
120    /// Owning trial, when logged inside a study.
121    #[serde(default)]
122    pub trial_id: Option<String>,
123    /// Emitting node, when the metric came from inside a node.
124    #[serde(default)]
125    pub node_id: Option<String>,
126}
127
128/// One `HealthFlag` event with its wall time.
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct HealthFlagRecord {
131    /// Wall time the flag was raised.
132    pub ts: DateTime<Utc>,
133    /// Node the flag fired on — hierarchical (`node/module.path`) when it
134    /// came from an intra-node audit hook.
135    pub node_id: String,
136    /// Training step at which the flag fired.
137    pub step: usize,
138    /// Flag family name, as emitted by the audit.
139    pub flag: String,
140    /// Human-readable description of what was detected.
141    pub detail: String,
142}
143
144/// Agent-level activity for one run, aggregated from the step events
145/// (`AgentTurnStarted`, `EffectCompleted`, `ToolCalled`, `Suspended`,
146/// `AgentStepCompleted`, …). Empty `by_node` means the run had no agent
147/// steps — or predates their telemetry.
148#[derive(Debug, Clone, Default, Serialize, Deserialize)]
149pub struct AgenticActivity {
150    /// Agent turns across all step nodes.
151    pub turns: u64,
152    /// Prompt tokens consumed across all step nodes.
153    pub input_tokens: u64,
154    /// Completion tokens produced across all step nodes.
155    pub output_tokens: u64,
156    /// Effects performed or replayed across all step nodes.
157    pub effects: u64,
158    /// Of `effects`, how many were served from the journal (a resumed
159    /// or replayed run should be nearly all replays).
160    pub replayed: u64,
161    /// Tool invocations across all step nodes.
162    pub tool_calls: u64,
163    /// Step nodes that completed successfully.
164    pub steps_completed: u64,
165    /// Step nodes that completed failed.
166    pub steps_failed: u64,
167    /// `Suspended` transitions observed across the run.
168    pub suspensions: u64,
169    /// Per-step-node breakdown, keyed by node id.
170    pub by_node: BTreeMap<String, AgentNodeActivity>,
171}
172
173/// One step node's share of the run's agentic work. Spawned instances
174/// appear under their own hierarchical ids (`parent/label`).
175///
176/// Token, turn and duration totals come from the accounting events
177/// (`AgentStepCompleted`, whose totals are cumulative, or the cost a
178/// `Suspended` carries when nothing completed after it) — never by
179/// summing both, which would double-count a resumed run.
180#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct AgentNodeActivity {
182    /// Agent turns this node ran.
183    pub turns: u64,
184    /// Prompt tokens this node consumed.
185    pub input_tokens: u64,
186    /// Completion tokens this node produced.
187    pub output_tokens: u64,
188    /// Wall time from the accounting events (see the type docs).
189    pub duration_ms: u64,
190    /// Effects this node performed or replayed.
191    pub effects: u64,
192    /// Effect counts by label (`llm:<model>`, `tool:<name>`, …).
193    pub effects_by_label: BTreeMap<String, u64>,
194    /// Effects that completed carrying an error result.
195    pub effect_errors: u64,
196    /// Of `effects`, how many were served from the journal.
197    pub replayed: u64,
198    /// Tool invocations this node made.
199    pub tool_calls: u64,
200    /// Tool invocations that returned an error.
201    pub tool_errors: u64,
202    /// Control handoffs this node emitted to other nodes.
203    pub handoffs_out: u64,
204    /// Times this node suspended awaiting external input.
205    pub suspensions: u64,
206    /// Instances this node fanned out (sum over its `AgentSpawned`s).
207    pub spawned: u64,
208    /// `AgentStepCompleted` events with `failed: false` / `true`.
209    pub completions: u64,
210    /// Completions that reported `failed: true`.
211    pub failures: u64,
212}
213
214/// One effect's execution inside a step — the gantt substrate for
215/// agent runs, the per-effect analogue of [`NodeSpan`]. An unclosed
216/// span (`outcome: "running"`) means the run died mid-effect.
217#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
218pub struct EffectSpan {
219    /// Step node the effect ran inside.
220    pub node_id: String,
221    /// Turn index within the step's loop.
222    pub turn: usize,
223    /// `Effect::label()` — e.g. `llm:qwen2.5:14b`, `tool:search`.
224    pub effect: String,
225    /// Envelope timestamp of the opening event.
226    pub started_ts: Option<DateTime<Utc>>,
227    /// Envelope timestamp of the closing event.
228    pub finished_ts: Option<DateTime<Utc>>,
229    /// Wall time between the two envelope timestamps.
230    pub duration_ms: Option<u64>,
231    /// Served from the journal instead of being performed.
232    pub replayed: bool,
233    /// The effect completed carrying an error result.
234    pub is_error: bool,
235    /// `completed` | `running`.
236    pub outcome: String,
237}
238
239/// One trial's lifetime, from `study.json`.
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct TrialSpan {
242    /// Trial identifier from `study.json`.
243    pub trial_id: String,
244    /// `completed` | `pruned` | `failed` | `running` | `pending`.
245    pub state: String,
246    /// When the trial started, if it did.
247    pub started_at: Option<DateTime<Utc>>,
248    /// When the trial finished, if it did.
249    pub finished_at: Option<DateTime<Utc>>,
250    /// Wall time between the two, when both are known.
251    pub duration_ms: Option<u64>,
252}
253
254impl RunReader {
255    /// Open a run directory. Fails only if the manifest is missing or
256    /// unreadable — everything else is tolerated per-file.
257    pub fn open(run_dir: impl AsRef<Path>) -> Result<Self> {
258        let dir = run_dir.as_ref().to_path_buf();
259        load_manifest(&dir)?;
260        Ok(Self { dir })
261    }
262
263    /// The run directory this reader was opened on.
264    pub fn dir(&self) -> &Path {
265        &self.dir
266    }
267
268    /// Parse `manifest.json` — the run's immutable identity record.
269    pub fn manifest(&self) -> Result<RunManifest> {
270        load_manifest(&self.dir)
271    }
272
273    /// Parse `status.json` — the latest state + heartbeat snapshot.
274    pub fn status(&self) -> Result<RunStatus> {
275        load_status(&self.dir)
276    }
277
278    /// Listing entry for this run (state includes crash detection).
279    pub fn info(&self) -> Result<RunInfo> {
280        let manifest = self.manifest()?;
281        Ok(run_info(
282            &self.dir,
283            manifest,
284            self.status().ok(),
285            Utc::now(),
286        ))
287    }
288
289    /// All parseable event envelopes, in log order. Torn or unknown
290    /// lines are skipped; `seq` gaps let a consumer detect the skips.
291    pub fn events(&self) -> Result<Vec<EventEnvelope>> {
292        let path = self.dir.join("events.jsonl");
293        let file = match fs::File::open(&path) {
294            Ok(f) => f,
295            Err(_) => return Ok(Vec::new()), // no events yet
296        };
297        let mut envelopes = Vec::new();
298        for line in BufReader::new(file).lines() {
299            let line = line.map_err(SomaError::Io)?;
300            if line.trim().is_empty() {
301                continue;
302            }
303            if let Ok(env) = serde_json::from_str::<EventEnvelope>(&line) {
304                envelopes.push(env);
305            }
306        }
307        Ok(envelopes)
308    }
309
310    /// Per-node execution spans in event order — the gantt/overlay
311    /// substrate. Cache hits are standalone spans (a hit node never
312    /// starts); an unclosed span means the run died mid-node.
313    pub fn node_timings(&self) -> Result<Vec<NodeSpan>> {
314        let mut spans: Vec<NodeSpan> = Vec::new();
315        let mut open: BTreeMap<String, usize> = BTreeMap::new();
316        for env in self.events()? {
317            match env.event {
318                Event::NodeStarted {
319                    node_id, effectful, ..
320                } => {
321                    open.insert(node_id.clone(), spans.len());
322                    spans.push(NodeSpan {
323                        node_id,
324                        started_ts: Some(env.ts),
325                        finished_ts: None,
326                        duration_ms: None,
327                        outcome: "running".into(),
328                        cache_tier: None,
329                        error: None,
330                        effectful,
331                    });
332                }
333                Event::NodeCacheHit {
334                    node_id,
335                    tier,
336                    load_time,
337                    ..
338                } => {
339                    spans.push(NodeSpan {
340                        node_id,
341                        started_ts: Some(env.ts),
342                        finished_ts: Some(env.ts),
343                        duration_ms: Some(load_time.as_millis() as u64),
344                        outcome: "cache_hit".into(),
345                        cache_tier: Some(format!("{tier:?}").to_lowercase()),
346                        error: None,
347                        effectful: false,
348                    });
349                }
350                Event::NodeCompleted {
351                    node_id, duration, ..
352                } => {
353                    let idx = open.remove(&node_id);
354                    let span = match idx {
355                        Some(i) => &mut spans[i],
356                        None => {
357                            spans.push(NodeSpan {
358                                node_id: node_id.clone(),
359                                started_ts: None,
360                                finished_ts: None,
361                                duration_ms: None,
362                                outcome: String::new(),
363                                cache_tier: None,
364                                error: None,
365                                effectful: false,
366                            });
367                            spans.last_mut().expect("just pushed")
368                        }
369                    };
370                    span.finished_ts = Some(env.ts);
371                    span.duration_ms = Some(duration.as_millis() as u64);
372                    span.outcome = "completed".into();
373                }
374                Event::NodeFailed { node_id, error, .. } => {
375                    let idx = open.remove(&node_id);
376                    let span = match idx {
377                        Some(i) => &mut spans[i],
378                        None => {
379                            spans.push(NodeSpan {
380                                node_id: node_id.clone(),
381                                started_ts: None,
382                                finished_ts: None,
383                                duration_ms: None,
384                                outcome: String::new(),
385                                cache_tier: None,
386                                error: None,
387                                effectful: false,
388                            });
389                            spans.last_mut().expect("just pushed")
390                        }
391                    };
392                    span.finished_ts = Some(env.ts);
393                    span.outcome = "failed".into();
394                    span.error = Some(error);
395                }
396                _ => {}
397            }
398        }
399        Ok(spans)
400    }
401
402    /// Cache hit/miss counts, total and per node.
403    pub fn cache_activity(&self) -> Result<CacheActivity> {
404        let mut activity = CacheActivity::default();
405        for env in self.events()? {
406            match env.event {
407                Event::NodeCacheHit { node_id, tier, .. } => {
408                    activity.hits += 1;
409                    let counts = activity.by_node.entry(node_id).or_default();
410                    counts.hits += 1;
411                    counts.last_tier = Some(format!("{tier:?}").to_lowercase());
412                }
413                Event::NodeCacheMiss { node_id, .. } => {
414                    activity.misses += 1;
415                    activity.by_node.entry(node_id).or_default().misses += 1;
416                }
417                _ => {}
418            }
419        }
420        Ok(activity)
421    }
422
423    /// Metric time series, optionally filtered by name. Reads the flat
424    /// `metrics.jsonl` tee; falls back to deriving the same points from
425    /// `events.jsonl` when the tee is absent.
426    pub fn metric_series(&self, name: Option<&str>) -> Result<Vec<MetricPoint>> {
427        let path = self.dir.join("metrics.jsonl");
428        let mut points: Vec<MetricPoint> = Vec::new();
429        if let Ok(file) = fs::File::open(&path) {
430            for line in BufReader::new(file).lines() {
431                let line = line.map_err(SomaError::Io)?;
432                if line.trim().is_empty() {
433                    continue;
434                }
435                if let Ok(p) = serde_json::from_str::<MetricPoint>(&line) {
436                    points.push(p);
437                }
438            }
439        } else {
440            for env in self.events()? {
441                match env.event {
442                    Event::TrialMetric {
443                        trial_id, metric, ..
444                    } => points.push(MetricPoint {
445                        ts: metric.timestamp,
446                        name: metric.name,
447                        value: metric.value,
448                        step: metric.step as u64,
449                        trial_id: Some(trial_id),
450                        node_id: None,
451                    }),
452                    Event::MetricReported {
453                        metric,
454                        node_id,
455                        trial_id,
456                        ..
457                    } => points.push(MetricPoint {
458                        ts: metric.timestamp,
459                        name: metric.name,
460                        value: metric.value,
461                        step: metric.step as u64,
462                        trial_id,
463                        node_id,
464                    }),
465                    _ => {}
466                }
467            }
468        }
469        if let Some(name) = name {
470            points.retain(|p| p.name == name);
471        }
472        Ok(points)
473    }
474
475    /// All `HealthFlag` events with wall time.
476    pub fn health_flags(&self) -> Result<Vec<HealthFlagRecord>> {
477        let mut flags = Vec::new();
478        for env in self.events()? {
479            if let Event::HealthFlag {
480                node_id,
481                step,
482                flag,
483                detail,
484                ..
485            } = env.event
486            {
487                flags.push(HealthFlagRecord {
488                    ts: env.ts,
489                    node_id,
490                    step,
491                    flag,
492                    detail,
493                });
494            }
495        }
496        Ok(flags)
497    }
498
499    /// Agent-level activity, aggregated per step node.
500    ///
501    /// Accounting rule: `AgentStepCompleted` totals are authoritative
502    /// (they are cumulative — a resumed run re-counts its replayed
503    /// effects). A `Suspended` cost stands in only until a later
504    /// completion for the same node supersedes it, and turn counts seen
505    /// on the wire (`AgentTurnStarted`) are used only for nodes that
506    /// died without any accounting event at all.
507    pub fn agentic_activity(&self) -> Result<AgenticActivity> {
508        #[derive(Default)]
509        struct Pending {
510            turns: u64,
511            input_tokens: u64,
512            output_tokens: u64,
513            duration_ms: u64,
514        }
515        let mut by_node: BTreeMap<String, AgentNodeActivity> = BTreeMap::new();
516        let mut pending: BTreeMap<String, Pending> = BTreeMap::new();
517        let mut observed_turns: BTreeMap<String, u64> = BTreeMap::new();
518
519        for env in self.events()? {
520            match env.event {
521                Event::AgentTurnStarted { node_id, turn, .. } => {
522                    let seen = observed_turns.entry(node_id).or_default();
523                    *seen = (*seen).max(turn as u64 + 1);
524                }
525                Event::EffectCompleted {
526                    node_id,
527                    effect,
528                    replayed,
529                    is_error,
530                    ..
531                } => {
532                    let node = by_node.entry(node_id).or_default();
533                    node.effects += 1;
534                    *node.effects_by_label.entry(effect).or_default() += 1;
535                    if replayed {
536                        node.replayed += 1;
537                    }
538                    if is_error {
539                        node.effect_errors += 1;
540                    }
541                }
542                Event::ToolCalled {
543                    node_id, is_error, ..
544                } => {
545                    let node = by_node.entry(node_id).or_default();
546                    node.tool_calls += 1;
547                    if is_error {
548                        node.tool_errors += 1;
549                    }
550                }
551                Event::Handoff { from, .. } => {
552                    by_node.entry(from).or_default().handoffs_out += 1;
553                }
554                Event::Suspended {
555                    node_id,
556                    turns,
557                    duration,
558                    input_tokens,
559                    output_tokens,
560                    ..
561                } => {
562                    by_node.entry(node_id.clone()).or_default().suspensions += 1;
563                    pending.insert(
564                        node_id,
565                        Pending {
566                            turns: turns as u64,
567                            input_tokens,
568                            output_tokens,
569                            duration_ms: duration.as_millis() as u64,
570                        },
571                    );
572                }
573                Event::AgentSpawned {
574                    node_id, children, ..
575                } => {
576                    by_node.entry(node_id).or_default().spawned += children.len() as u64;
577                }
578                Event::AgentStepCompleted {
579                    node_id,
580                    turns,
581                    duration,
582                    input_tokens,
583                    output_tokens,
584                    failed,
585                    ..
586                } => {
587                    let node = by_node.entry(node_id.clone()).or_default();
588                    node.turns += turns as u64;
589                    node.input_tokens += input_tokens;
590                    node.output_tokens += output_tokens;
591                    node.duration_ms += duration.as_millis() as u64;
592                    if failed {
593                        node.failures += 1;
594                    } else {
595                        node.completions += 1;
596                    }
597                    pending.remove(&node_id);
598                }
599                _ => {}
600            }
601        }
602
603        for (node_id, p) in pending {
604            let node = by_node.entry(node_id).or_default();
605            node.turns += p.turns;
606            node.input_tokens += p.input_tokens;
607            node.output_tokens += p.output_tokens;
608            node.duration_ms += p.duration_ms;
609        }
610        for (node_id, seen) in observed_turns {
611            let node = by_node.entry(node_id).or_default();
612            if node.turns == 0 {
613                node.turns = seen;
614            }
615        }
616
617        let mut totals = AgenticActivity::default();
618        for node in by_node.values() {
619            totals.turns += node.turns;
620            totals.input_tokens += node.input_tokens;
621            totals.output_tokens += node.output_tokens;
622            totals.effects += node.effects;
623            totals.replayed += node.replayed;
624            totals.tool_calls += node.tool_calls;
625            totals.steps_completed += node.completions;
626            totals.steps_failed += node.failures;
627            totals.suspensions += node.suspensions;
628        }
629        totals.by_node = by_node;
630        Ok(totals)
631    }
632
633    /// Per-effect execution spans in event order — the gantt substrate
634    /// for agent runs. Concurrent same-label effects within a turn are
635    /// matched first-in-first-out, which is the order the driver
636    /// reports completions in.
637    pub fn agentic_timeline(&self) -> Result<Vec<EffectSpan>> {
638        let mut spans: Vec<EffectSpan> = Vec::new();
639        let mut open: BTreeMap<(String, usize, String), Vec<usize>> = BTreeMap::new();
640        for env in self.events()? {
641            match env.event {
642                Event::EffectRequested {
643                    node_id,
644                    turn,
645                    effect,
646                    ..
647                } => {
648                    open.entry((node_id.clone(), turn, effect.clone()))
649                        .or_default()
650                        .push(spans.len());
651                    spans.push(EffectSpan {
652                        node_id,
653                        turn,
654                        effect,
655                        started_ts: Some(env.ts),
656                        finished_ts: None,
657                        duration_ms: None,
658                        replayed: false,
659                        is_error: false,
660                        outcome: "running".into(),
661                    });
662                }
663                Event::EffectCompleted {
664                    node_id,
665                    turn,
666                    effect,
667                    duration,
668                    replayed,
669                    is_error,
670                    ..
671                } => {
672                    let key = (node_id.clone(), turn, effect.clone());
673                    let idx = open
674                        .get_mut(&key)
675                        .filter(|v| !v.is_empty())
676                        .map(|v| v.remove(0));
677                    let span = match idx {
678                        Some(i) => &mut spans[i],
679                        None => {
680                            spans.push(EffectSpan {
681                                node_id,
682                                turn,
683                                effect,
684                                started_ts: None,
685                                finished_ts: None,
686                                duration_ms: None,
687                                replayed: false,
688                                is_error: false,
689                                outcome: String::new(),
690                            });
691                            spans.last_mut().expect("just pushed")
692                        }
693                    };
694                    span.finished_ts = Some(env.ts);
695                    span.duration_ms = Some(duration.as_millis() as u64);
696                    span.replayed = replayed;
697                    span.is_error = is_error;
698                    span.outcome = "completed".into();
699                }
700                _ => {}
701            }
702        }
703        Ok(spans)
704    }
705
706    /// The graph this run executed (`graph.json`), if snapshotted.
707    pub fn graph(&self) -> Result<Option<Graph>> {
708        let path = self.dir.join("graph.json");
709        if !path.exists() {
710            return Ok(None);
711        }
712        let bytes = fs::read(&path)?;
713        serde_json::from_slice(&bytes)
714            .map(Some)
715            .map_err(|e| SomaError::Serialization(e.to_string()))
716    }
717
718    /// Fold this run's node spans and health flags into a rendering
719    /// overlay: status + total duration + cache tier per node, `×N`
720    /// when a node ran more than once, deduplicated flags.
721    pub fn overlay(&self) -> Result<GraphOverlay> {
722        let mut overlay = GraphOverlay::default();
723        let mut counts: BTreeMap<String, u64> = BTreeMap::new();
724        for span in self.node_timings()? {
725            let entry = overlay.nodes.entry(span.node_id.clone()).or_default();
726            *counts.entry(span.node_id).or_default() += 1;
727            // Last span wins for status/tier; durations accumulate.
728            entry.status = Some(match span.outcome.as_str() {
729                "completed" => NodeStatus::Completed,
730                "cache_hit" => NodeStatus::Cached,
731                "failed" => NodeStatus::Failed,
732                _ => NodeStatus::Running,
733            });
734            entry.cache_tier = span.cache_tier;
735            if let Some(ms) = span.duration_ms {
736                entry.duration_ms = Some(entry.duration_ms.unwrap_or(0) + ms);
737            }
738        }
739        for (node_id, n) in counts {
740            if n > 1
741                && let Some(entry) = overlay.nodes.get_mut(&node_id)
742            {
743                entry.sublabel = Some(format!("×{n}"));
744            }
745        }
746        for flag in self.health_flags()? {
747            let entry = overlay.nodes.entry(flag.node_id).or_default();
748            if !entry.flags.contains(&flag.flag) {
749                entry.flags.push(flag.flag);
750            }
751        }
752        Ok(overlay)
753    }
754
755    /// Mermaid rendering of the run's graph, annotated with this run's
756    /// overlay. Errors if the run has no `graph.json` snapshot.
757    pub fn to_mermaid(&self) -> Result<String> {
758        let graph = self.graph()?.ok_or_else(|| {
759            SomaError::Other(format!("run dir {} has no graph.json", self.dir.display()))
760        })?;
761        Ok(graph.to_mermaid_with(&self.overlay()?))
762    }
763
764    /// Graphviz rendering of the run's graph, annotated with this
765    /// run's overlay. Errors if the run has no `graph.json` snapshot.
766    pub fn to_graphviz(&self) -> Result<String> {
767        let graph = self.graph()?.ok_or_else(|| {
768            SomaError::Other(format!("run dir {} has no graph.json", self.dir.display()))
769        })?;
770        Ok(graph.to_graphviz_with(&self.overlay()?))
771    }
772
773    /// Self-contained SVG rendering of the run's graph with this run's
774    /// overlay — no JavaScript, safe for notebook/report embedding.
775    /// Errors if the run has no `graph.json` snapshot.
776    pub fn to_svg(&self) -> Result<String> {
777        let graph = self.graph()?.ok_or_else(|| {
778            SomaError::Other(format!("run dir {} has no graph.json", self.dir.display()))
779        })?;
780        Ok(graph.to_svg_with(&self.overlay()?))
781    }
782
783    /// The study attached to this run, if any.
784    pub fn study(&self) -> Result<Option<Study>> {
785        let path = self.dir.join("study.json");
786        if !path.exists() {
787            return Ok(None);
788        }
789        Study::load(&path).map(Some)
790    }
791
792    /// Trial lifetimes from `study.json` (empty for non-study runs) —
793    /// the timeline/gantt substrate for HPO charts.
794    pub fn trial_timeline(&self) -> Result<Vec<TrialSpan>> {
795        let Some(study) = self.study()? else {
796            return Ok(Vec::new());
797        };
798        Ok(study
799            .trials
800            .iter()
801            .map(|t| TrialSpan {
802                trial_id: t.id.clone(),
803                state: trial_state_str(&t.state).to_string(),
804                started_at: t.started_at,
805                finished_at: t.finished_at,
806                duration_ms: t.duration_ms,
807            })
808            .collect())
809    }
810}
811
812fn trial_state_str(state: &TrialState) -> &'static str {
813    match state {
814        TrialState::Pending => "pending",
815        TrialState::Running => "running",
816        TrialState::Completed => "completed",
817        TrialState::Pruned { .. } => "pruned",
818        TrialState::Failed { .. } => "failed",
819    }
820}
821
822/// Derive a [`RunInfo`] from manifest + status. `now` is a parameter so
823/// crash detection is testable.
824fn run_info(
825    dir: &Path,
826    manifest: RunManifest,
827    status: Option<RunStatus>,
828    now: DateTime<Utc>,
829) -> RunInfo {
830    let state = match &status {
831        None => "running".to_string(),
832        Some(s) => match s.state {
833            RunState::Completed => "completed".to_string(),
834            RunState::Failed => "failed".to_string(),
835            RunState::Running => {
836                let last_beat = s.heartbeat_at.unwrap_or(s.updated_at);
837                if (now - last_beat).num_seconds() > STALE_HEARTBEAT_SECS {
838                    "crashed".to_string()
839                } else {
840                    "running".to_string()
841                }
842            }
843            // Forward-compat: RunState is non_exhaustive.
844            _ => "running".to_string(),
845        },
846    };
847    let finished_at = status.as_ref().and_then(|s| s.finished_at);
848    let duration_ms = finished_at
849        .map(|end| (end - manifest.created_at).num_milliseconds())
850        .filter(|ms| *ms >= 0)
851        .map(|ms| ms as u64);
852    let kind = serde_json::to_value(manifest.kind)
853        .ok()
854        .and_then(|v| v.as_str().map(str::to_string))
855        .unwrap_or_else(|| "other".to_string());
856    RunInfo {
857        run_id: manifest.run_id,
858        kind,
859        name: manifest.name,
860        state,
861        created_at: manifest.created_at,
862        finished_at,
863        duration_ms,
864        tags: manifest.tags,
865        dir: dir.display().to_string(),
866    }
867}
868
869/// All runs under `<root>/runs/`, newest first. Directories without a
870/// readable manifest are skipped.
871pub fn list_runs(root: impl AsRef<Path>) -> Result<Vec<RunInfo>> {
872    let runs_dir = root.as_ref().join("runs");
873    let entries = match fs::read_dir(&runs_dir) {
874        Ok(e) => e,
875        Err(_) => return Ok(Vec::new()), // no runs yet
876    };
877    let now = Utc::now();
878    let mut infos: Vec<RunInfo> = entries
879        .flatten()
880        .filter(|e| e.path().is_dir())
881        .filter_map(|e| {
882            let dir = e.path();
883            let manifest = load_manifest(&dir).ok()?;
884            let status = load_status(&dir).ok();
885            Some(run_info(&dir, manifest, status, now))
886        })
887        .collect();
888    infos.sort_by_key(|info| std::cmp::Reverse(info.created_at));
889    Ok(infos)
890}
891
892#[cfg(test)]
893mod tests {
894    use super::*;
895    use chrono::Duration as ChronoDuration;
896    use somatize_core::tracking::RunKind;
897
898    fn manifest(run_id: &str) -> RunManifest {
899        RunManifest::new(run_id, RunKind::Train, "test-run")
900    }
901
902    #[test]
903    fn run_info_detects_crash_from_stale_heartbeat() {
904        let now = Utc::now();
905        let stale = RunStatus {
906            state: RunState::Running,
907            updated_at: now - ChronoDuration::seconds(STALE_HEARTBEAT_SECS + 60),
908            heartbeat_at: Some(now - ChronoDuration::seconds(STALE_HEARTBEAT_SECS + 60)),
909            finished_at: None,
910        };
911        let info = run_info(Path::new("/tmp/r"), manifest("r1"), Some(stale), now);
912        assert_eq!(info.state, "crashed");
913
914        let fresh = RunStatus::running();
915        let info = run_info(Path::new("/tmp/r"), manifest("r1"), Some(fresh), now);
916        assert_eq!(info.state, "running");
917    }
918
919    #[test]
920    fn run_info_duration_and_kind() {
921        let now = Utc::now();
922        let mut m = manifest("r2");
923        m.created_at = now - ChronoDuration::milliseconds(1500);
924        let status = RunStatus {
925            state: RunState::Completed,
926            updated_at: now,
927            heartbeat_at: Some(now),
928            finished_at: Some(now),
929        };
930        let info = run_info(Path::new("/tmp/r"), m, Some(status), now);
931        assert_eq!(info.state, "completed");
932        assert_eq!(info.kind, "train");
933        assert_eq!(info.duration_ms, Some(1500));
934    }
935}