Skip to main content

artifactize_core/monitor/
model.rs

1//! Pure projections of saved state for the monitor screens.
2mod graph;
3pub use graph::{Graph, GraphEdge, GraphNode, Light, graph};
4mod node_id;
5pub use node_id::NodeId;
6mod states;
7mod status;
8#[cfg(test)]
9mod tests;
10pub(crate) mod tree;
11pub use states::{Activity, Busy, Completion, EvalView, NotRun, Queue, Source, Upstream, Waits};
12pub use status::Status;
13pub use tree::{Clock, Kind, Node, Segment, Tone, Weight, completion, tree, upstream_index};
14
15use serde_json::Value;
16use time::OffsetDateTime;
17
18use crate::{
19    query,
20    store::{RequestView, RunSummary, RunView},
21    types::{ArtifactName, EvalId},
22};
23
24/// What a tree node shows in the detail pane, parsed from its stable tree identifier.
25#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
26pub enum Target {
27    /// The open Run itself, from the node above its Artifacts.
28    Run,
29    Artifact(crate::types::ArtifactName),
30    Eval(crate::types::EvalId),
31}
32
33impl Target {
34    pub fn parse(id: &str) -> Option<Self> {
35        let (kind, name) = id.split_once(':')?;
36        match kind {
37            "run" => name.parse::<crate::types::RunId>().ok().map(|_| Self::Run),
38            "a" => name.parse().ok().map(Self::Artifact),
39            "e" => name.parse().ok().map(Self::Eval),
40            _ => None,
41        }
42    }
43}
44
45/// Where a Detail field goes, in display order. Technical is folded by default.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
47pub enum Section {
48    Outcome,
49    /// The Artifacts an eval waits for, each with its pending evals.
50    Waits,
51    What,
52    Provenance,
53    Technical,
54}
55
56impl Section {
57    pub const ALL: [Self; 5] = [
58        Self::Outcome,
59        Self::Waits,
60        Self::What,
61        Self::Provenance,
62        Self::Technical,
63    ];
64
65    /// The verdict first; technical identifiers and hashes last. The request stays visible.
66    pub fn of(key: &str) -> Self {
67        match key {
68            "Status" | "Error" | "Reason" | "Blocked by" | "Result" | "Claim" | "Source"
69            | "At Run end" | "Validation" | "Counts" | "Errors" | "Waiting" | "Running"
70            | "Evals" | "Gates" => Self::Outcome,
71            "Waits for" => Self::Waits,
72            "Timing" | "Usage" | "Budget" | "Work" | "Saved usage" => Self::Provenance,
73            "Execution" | "Fingerprint" | "Key" | "Key covers" | "Path" | "Tags" | "Cycle"
74            | "Run" | "Repository" => Self::Technical,
75            _ => Self::What,
76        }
77    }
78
79    pub fn name(self) -> &'static str {
80        match self {
81            Self::Outcome => "Outcome",
82            Self::Waits => "Waits for",
83            Self::What => "What",
84            Self::Provenance => "Provenance",
85            Self::Technical => "Technical",
86        }
87    }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct RunRow {
92    pub id: crate::types::RunId,
93    pub repo: std::path::PathBuf,
94    pub status: crate::types::RunStatus,
95    /// Request status glyphs with their counts, most urgent first, such as `✗1 ✓2`.
96    pub counts: String,
97    pub age: String,
98    /// Wall time of a finished Run.
99    pub took: String,
100    /// What the requester said it is working on, or empty.
101    pub comment: String,
102}
103
104#[derive(Debug, Clone)]
105pub struct Progress {
106    pub status: Status,
107    pub repo: String,
108    /// What the requester said it is working on (`verify --comment`).
109    pub comment: Option<String>,
110    pub validation: String,
111    pub timing: String,
112    /// Elapsed time of a running Run, or how long a finished one took.
113    pub elapsed: String,
114    pub finished: bool,
115    /// The saved validation snapshot: `None` until the Run ends.
116    pub satisfied: Option<bool>,
117    pub counts: Vec<(Status, u64)>,
118    /// Executions against their budget, and jobs.
119    pub budget: String,
120    /// Executed and reused requests.
121    pub work: String,
122    /// Every spent token counter, and the counters reuse saved.
123    pub usage: String,
124    pub saved: String,
125    /// The spent token total, the only usage the Run headline shows.
126    pub tokens: Option<u64>,
127    /// Eval ID and elapsed time.
128    pub running: Vec<(String, String)>,
129    /// Eval ID and claim state.
130    pub waiting: Vec<(String, String)>,
131    /// Eval ID (or "Run") and message.
132    pub errors: Vec<(String, String)>,
133}
134
135/// The Run headline: one status line, then what needs attention.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Strip {
138    pub status: Status,
139    pub headline: String,
140    /// Status and text: errors, then waiting Human reviews, then running evals.
141    pub attention: Vec<(Status, String)>,
142    /// Attention lines beyond the shown ones.
143    pub more: usize,
144}
145
146#[derive(Debug, Clone, Default)]
147pub struct Detail {
148    pub title: String,
149    /// One-line outcome: state, elapsed time, profile and dependencies.
150    pub summary: String,
151    pub fields: Vec<(&'static str, String)>,
152}
153
154impl Detail {
155    pub fn field(&self, name: &str) -> Option<&str> {
156        self.fields
157            .iter()
158            .find(|(key, _)| *key == name)
159            .map(|(_, value)| value.as_str())
160    }
161
162    fn push(&mut self, key: &'static str, value: impl Into<String>) {
163        let value = value.into();
164        if !value.is_empty() {
165            self.fields.push((key, value));
166        }
167    }
168
169    fn insert_after(&mut self, after: &str, key: &'static str, value: String) {
170        if value.is_empty() {
171            return;
172        }
173        let index = self.fields.iter().position(|(name, _)| *name == after);
174        let index = index.map_or(self.fields.len(), |index| index + 1);
175        self.fields.insert(index, (key, value));
176    }
177}
178
179/// Attention lines shown in the Run headline before `+N more`.
180const ATTENTION_LINES: usize = 3;
181
182const ABSENT: &str = "not in Run";
183
184/// The mark for `status`, or `-` for an eval without one.
185pub fn glyph(status: Option<Status>) -> &'static str {
186    status.map_or("-", Status::glyph)
187}
188
189fn join<T: AsRef<str>>(items: impl IntoIterator<Item = T>, separator: &str) -> String {
190    let items: Vec<_> = items
191        .into_iter()
192        .map(|item| item.as_ref().to_owned())
193        .collect();
194    items.join(separator)
195}
196
197fn mark(name: &EvalId, status: Option<Status>) -> String {
198    format!(
199        "{} {name} {}",
200        glyph(status),
201        status.map_or(ABSENT, Status::as_str)
202    )
203}
204
205/// Seconds per minute, hour and day, the units a duration is shown in.
206const MINUTE: i64 = 60;
207const HOUR: i64 = 60 * MINUTE;
208const DAY: i64 = 24 * HOUR;
209
210pub fn duration(elapsed: time::Duration) -> String {
211    let seconds = elapsed.whole_seconds().max(0);
212    match seconds {
213        0..MINUTE => format!("{seconds}s"),
214        MINUTE..HOUR => format!("{}m {:02}s", seconds / MINUTE, seconds % MINUTE),
215        HOUR..DAY => format!("{}h {:02}m", seconds / HOUR, seconds % HOUR / MINUTE),
216        _ => format!("{}d {}h", seconds / DAY, seconds % DAY / HOUR),
217    }
218}
219
220#[cfg(test)]
221#[test]
222fn typed_durations_keep_the_display_units_and_clamp_clock_skew() {
223    for (elapsed, expected) in [
224        (time::Duration::seconds(-1), "0s"),
225        (time::Duration::ZERO, "0s"),
226        (time::Duration::milliseconds(59_999), "59s"),
227        (time::Duration::minutes(1), "1m 00s"),
228        (time::Duration::seconds(125), "2m 05s"),
229        (time::Duration::hours(1), "1h 00m"),
230        (time::Duration::minutes(61), "1h 01m"),
231        (time::Duration::days(1), "1d 0h"),
232        (time::Duration::hours(25), "1d 1h"),
233    ] {
234        assert_eq!(duration(elapsed), expected);
235    }
236}
237
238/// The tokens a Request's saved attempts spent, `None` for none. The counts are
239/// read from the state, so a sum past `u64::MAX` saturates instead of panicking.
240pub(crate) fn attempt_tokens(attempts: &[crate::llm::Attempt]) -> Option<u64> {
241    let tokens = attempts
242        .iter()
243        .filter_map(|attempt| attempt.usage.get("totalTokens"))
244        .fold(0u64, |total, tokens| total.saturating_add(*tokens));
245    (tokens > 0).then_some(tokens)
246}
247
248#[test]
249fn attempt_tokens_saturate_on_saved_counts() {
250    let attempt = |tokens: u64| -> crate::llm::Attempt {
251        serde_json::from_value(serde_json::json!({ "usage": { "totalTokens": tokens } }))
252            .expect("a saved attempt")
253    };
254    assert_eq!(attempt_tokens(&[]), None);
255    assert_eq!(attempt_tokens(&[attempt(0)]), None);
256    assert_eq!(attempt_tokens(&[attempt(14), attempt(8)]), Some(22));
257    assert_eq!(
258        attempt_tokens(&[attempt(u64::MAX), attempt(1)]),
259        Some(u64::MAX)
260    );
261}
262
263/// Saved RFC 3339 times; an open end is measured to `now`.
264pub(crate) fn span(
265    start: crate::types::Timestamp,
266    end: Option<crate::types::Timestamp>,
267    now: OffsetDateTime,
268) -> Option<String> {
269    let end = end.map_or(now, crate::types::Timestamp::time);
270    Some(duration(end - start.time()))
271}
272
273/// Status counts as glyphs, most urgent first and zero counts left out: `!2 ✓1`.
274pub fn counts(counts: impl IntoIterator<Item = (Status, u64)>) -> String {
275    let mut counts: Vec<_> = counts.into_iter().filter(|(_, n)| *n > 0).collect();
276    counts.sort_by_key(|(status, _)| *status);
277    join(
278        counts
279            .into_iter()
280            .map(|(status, n)| format!("{}{n}", status.glyph())),
281        " ",
282    )
283}
284
285/// Token totals for a headline: `950`, `12.3k`, `3.9M`.
286pub fn tokens(total: u64) -> String {
287    // Headlines abbreviate decimal thousands and millions as k and M, respectively.
288    const THOUSAND: u64 = 1_000;
289    const MILLION: u64 = 1_000_000;
290    match total {
291        0..THOUSAND => total.to_string(),
292        THOUSAND..MILLION => format!("{:.1}k", total as f64 / THOUSAND as f64),
293        _ => format!("{:.1}M", total as f64 / MILLION as f64),
294    }
295}
296
297pub fn run_rows(runs: &[RunSummary], now: OffsetDateTime) -> Vec<RunRow> {
298    runs.iter()
299        .map(|run| RunRow {
300            id: run.id.clone(),
301            repo: run.repo_path.clone(),
302            status: run.status,
303            counts: counts(run.counts.iter().map(|(status, n)| ((*status).into(), *n))),
304            age: span(run.created_at, None, now).unwrap_or_default(),
305            took: run
306                .completed_at
307                .and_then(|end| span(run.created_at, Some(end), now))
308                .unwrap_or_default(),
309            comment: run.comment.as_deref().unwrap_or_default().to_owned(),
310        })
311        .collect()
312}
313
314fn reused(view: &RequestView) -> bool {
315    query::reused(&view.request)
316}
317
318fn elapsed(view: &RequestView, now: OffsetDateTime) -> Option<String> {
319    let request = &view.request;
320    if reused(view) {
321        return Some("reused".into());
322    }
323    let active = matches!(
324        request.status(),
325        crate::types::RequestStatus::Running
326            | crate::types::RequestStatus::WaitingHuman
327            | crate::types::RequestStatus::Deferred
328    );
329    let start = request
330        .started_at
331        .or(active.then_some(request.created_at))?;
332    span(start, request.completed_at(), now)
333}
334
335fn claim(view: &RequestView) -> String {
336    view.claim.as_ref().map_or("unclaimed".into(), |claim| {
337        format!("claimed by {} at {}", claim.reviewer, claim.claimed_at)
338    })
339}
340
341fn error(view: &RequestView) -> Option<String> {
342    let request = &view.request;
343    let error = request.error().map(str::to_owned)?;
344    Some(match &request.error_code() {
345        Some(code) => format!("[{code}] {error}"),
346        None => error,
347    })
348}
349
350pub fn progress(view: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Progress {
351    let run = &view.run;
352    let (summary, saved_usage) = query::run_summary(view, now);
353    let with = |status: crate::types::RequestStatus| {
354        requests
355            .iter()
356            .filter(move |view| view.request.status() == status)
357    };
358    let usage = counter_pairs(&summary.usage.usage);
359    let saved = counter_pairs(&saved_usage);
360    let spent = &summary.usage.usage;
361    let tokens = spent.get("totalTokens").copied().or_else(|| {
362        let input = spent.get("inputTokens").copied();
363        let output = spent.get("outputTokens").copied();
364        (input.is_some() || output.is_some())
365            .then(|| input.unwrap_or(0).saturating_add(output.unwrap_or(0)))
366    });
367    let validation = run.validation.snapshot();
368    let unmet = validation
369        .and_then(|v| v.obligations.value())
370        .map(|ids| {
371            ids.iter()
372                .map(ToString::to_string)
373                .collect::<Vec<_>>()
374                .join(", ")
375        })
376        .unwrap_or_default();
377    let wall = span(run.created_at, run.completed_at(), now).unwrap_or_default();
378    Progress {
379        status: run.status().into(),
380        repo: crate::platform::path_text(&run.repo_path),
381        comment: run.comment.as_ref().map(ToString::to_string),
382        // The saved snapshot; the tree shows the current state.
383        validation: match validation.and_then(|v| v.satisfied.value()) {
384            None => "pending".into(),
385            Some(true) => "SATISFIED at Run end".into(),
386            Some(_) if unmet.is_empty() => "NOT SATISFIED at Run end".into(),
387            Some(_) => format!("NOT SATISFIED at Run end (unmet: {unmet})"),
388        },
389        timing: format!(
390            "started {} · {} {wall}",
391            run.created_at,
392            if run.completed_at().is_some() {
393                "took"
394            } else {
395                "elapsed"
396            },
397        ),
398        elapsed: wall,
399        finished: run.completed_at().is_some(),
400        satisfied: validation.and_then(|v| v.satisfied.value()).copied(),
401        counts: summary
402            .counts
403            .iter()
404            .map(|(status, count)| ((*status).into(), *count))
405            .collect(),
406        budget: format!(
407            "executions {}/{} · jobs {}",
408            run.executions_started,
409            run.max_executions
410                .map_or("unlimited".into(), |max| max.to_string()),
411            run.jobs
412                .map_or("unlimited".into(), |limit| limit.to_string()),
413        ),
414        work: format!(
415            "executed {} · reused {}",
416            summary.executed.total, summary.reused.kinds.total,
417        ),
418        usage,
419        saved,
420        tokens,
421        running: with(crate::types::RequestStatus::Running)
422            .map(|view| {
423                (
424                    view.request.eval_id.to_string(),
425                    elapsed(view, now).unwrap_or_default(),
426                )
427            })
428            .collect(),
429        waiting: requests
430            .iter()
431            .filter(|view| view.request.status().is_human_wait())
432            .map(|view| (view.request.eval_id.to_string(), claim(view)))
433            .collect(),
434        errors: with(crate::types::RequestStatus::Error)
435            .map(|view| {
436                (
437                    view.request.eval_id.to_string(),
438                    error(view).unwrap_or_default(),
439                )
440            })
441            .chain(
442                run.error()
443                    .map(str::to_owned)
444                    .map(|error| ("Run".into(), error)),
445            )
446            .chain(
447                view.unreadable
448                    .iter()
449                    .map(|record| (record.id.clone(), record.to_string())),
450            )
451            .collect(),
452    }
453}
454
455/// The Run headline from its progress: snapshot verdict, counts, time and token total.
456pub fn strip(progress: &Progress) -> Strip {
457    let validation = match progress.satisfied {
458        None => "validation pending".to_owned(),
459        Some(true) => "SATISFIED at Run end".to_owned(),
460        Some(false) => "NOT SATISFIED at Run end".to_owned(),
461    };
462    let counts = counts(progress.counts.iter().copied());
463    let time = format!(
464        "{} {}",
465        if progress.finished { "took" } else { "elapsed" },
466        progress.elapsed
467    );
468    let tokens = progress
469        .tokens
470        .map(|total| format!("tokens {}", tokens(total)));
471    let headline = join(
472        [Some(validation), Some(counts), Some(time), tokens]
473            .into_iter()
474            .flatten()
475            .filter(|part| !part.is_empty()),
476        " · ",
477    );
478    let mut attention: Vec<(Status, String)> = progress
479        .errors
480        .iter()
481        .map(|(eval, error)| (Status::Error, format!("{eval}  {error}")))
482        .chain(progress.waiting.iter().map(|(eval, claim)| {
483            (
484                Status::WaitingHuman,
485                format!("{eval}  waiting Human · {claim}"),
486            )
487        }))
488        .chain(progress.running.iter().map(|(eval, time)| {
489            (
490                Status::Running,
491                format!("{eval}  running {time}").trim_end().to_owned(),
492            )
493        }))
494        .collect();
495    let more = attention.len().saturating_sub(ATTENTION_LINES);
496    attention.truncate(ATTENTION_LINES);
497    Strip {
498        status: progress.status,
499        headline,
500        attention,
501        more,
502    }
503}
504
505/// The row above the Artifacts; Enter on it opens the Run detail.
506pub fn run_node(run: &RunView) -> Node {
507    let status = Status::from(run.run.status());
508    Node {
509        id: NodeId::run(&run.run.id),
510        clock: None,
511        kind: Kind::Artifact {
512            completion: Completion::Complete,
513            basis: false,
514        },
515        glyph: status.glyph(),
516        tone: match status {
517            Status::Green => Tone::Green,
518            Status::Red => Tone::Red,
519            Status::Error => Tone::Error,
520            Status::Running => Tone::Running,
521            Status::Blocked | Status::BudgetExhausted | Status::Incomplete => Tone::Blocked,
522            _ => Tone::Muted,
523        },
524        weight: Weight::Normal,
525        name: "Run".into(),
526        marks: String::new(),
527        text: vec![Segment {
528            text: "Enter: usage, budgets, counts".into(),
529            tone: Some(Tone::Muted),
530        }],
531        right: String::new(),
532        compact: String::new(),
533        changed: false,
534        upstream: Vec::new(),
535        held: false,
536        parts: Vec::new(),
537        children: Vec::new(),
538    }
539}
540
541/// Top-level entries of a result, one line each, such as `verdict GREEN`. Saved runtime
542/// logs are left to the evidence view.
543fn result_summary(result: &crate::store::ExecutionResult) -> String {
544    let result = result.to_json();
545    let Some(object) = result.as_object() else {
546        return result
547            .to_string()
548            .lines()
549            .next()
550            .unwrap_or_default()
551            .to_owned();
552    };
553    // The verdict first, then the other entries in their saved order.
554    let mut entries: Vec<_> = object
555        .iter()
556        .filter(|(key, _)| !matches!(key.as_str(), "stdout" | "stderr"))
557        .collect();
558    entries.sort_by_key(|(key, _)| *key != "verdict");
559    let parts = entries.into_iter().map(|(key, value)| match value {
560        Value::String(text) => {
561            let line = text.lines().next().unwrap_or_default();
562            if line.len() < text.trim_end().len() {
563                format!("{key} {line} …")
564            } else {
565                format!("{key} {line}")
566            }
567        }
568        Value::Array(items) => format!("{key} [{} items]", items.len()),
569        Value::Object(fields) => format!("{key} {{{} fields}}", fields.len()),
570        other => format!("{key} {other}"),
571    });
572    join(parts, "\n")
573}
574
575/// Saved Run definitions joined with the Run's requests.
576struct Saved<'a> {
577    run: &'a RunView,
578    requests: &'a [RequestView],
579}
580
581impl<'a> Saved<'a> {
582    fn definitions(&self) -> Option<&'a crate::store::definitions::Graph> {
583        self.run.run.definitions.graph()
584    }
585    fn artifacts(
586        &self,
587    ) -> impl Iterator<
588        Item = (
589            &'a crate::types::ArtifactName,
590            &'a crate::store::definitions::Artifact,
591        ),
592    > {
593        self.definitions()
594            .into_iter()
595            .flat_map(|graph| graph.artifacts())
596    }
597    fn artifact(&self, id: &ArtifactName) -> Option<&'a crate::store::definitions::Artifact> {
598        self.definitions().and_then(|graph| graph.artifact(id))
599    }
600    fn eval_definitions(&self) -> &'a [crate::store::definitions::Eval] {
601        self.definitions().map_or(&[], |graph| graph.evals())
602    }
603    fn components(&self) -> &'a [crate::store::definitions::Component] {
604        self.definitions().map_or(&[], |graph| graph.components())
605    }
606
607    fn request(&self, eval: &EvalId) -> Option<&'a RequestView> {
608        self.requests
609            .iter()
610            .find(|view| &view.request.eval_id == eval)
611    }
612
613    fn status(&self, eval: &EvalId) -> Option<Status> {
614        self.request(eval).map(|view| view.request.status().into())
615    }
616
617    /// Dependency-first component order with cycle peers by name, then any other saved or
618    /// requested Artifact.
619    fn artifact_ids(&self) -> Vec<&'a ArtifactName> {
620        let components = self.components().iter();
621        let saved = components
622            .flat_map(|component| {
623                let mut members: Vec<_> =
624                    component.artifacts.value().into_iter().flatten().collect();
625                members.sort_unstable();
626                members
627            })
628            .chain(self.artifacts().map(|(id, _)| id))
629            .chain(self.requests.iter().map(|view| &view.request.target));
630        let mut seen = std::collections::HashSet::new();
631        saved.filter(|id| seen.insert(*id)).collect()
632    }
633
634    /// Saved Eval definitions targeting the Artifact, then requests without one.
635    fn evals(
636        &self,
637        artifact: &ArtifactName,
638    ) -> Vec<(&'a EvalId, Option<&'a crate::store::definitions::Eval>)> {
639        let mut evals: Vec<_> = self
640            .eval_definitions()
641            .iter()
642            .filter(|eval| &eval.target == artifact)
643            .map(|eval| (&eval.id, Some(eval)))
644            .collect();
645        for request in self.requests.iter().map(|view| &view.request) {
646            if &request.target == artifact && !evals.iter().any(|(id, _)| *id == &request.eval_id) {
647                evals.push((&request.eval_id, None));
648            }
649        }
650        evals
651    }
652
653    fn validation(&self, artifact: &ArtifactName) -> Option<&'a crate::store::ArtifactValidation> {
654        self.run
655            .run
656            .validation
657            .snapshot()?
658            .artifacts
659            .value()?
660            .iter()
661            .find(|a| &a.id == artifact)
662    }
663
664    fn component(&self, id: &ArtifactName) -> Option<&'a crate::store::definitions::Component> {
665        self.components().iter().find(|component| {
666            component
667                .artifacts
668                .value()
669                .is_some_and(|artifacts| artifacts.iter().any(|artifact| artifact == id))
670        })
671    }
672
673    /// Each Artifact with its direct parts.
674    fn parts(&self) -> std::collections::BTreeMap<&'a ArtifactName, Vec<&'a ArtifactName>> {
675        let mut parts: std::collections::BTreeMap<_, Vec<_>> = Default::default();
676        for relation in self
677            .definitions()
678            .into_iter()
679            .flat_map(|graph| graph.relations())
680        {
681            if matches!(relation.kind, crate::store::definitions::RelationKind::Part) {
682                parts
683                    .entry(&relation.target)
684                    .or_default()
685                    .push(&relation.source);
686            }
687        }
688        parts
689    }
690
691    fn relations(
692        &self,
693        artifact: &ArtifactName,
694        input: bool,
695    ) -> Vec<&'a crate::store::definitions::Relation> {
696        self.definitions()
697            .into_iter()
698            .flat_map(|graph| graph.relations())
699            .filter(|relation| {
700                if input {
701                    &relation.target == artifact
702                } else {
703                    &relation.source == artifact
704                }
705            })
706            .collect()
707    }
708}
709
710fn relation_kind(relation: &crate::store::definitions::Relation) -> String {
711    use crate::store::definitions::RelationKind;
712    let kind = match &relation.kind {
713        RelationKind::Unknown { .. } => serde_json::to_string(relation).unwrap_or_default(),
714        RelationKind::Part => "part".into(),
715        RelationKind::Child { path } => format!(
716            "child {}",
717            path.value().map_or("", crate::config::ChildPrefix::as_str)
718        ),
719        RelationKind::Instruction { name, eval_id } => format!("{{{name}}} in {eval_id}"),
720        RelationKind::Argument {
721            eval_id,
722            index,
723            name,
724            ..
725        } => format!("argv {eval_id}[{index}] {{{name}}}"),
726    };
727    if relation.cyclic.value() == Some(&true) {
728        format!("{kind} ↻")
729    } else {
730        kind
731    }
732}
733
734fn profile(profile: &crate::config::StoredProfile) -> String {
735    use crate::config::{Field, StoredProfile};
736    match profile {
737        StoredProfile::Agent {
738            backend,
739            model,
740            reasoning,
741            ..
742        } => {
743            let reasoning = match reasoning {
744                Field::Value(reasoning) => format!(" reasoning {reasoning}"),
745                _ => String::new(),
746            };
747            format!(
748                "agent {} {model}{reasoning}",
749                serde_json::to_value(backend)
750                    .expect("backend is JSON")
751                    .as_str()
752                    .expect("backend is a string")
753            )
754        }
755        StoredProfile::Runtime { command, args, .. } => {
756            format!("runtime {command} {}", args.join(" "))
757        }
758        StoredProfile::Human {} => "human".into(),
759    }
760}
761
762/// Execution options that are set, such as `variant fast · timeoutMs 60000`.
763fn options(options: &crate::store::ExecutionOptions) -> String {
764    let value = serde_json::to_value(options).unwrap_or_default();
765    let set: Vec<_> = value
766        .as_object()
767        .into_iter()
768        .flatten()
769        .map(|(key, value)| match value.as_str() {
770            Some(text) => format!("{key} {text}"),
771            None => format!("{key} {value}"),
772        })
773        .collect();
774    if set.is_empty() {
775        "declared defaults".into()
776    } else {
777        set.join(" · ")
778    }
779}
780
781fn request_detail(view: &RequestView, now: OffsetDateTime) -> Detail {
782    let request = &view.request;
783    let summary = query::request_summary(view, now);
784    let deps = join(&request.deps, ", ");
785    let mut detail = Detail {
786        title: format!("{} · {}", request.eval_id, request.title),
787        summary: join(
788            [
789                format!(
790                    "{}{}",
791                    request.status(),
792                    elapsed(view, now).map_or(String::new(), |time| format!(" {time}"))
793                ),
794                profile(&request.profile),
795                if deps.is_empty() {
796                    deps
797                } else {
798                    format!("← {deps}")
799                },
800            ]
801            .into_iter()
802            .filter(|part| !part.is_empty()),
803            " · ",
804        ),
805        fields: Vec::new(),
806    };
807    detail.push(
808        "Status",
809        format!(
810            "{} — {}",
811            request.status(),
812            Status::from(request.status()).meaning()
813        ),
814    );
815    detail.push("Request", request.id.as_str());
816    detail.push("Reason", request.blocked_reason.clone().unwrap_or_default());
817    detail.push("Error", error(view).unwrap_or_default());
818    detail.push("Instruction", request.payload.instruction());
819    detail.push("Profile", profile(&request.profile));
820    if request.requested_profile != request.profile {
821        detail.push("Requested", profile(&request.requested_profile));
822    }
823    detail.push("Options", options(&request.options));
824    detail.push(
825        "Fingerprint",
826        request.fingerprint.as_deref().unwrap_or("none"),
827    );
828    detail.push("Key", request.key.as_deref().unwrap_or("none (no reuse)"));
829    detail.push(
830        "Key covers",
831        join(
832            request
833                .fingerprints
834                .iter()
835                .map(|(name, fingerprint)| format!("{name} {fingerprint}")),
836            "\n",
837        ),
838    );
839    if let Some(source) = &request.provenance {
840        detail.push(
841            "Source",
842            if reused(view) {
843                format!(
844                    "reused from Run {} request {} ({}, {}) completed {}{}",
845                    source.run_id,
846                    source.request_id,
847                    source.eval_id,
848                    crate::platform::path_text(&source.repo_path),
849                    source
850                        .completed_at
851                        .map_or_else(|| "?".to_owned(), |time| time.to_string()),
852                    request
853                        .producer
854                        .as_ref()
855                        .map_or(String::new(), |producer| format!(" by {}", producer.name))
856                )
857            } else {
858                "executed in this Run".into()
859            },
860        );
861    }
862    if let Some(execution) = &view.execution {
863        let owner = execution
864            .owner
865            .map_or(String::new(), |owner| format!(" · owner pid {}", owner.pid));
866        detail.push(
867            "Execution",
868            format!("{} {}{owner}", execution.id, execution.status()),
869        );
870    }
871    if request.profile.kind() == crate::config::ProfileKind::Human || view.claim.is_some() {
872        detail.push("Claim", claim(view));
873    }
874    detail.push(
875        "Timing",
876        format!(
877            "created {} · started {} · completed {}{}",
878            request.created_at,
879            request
880                .started_at
881                .map_or_else(|| "-".to_owned(), |time| time.to_string()),
882            request
883                .completed_at()
884                .map_or_else(|| "-".to_owned(), |time| time.to_string()),
885            elapsed(view, now).map_or(String::new(), |time| format!(" · {time}"))
886        ),
887    );
888    detail.push("Deps", request.deps.join(", "));
889    detail.push(
890        "Argv",
891        request
892            .argv
893            .as_ref()
894            .map_or(String::new(), |argv| argv.join(" ")),
895    );
896    if let Some(result) = &request.result() {
897        detail.push("Result", result_summary(result));
898        detail.push(
899            "Raw result",
900            serde_json::to_string_pretty(result).unwrap_or_default(),
901        );
902    }
903    let total = counter_pairs(&summary.usage.usage);
904    let attempts = request.usage.as_ref().or(request.reused_usage.as_ref());
905    let attempts = attempts.map_or(&[][..], Vec::as_slice);
906    let original = attempts.len();
907    let attempts = attempts.iter().map(|attempt| {
908        let error = attempt.error.as_deref();
909        let error = error.map_or(String::new(), |error| format!(" error: {error}"));
910        format!(
911            "turn {} attempt {}: {}{error}",
912            attempt.turn,
913            attempt.attempt,
914            counter_pairs(&attempt.usage)
915        )
916    });
917    let state = if reused(view) {
918        format!("reused: spent none · original attempts {original}")
919    } else {
920        format!(
921            "{} · attempts {}{}",
922            summary.usage.usage_state,
923            summary.usage.attempts,
924            if total.is_empty() {
925                total
926            } else {
927                format!(" · total {total}")
928            }
929        )
930    };
931    detail.push("Usage", join(std::iter::once(state).chain(attempts), "\n"));
932    detail
933}
934
935/// An eval at a glance: what it checks, its result once it has one, and what running it cost.
936/// For a reused result, `took` and `tokens` are what reuse saved. Everything else about the
937/// request stays in its [`Detail`]'s fields.
938#[derive(Debug, Clone, Default, PartialEq)]
939pub struct Card {
940    pub instruction: String,
941    /// The result's own entries, without its verdict and saved output.
942    pub result: Option<serde_json::Map<String, Value>>,
943    pub exit_code: Option<i32>,
944    pub error: Option<String>,
945    pub reused: bool,
946    /// The execution's own span: from its start to its end, or to `now` while it runs.
947    pub took: Option<String>,
948    /// Total tokens over every attempt.
949    pub tokens: Option<u64>,
950    pub model: Option<String>,
951}
952
953/// A result's own entries, without its verdict and saved output, as a Card shows them.
954pub(crate) fn entries(result: &crate::store::ExecutionResult) -> serde_json::Map<String, Value> {
955    let mut entries = result.to_json().as_object().cloned().unwrap_or_default();
956    for key in [
957        "verdict",
958        "stdout",
959        "stderr",
960        "exitCode",
961        "durationMs",
962        "truncated",
963    ] {
964        entries.remove(key);
965    }
966    entries
967}
968
969pub fn card(view: &RequestView, now: OffsetDateTime) -> Card {
970    use crate::config::StoredProfile;
971    use crate::store::ExecutionResult;
972    let request = &view.request;
973    let reused = reused(view);
974    let result = request.result();
975    let attempts = if reused {
976        request.reused_usage.as_ref()
977    } else {
978        request.usage.as_ref()
979    };
980    let tokens = attempts.and_then(|attempts| attempt_tokens(attempts));
981    Card {
982        instruction: request.payload.instruction().to_owned(),
983        result: result.map(entries),
984        exit_code: match result {
985            Some(ExecutionResult::Runtime(result)) => result.exit_code.value().copied(),
986            _ => None,
987        },
988        error: error(view),
989        reused,
990        took: view
991            .execution
992            .as_ref()
993            .and_then(|execution| span(execution.started_at, execution.completed_at(), now)),
994        tokens,
995        model: match &request.profile {
996            StoredProfile::Agent { model, .. } => Some(model.to_string()),
997            _ => None,
998        },
999    }
1000}
1001
1002pub fn detail(
1003    run: &RunView,
1004    requests: &[RequestView],
1005    target: &Target,
1006    now: OffsetDateTime,
1007) -> Detail {
1008    let saved = Saved { run, requests };
1009    let mut detail = Detail::default();
1010    match target {
1011        Target::Run => {
1012            let progress = progress(run, requests, now);
1013            let strip = strip(&progress);
1014            detail.title = format!("Run {}", run.run.id);
1015            detail.summary = format!("{} · {}", progress.status, strip.headline);
1016            detail.push("Status", progress.status.as_str());
1017            detail.push("Comment", progress.comment.clone().unwrap_or_default());
1018            detail.push("Validation", progress.validation.as_str());
1019            detail.push(
1020                "Counts",
1021                join(
1022                    progress
1023                        .counts
1024                        .iter()
1025                        .filter(|(_, n)| *n > 0)
1026                        .map(|(status, n)| format!("{} {status} {n}", status.glyph())),
1027                    " · ",
1028                ),
1029            );
1030            let lines = |items: &[(String, String)]| {
1031                join(
1032                    items.iter().map(|(eval, text)| format!("{eval}  {text}")),
1033                    "\n",
1034                )
1035            };
1036            detail.push("Errors", lines(&progress.errors));
1037            detail.push("Waiting", lines(&progress.waiting));
1038            detail.push("Running", lines(&progress.running));
1039            detail.push("Timing", progress.timing.as_str());
1040            detail.push("Budget", progress.budget.as_str());
1041            detail.push("Work", progress.work.as_str());
1042            detail.push("Usage", progress.usage.as_str());
1043            detail.push("Saved usage", progress.saved.as_str());
1044            detail.push("Run", run.run.id.as_str());
1045            detail.push("Repository", progress.repo.as_str());
1046        }
1047        Target::Eval(id) => {
1048            let waits = tree::waits_for(&saved, id, now);
1049            if let Some(view) = saved.request(id) {
1050                let mut detail = request_detail(view, now);
1051                detail.insert_after("Status", "Waits for", waits);
1052                return detail;
1053            }
1054            let declaration = saved
1055                .eval_definitions()
1056                .iter()
1057                .find(|eval| &eval.id == id)
1058                .and_then(|eval| eval.declaration.value());
1059            detail.title = format!(
1060                "{id} · {}",
1061                declaration
1062                    .and_then(|declaration| declaration.title.value())
1063                    .map_or("", String::as_str)
1064            );
1065            detail.push("Status", "not in this Run (outside the selection)");
1066            detail.push("Waits for", waits);
1067            detail.push(
1068                "Instruction",
1069                declaration
1070                    .and_then(|declaration| declaration.payload.value())
1071                    .map_or("", |payload| payload.instruction()),
1072            );
1073            detail.push(
1074                "Profile",
1075                declaration
1076                    .and_then(|declaration| declaration.profile.value())
1077                    .map_or_else(|| "unknown".into(), |saved| profile(&saved.known)),
1078            );
1079        }
1080        Target::Artifact(id) => {
1081            let artifact = saved.artifact(id);
1082            let basis = if artifact.and_then(|artifact| artifact.basis.value()) == Some(&true) {
1083                " [basis]"
1084            } else {
1085                ""
1086            };
1087            let kind = if artifact.and_then(|artifact| artifact.kind.value())
1088                == Some(&crate::config::ArtifactKind::File)
1089            {
1090                " [file]"
1091            } else {
1092                ""
1093            };
1094            detail.title = format!("Artifact {id}{basis}{kind}");
1095            detail.push(
1096                "Tags",
1097                artifact
1098                    .and_then(|artifact| artifact.tags.value())
1099                    .map_or_else(String::new, |tags| tags.join(", ")),
1100            );
1101            detail.summary = tree::artifact_status(&saved, id, now);
1102            detail.push("Status", detail.summary.clone());
1103            detail.push("Waits for", tree::artifact_waits(&saved, id, now));
1104            let snapshot = saved.validation(id);
1105            if let Some(status) = snapshot.and_then(|s| s.status.value()) {
1106                detail.push(
1107                    "At Run end",
1108                    format!(
1109                        "{status} · {}/{} Evals GREEN",
1110                        snapshot
1111                            .and_then(|s| s.passed.value())
1112                            .copied()
1113                            .unwrap_or(0),
1114                        snapshot.and_then(|s| s.total.value()).copied().unwrap_or(0)
1115                    ),
1116                );
1117            }
1118            detail.push(
1119                "Path",
1120                artifact
1121                    .and_then(|artifact| artifact.path.value())
1122                    .map_or_else(
1123                        || "-".into(),
1124                        |path| {
1125                            if path.as_os_str().is_empty() {
1126                                ".".into()
1127                            } else {
1128                                crate::platform::path_text(path)
1129                            }
1130                        },
1131                    ),
1132            );
1133            detail.push(
1134                "Fingerprint",
1135                saved
1136                    .validation(id)
1137                    .and_then(|s| s.fingerprint.value())
1138                    .map(|f| f.as_str())
1139                    .unwrap_or_default(),
1140            );
1141            let component = saved.component(id);
1142            if component.and_then(|component| component.cyclic.value()) == Some(&true) {
1143                detail.push(
1144                    "Cycle",
1145                    format!(
1146                        "↻ {}",
1147                        join(
1148                            component
1149                                .and_then(|component| component.artifacts.value())
1150                                .into_iter()
1151                                .flatten(),
1152                            ", "
1153                        )
1154                    ),
1155                );
1156            }
1157            let gates = component
1158                .and_then(|component| component.gates.value())
1159                .into_iter()
1160                .flatten()
1161                .map(|gate| mark(gate, saved.status(gate)));
1162            detail.push("Gates", join(gates, "\n"));
1163            let evals = saved.evals(id).into_iter();
1164            detail.push(
1165                "Evals",
1166                join(evals.map(|(eval, _)| mark(eval, saved.status(eval))), "\n"),
1167            );
1168            for (key, input) in [("Inputs", true), ("Used by", false)] {
1169                let relations = saved.relations(id, input).into_iter();
1170                let relations = relations.map(|relation| {
1171                    format!(
1172                        "{} — {}",
1173                        if input {
1174                            &relation.source
1175                        } else {
1176                            &relation.target
1177                        },
1178                        relation_kind(relation)
1179                    )
1180                });
1181                detail.push(key, join(relations, "\n"));
1182            }
1183        }
1184    }
1185    detail
1186}
1187
1188fn counter_pairs(counters: &std::collections::BTreeMap<String, u64>) -> String {
1189    join(
1190        counters
1191            .iter()
1192            .map(|(name, value)| format!("{name} {value}")),
1193        ", ",
1194    )
1195}
1196
1197#[cfg(any(test, feature = "test-support"))]
1198pub fn derivation_count(run: &RunView, requests: &[RequestView]) -> usize {
1199    states::States::new(&Saved { run, requests })
1200        .derivations
1201        .get()
1202}