1use 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
31pub const STALE_HEARTBEAT_SECS: i64 = 300;
34
35pub struct RunReader {
37 dir: PathBuf,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct RunInfo {
43 pub run_id: String,
45 pub kind: String,
47 pub name: String,
49 pub state: String,
51 pub created_at: DateTime<Utc>,
53 pub finished_at: Option<DateTime<Utc>>,
55 pub duration_ms: Option<u64>,
57 pub tags: Vec<String>,
59 pub dir: String,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
66pub struct NodeSpan {
67 pub node_id: String,
69 pub started_ts: Option<DateTime<Utc>>,
71 pub finished_ts: Option<DateTime<Utc>>,
73 pub duration_ms: Option<u64>,
75 pub outcome: String,
77 pub cache_tier: Option<String>,
79 pub error: Option<String>,
81 #[serde(default)]
84 pub effectful: bool,
85}
86
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct CacheActivity {
90 pub hits: u64,
92 pub misses: u64,
94 pub by_node: BTreeMap<String, NodeCacheCounts>,
96}
97
98#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100pub struct NodeCacheCounts {
101 pub hits: u64,
103 pub misses: u64,
105 pub last_tier: Option<String>,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct MetricPoint {
112 pub ts: DateTime<Utc>,
114 pub name: String,
116 pub value: f64,
118 pub step: u64,
120 #[serde(default)]
122 pub trial_id: Option<String>,
123 #[serde(default)]
125 pub node_id: Option<String>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct HealthFlagRecord {
131 pub ts: DateTime<Utc>,
133 pub node_id: String,
136 pub step: usize,
138 pub flag: String,
140 pub detail: String,
142}
143
144#[derive(Debug, Clone, Default, Serialize, Deserialize)]
149pub struct AgenticActivity {
150 pub turns: u64,
152 pub input_tokens: u64,
154 pub output_tokens: u64,
156 pub effects: u64,
158 pub replayed: u64,
161 pub tool_calls: u64,
163 pub steps_completed: u64,
165 pub steps_failed: u64,
167 pub suspensions: u64,
169 pub by_node: BTreeMap<String, AgentNodeActivity>,
171}
172
173#[derive(Debug, Clone, Default, Serialize, Deserialize)]
181pub struct AgentNodeActivity {
182 pub turns: u64,
184 pub input_tokens: u64,
186 pub output_tokens: u64,
188 pub duration_ms: u64,
190 pub effects: u64,
192 pub effects_by_label: BTreeMap<String, u64>,
194 pub effect_errors: u64,
196 pub replayed: u64,
198 pub tool_calls: u64,
200 pub tool_errors: u64,
202 pub handoffs_out: u64,
204 pub suspensions: u64,
206 pub spawned: u64,
208 pub completions: u64,
210 pub failures: u64,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
218pub struct EffectSpan {
219 pub node_id: String,
221 pub turn: usize,
223 pub effect: String,
225 pub started_ts: Option<DateTime<Utc>>,
227 pub finished_ts: Option<DateTime<Utc>>,
229 pub duration_ms: Option<u64>,
231 pub replayed: bool,
233 pub is_error: bool,
235 pub outcome: String,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct TrialSpan {
242 pub trial_id: String,
244 pub state: String,
246 pub started_at: Option<DateTime<Utc>>,
248 pub finished_at: Option<DateTime<Utc>>,
250 pub duration_ms: Option<u64>,
252}
253
254impl RunReader {
255 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 pub fn dir(&self) -> &Path {
265 &self.dir
266 }
267
268 pub fn manifest(&self) -> Result<RunManifest> {
270 load_manifest(&self.dir)
271 }
272
273 pub fn status(&self) -> Result<RunStatus> {
275 load_status(&self.dir)
276 }
277
278 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 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()), };
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
822fn 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 _ => "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
869pub 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()), };
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}