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        children: Vec::new(),
537    }
538}
539
540/// Top-level entries of a result, one line each, such as `verdict GREEN`. Saved runtime
541/// logs are left to the evidence view.
542fn result_summary(result: &crate::store::ExecutionResult) -> String {
543    let result = result.to_json();
544    let Some(object) = result.as_object() else {
545        return result
546            .to_string()
547            .lines()
548            .next()
549            .unwrap_or_default()
550            .to_owned();
551    };
552    // The verdict first, then the other entries in their saved order.
553    let mut entries: Vec<_> = object
554        .iter()
555        .filter(|(key, _)| !matches!(key.as_str(), "stdout" | "stderr"))
556        .collect();
557    entries.sort_by_key(|(key, _)| *key != "verdict");
558    let parts = entries.into_iter().map(|(key, value)| match value {
559        Value::String(text) => {
560            let line = text.lines().next().unwrap_or_default();
561            if line.len() < text.trim_end().len() {
562                format!("{key} {line} …")
563            } else {
564                format!("{key} {line}")
565            }
566        }
567        Value::Array(items) => format!("{key} [{} items]", items.len()),
568        Value::Object(fields) => format!("{key} {{{} fields}}", fields.len()),
569        other => format!("{key} {other}"),
570    });
571    join(parts, "\n")
572}
573
574/// Saved Run definitions joined with the Run's requests.
575struct Saved<'a> {
576    run: &'a RunView,
577    requests: &'a [RequestView],
578}
579
580impl<'a> Saved<'a> {
581    fn definitions(&self) -> Option<&'a crate::store::definitions::Graph> {
582        self.run.run.definitions.graph()
583    }
584    fn artifacts(
585        &self,
586    ) -> impl Iterator<
587        Item = (
588            &'a crate::types::ArtifactName,
589            &'a crate::store::definitions::Artifact,
590        ),
591    > {
592        self.definitions()
593            .into_iter()
594            .flat_map(|graph| graph.artifacts())
595    }
596    fn artifact(&self, id: &ArtifactName) -> Option<&'a crate::store::definitions::Artifact> {
597        self.definitions().and_then(|graph| graph.artifact(id))
598    }
599    fn eval_definitions(&self) -> &'a [crate::store::definitions::Eval] {
600        self.definitions().map_or(&[], |graph| graph.evals())
601    }
602    fn components(&self) -> &'a [crate::store::definitions::Component] {
603        self.definitions().map_or(&[], |graph| graph.components())
604    }
605
606    fn request(&self, eval: &EvalId) -> Option<&'a RequestView> {
607        self.requests
608            .iter()
609            .find(|view| &view.request.eval_id == eval)
610    }
611
612    fn status(&self, eval: &EvalId) -> Option<Status> {
613        self.request(eval).map(|view| view.request.status().into())
614    }
615
616    /// Dependency-first component order with cycle peers by name, then any other saved or
617    /// requested Artifact.
618    fn artifact_ids(&self) -> Vec<&'a ArtifactName> {
619        let components = self.components().iter();
620        let saved = components
621            .flat_map(|component| {
622                let mut members: Vec<_> =
623                    component.artifacts.value().into_iter().flatten().collect();
624                members.sort_unstable();
625                members
626            })
627            .chain(self.artifacts().map(|(id, _)| id))
628            .chain(self.requests.iter().map(|view| &view.request.target));
629        let mut seen = std::collections::HashSet::new();
630        saved.filter(|id| seen.insert(*id)).collect()
631    }
632
633    /// Saved Eval definitions targeting the Artifact, then requests without one.
634    fn evals(
635        &self,
636        artifact: &ArtifactName,
637    ) -> Vec<(&'a EvalId, Option<&'a crate::store::definitions::Eval>)> {
638        let mut evals: Vec<_> = self
639            .eval_definitions()
640            .iter()
641            .filter(|eval| &eval.target == artifact)
642            .map(|eval| (&eval.id, Some(eval)))
643            .collect();
644        for request in self.requests.iter().map(|view| &view.request) {
645            if &request.target == artifact && !evals.iter().any(|(id, _)| *id == &request.eval_id) {
646                evals.push((&request.eval_id, None));
647            }
648        }
649        evals
650    }
651
652    fn validation(&self, artifact: &ArtifactName) -> Option<&'a crate::store::ArtifactValidation> {
653        self.run
654            .run
655            .validation
656            .snapshot()?
657            .artifacts
658            .value()?
659            .iter()
660            .find(|a| &a.id == artifact)
661    }
662
663    fn component(&self, id: &ArtifactName) -> Option<&'a crate::store::definitions::Component> {
664        self.components().iter().find(|component| {
665            component
666                .artifacts
667                .value()
668                .is_some_and(|artifacts| artifacts.iter().any(|artifact| artifact == id))
669        })
670    }
671
672    fn relations(
673        &self,
674        artifact: &ArtifactName,
675        input: bool,
676    ) -> Vec<&'a crate::store::definitions::Relation> {
677        self.definitions()
678            .into_iter()
679            .flat_map(|graph| graph.relations())
680            .filter(|relation| {
681                if input {
682                    &relation.target == artifact
683                } else {
684                    &relation.source == artifact
685                }
686            })
687            .collect()
688    }
689}
690
691fn relation_kind(relation: &crate::store::definitions::Relation) -> String {
692    use crate::store::definitions::RelationKind;
693    let kind = match &relation.kind {
694        RelationKind::Unknown { .. } => serde_json::to_string(relation).unwrap_or_default(),
695        RelationKind::Child { path } => {
696            format!(
697                "child {}",
698                path.value().map_or("", crate::config::ChildPrefix::as_str)
699            )
700        }
701        RelationKind::Instruction { name, eval_id } => format!("{{{name}}} in {eval_id}"),
702        RelationKind::Argument {
703            eval_id,
704            index,
705            name,
706            ..
707        } => format!("argv {eval_id}[{index}] {{{name}}}"),
708    };
709    if relation.cyclic.value() == Some(&true) {
710        format!("{kind} ↻")
711    } else {
712        kind
713    }
714}
715
716fn profile(profile: &crate::config::StoredProfile) -> String {
717    use crate::config::{Field, StoredProfile};
718    match profile {
719        StoredProfile::Agent {
720            backend,
721            model,
722            reasoning,
723            ..
724        } => {
725            let reasoning = match reasoning {
726                Field::Value(reasoning) => format!(" reasoning {reasoning}"),
727                _ => String::new(),
728            };
729            format!(
730                "agent {} {model}{reasoning}",
731                serde_json::to_value(backend)
732                    .expect("backend is JSON")
733                    .as_str()
734                    .expect("backend is a string")
735            )
736        }
737        StoredProfile::Runtime { command, args, .. } => {
738            format!("runtime {command} {}", args.join(" "))
739        }
740        StoredProfile::Human {} => "human".into(),
741    }
742}
743
744/// Execution options that are set, such as `variant fast · timeoutMs 60000`.
745fn options(options: &crate::store::ExecutionOptions) -> String {
746    let value = serde_json::to_value(options).unwrap_or_default();
747    let set: Vec<_> = value
748        .as_object()
749        .into_iter()
750        .flatten()
751        .map(|(key, value)| match value.as_str() {
752            Some(text) => format!("{key} {text}"),
753            None => format!("{key} {value}"),
754        })
755        .collect();
756    if set.is_empty() {
757        "declared defaults".into()
758    } else {
759        set.join(" · ")
760    }
761}
762
763fn request_detail(view: &RequestView, now: OffsetDateTime) -> Detail {
764    let request = &view.request;
765    let summary = query::request_summary(view, now);
766    let deps = join(&request.deps, ", ");
767    let mut detail = Detail {
768        title: format!("{} · {}", request.eval_id, request.title),
769        summary: join(
770            [
771                format!(
772                    "{}{}",
773                    request.status(),
774                    elapsed(view, now).map_or(String::new(), |time| format!(" {time}"))
775                ),
776                profile(&request.profile),
777                if deps.is_empty() {
778                    deps
779                } else {
780                    format!("← {deps}")
781                },
782            ]
783            .into_iter()
784            .filter(|part| !part.is_empty()),
785            " · ",
786        ),
787        fields: Vec::new(),
788    };
789    detail.push(
790        "Status",
791        format!(
792            "{} — {}",
793            request.status(),
794            Status::from(request.status()).meaning()
795        ),
796    );
797    detail.push("Request", request.id.as_str());
798    detail.push("Reason", request.blocked_reason.clone().unwrap_or_default());
799    detail.push("Error", error(view).unwrap_or_default());
800    detail.push("Instruction", request.payload.instruction());
801    detail.push("Profile", profile(&request.profile));
802    if request.requested_profile != request.profile {
803        detail.push("Requested", profile(&request.requested_profile));
804    }
805    detail.push("Options", options(&request.options));
806    detail.push(
807        "Fingerprint",
808        request.fingerprint.as_deref().unwrap_or("none"),
809    );
810    detail.push("Key", request.key.as_deref().unwrap_or("none (no reuse)"));
811    detail.push(
812        "Key covers",
813        join(
814            request
815                .fingerprints
816                .iter()
817                .map(|(name, fingerprint)| format!("{name} {fingerprint}")),
818            "\n",
819        ),
820    );
821    if let Some(source) = &request.provenance {
822        detail.push(
823            "Source",
824            if reused(view) {
825                format!(
826                    "reused from Run {} request {} ({}, {}) completed {}{}",
827                    source.run_id,
828                    source.request_id,
829                    source.eval_id,
830                    crate::platform::path_text(&source.repo_path),
831                    source
832                        .completed_at
833                        .map_or_else(|| "?".to_owned(), |time| time.to_string()),
834                    request
835                        .producer
836                        .as_ref()
837                        .map_or(String::new(), |producer| format!(" by {}", producer.name))
838                )
839            } else {
840                "executed in this Run".into()
841            },
842        );
843    }
844    if let Some(execution) = &view.execution {
845        let owner = execution
846            .owner
847            .map_or(String::new(), |owner| format!(" · owner pid {}", owner.pid));
848        detail.push(
849            "Execution",
850            format!("{} {}{owner}", execution.id, execution.status()),
851        );
852    }
853    if request.profile.kind() == crate::config::ProfileKind::Human || view.claim.is_some() {
854        detail.push("Claim", claim(view));
855    }
856    detail.push(
857        "Timing",
858        format!(
859            "created {} · started {} · completed {}{}",
860            request.created_at,
861            request
862                .started_at
863                .map_or_else(|| "-".to_owned(), |time| time.to_string()),
864            request
865                .completed_at()
866                .map_or_else(|| "-".to_owned(), |time| time.to_string()),
867            elapsed(view, now).map_or(String::new(), |time| format!(" · {time}"))
868        ),
869    );
870    detail.push("Deps", request.deps.join(", "));
871    detail.push(
872        "Argv",
873        request
874            .argv
875            .as_ref()
876            .map_or(String::new(), |argv| argv.join(" ")),
877    );
878    if let Some(result) = &request.result() {
879        detail.push("Result", result_summary(result));
880        detail.push(
881            "Raw result",
882            serde_json::to_string_pretty(result).unwrap_or_default(),
883        );
884    }
885    let total = counter_pairs(&summary.usage.usage);
886    let attempts = request.usage.as_ref().or(request.reused_usage.as_ref());
887    let attempts = attempts.map_or(&[][..], Vec::as_slice);
888    let original = attempts.len();
889    let attempts = attempts.iter().map(|attempt| {
890        let error = attempt.error.as_deref();
891        let error = error.map_or(String::new(), |error| format!(" error: {error}"));
892        format!(
893            "turn {} attempt {}: {}{error}",
894            attempt.turn,
895            attempt.attempt,
896            counter_pairs(&attempt.usage)
897        )
898    });
899    let state = if reused(view) {
900        format!("reused: spent none · original attempts {original}")
901    } else {
902        format!(
903            "{} · attempts {}{}",
904            summary.usage.usage_state,
905            summary.usage.attempts,
906            if total.is_empty() {
907                total
908            } else {
909                format!(" · total {total}")
910            }
911        )
912    };
913    detail.push("Usage", join(std::iter::once(state).chain(attempts), "\n"));
914    detail
915}
916
917/// An eval at a glance: what it checks, its result once it has one, and what running it cost.
918/// For a reused result, `took` and `tokens` are what reuse saved. Everything else about the
919/// request stays in its [`Detail`]'s fields.
920#[derive(Debug, Clone, Default, PartialEq)]
921pub struct Card {
922    pub instruction: String,
923    /// The result's own entries, without its verdict and saved output.
924    pub result: Option<serde_json::Map<String, Value>>,
925    pub exit_code: Option<i32>,
926    pub error: Option<String>,
927    pub reused: bool,
928    /// The execution's own span: from its start to its end, or to `now` while it runs.
929    pub took: Option<String>,
930    /// Total tokens over every attempt.
931    pub tokens: Option<u64>,
932    pub model: Option<String>,
933}
934
935/// A result's own entries, without its verdict and saved output, as a Card shows them.
936pub(crate) fn entries(result: &crate::store::ExecutionResult) -> serde_json::Map<String, Value> {
937    let mut entries = result.to_json().as_object().cloned().unwrap_or_default();
938    for key in [
939        "verdict",
940        "stdout",
941        "stderr",
942        "exitCode",
943        "durationMs",
944        "truncated",
945    ] {
946        entries.remove(key);
947    }
948    entries
949}
950
951pub fn card(view: &RequestView, now: OffsetDateTime) -> Card {
952    use crate::config::StoredProfile;
953    use crate::store::ExecutionResult;
954    let request = &view.request;
955    let reused = reused(view);
956    let result = request.result();
957    let attempts = if reused {
958        request.reused_usage.as_ref()
959    } else {
960        request.usage.as_ref()
961    };
962    let tokens = attempts.and_then(|attempts| attempt_tokens(attempts));
963    Card {
964        instruction: request.payload.instruction().to_owned(),
965        result: result.map(entries),
966        exit_code: match result {
967            Some(ExecutionResult::Runtime(result)) => result.exit_code.value().copied(),
968            _ => None,
969        },
970        error: error(view),
971        reused,
972        took: view
973            .execution
974            .as_ref()
975            .and_then(|execution| span(execution.started_at, execution.completed_at(), now)),
976        tokens,
977        model: match &request.profile {
978            StoredProfile::Agent { model, .. } => Some(model.to_string()),
979            _ => None,
980        },
981    }
982}
983
984pub fn detail(
985    run: &RunView,
986    requests: &[RequestView],
987    target: &Target,
988    now: OffsetDateTime,
989) -> Detail {
990    let saved = Saved { run, requests };
991    let mut detail = Detail::default();
992    match target {
993        Target::Run => {
994            let progress = progress(run, requests, now);
995            let strip = strip(&progress);
996            detail.title = format!("Run {}", run.run.id);
997            detail.summary = format!("{} · {}", progress.status, strip.headline);
998            detail.push("Status", progress.status.as_str());
999            detail.push("Comment", progress.comment.clone().unwrap_or_default());
1000            detail.push("Validation", progress.validation.as_str());
1001            detail.push(
1002                "Counts",
1003                join(
1004                    progress
1005                        .counts
1006                        .iter()
1007                        .filter(|(_, n)| *n > 0)
1008                        .map(|(status, n)| format!("{} {status} {n}", status.glyph())),
1009                    " · ",
1010                ),
1011            );
1012            let lines = |items: &[(String, String)]| {
1013                join(
1014                    items.iter().map(|(eval, text)| format!("{eval}  {text}")),
1015                    "\n",
1016                )
1017            };
1018            detail.push("Errors", lines(&progress.errors));
1019            detail.push("Waiting", lines(&progress.waiting));
1020            detail.push("Running", lines(&progress.running));
1021            detail.push("Timing", progress.timing.as_str());
1022            detail.push("Budget", progress.budget.as_str());
1023            detail.push("Work", progress.work.as_str());
1024            detail.push("Usage", progress.usage.as_str());
1025            detail.push("Saved usage", progress.saved.as_str());
1026            detail.push("Run", run.run.id.as_str());
1027            detail.push("Repository", progress.repo.as_str());
1028        }
1029        Target::Eval(id) => {
1030            let waits = tree::waits_for(&saved, id, now);
1031            if let Some(view) = saved.request(id) {
1032                let mut detail = request_detail(view, now);
1033                detail.insert_after("Status", "Waits for", waits);
1034                return detail;
1035            }
1036            let declaration = saved
1037                .eval_definitions()
1038                .iter()
1039                .find(|eval| &eval.id == id)
1040                .and_then(|eval| eval.declaration.value());
1041            detail.title = format!(
1042                "{id} · {}",
1043                declaration
1044                    .and_then(|declaration| declaration.title.value())
1045                    .map_or("", String::as_str)
1046            );
1047            detail.push("Status", "not in this Run (outside the selection)");
1048            detail.push("Waits for", waits);
1049            detail.push(
1050                "Instruction",
1051                declaration
1052                    .and_then(|declaration| declaration.payload.value())
1053                    .map_or("", |payload| payload.instruction()),
1054            );
1055            detail.push(
1056                "Profile",
1057                declaration
1058                    .and_then(|declaration| declaration.profile.value())
1059                    .map_or_else(|| "unknown".into(), |saved| profile(&saved.known)),
1060            );
1061        }
1062        Target::Artifact(id) => {
1063            let artifact = saved.artifact(id);
1064            let basis = if artifact.and_then(|artifact| artifact.basis.value()) == Some(&true) {
1065                " [basis]"
1066            } else {
1067                ""
1068            };
1069            let kind = if artifact.and_then(|artifact| artifact.kind.value())
1070                == Some(&crate::config::ArtifactKind::File)
1071            {
1072                " [file]"
1073            } else {
1074                ""
1075            };
1076            detail.title = format!("Artifact {id}{basis}{kind}");
1077            detail.push(
1078                "Tags",
1079                artifact
1080                    .and_then(|artifact| artifact.tags.value())
1081                    .map_or_else(String::new, |tags| tags.join(", ")),
1082            );
1083            detail.summary = tree::artifact_status(&saved, id, now);
1084            detail.push("Status", detail.summary.clone());
1085            detail.push("Waits for", tree::artifact_waits(&saved, id, now));
1086            let snapshot = saved.validation(id);
1087            if let Some(status) = snapshot.and_then(|s| s.status.value()) {
1088                detail.push(
1089                    "At Run end",
1090                    format!(
1091                        "{status} · {}/{} Evals GREEN",
1092                        snapshot
1093                            .and_then(|s| s.passed.value())
1094                            .copied()
1095                            .unwrap_or(0),
1096                        snapshot.and_then(|s| s.total.value()).copied().unwrap_or(0)
1097                    ),
1098                );
1099            }
1100            detail.push(
1101                "Path",
1102                artifact
1103                    .and_then(|artifact| artifact.path.value())
1104                    .map_or_else(
1105                        || "-".into(),
1106                        |path| {
1107                            if path.as_os_str().is_empty() {
1108                                ".".into()
1109                            } else {
1110                                crate::platform::path_text(path)
1111                            }
1112                        },
1113                    ),
1114            );
1115            detail.push(
1116                "Fingerprint",
1117                saved
1118                    .validation(id)
1119                    .and_then(|s| s.fingerprint.value())
1120                    .map(|f| f.as_str())
1121                    .unwrap_or_default(),
1122            );
1123            let component = saved.component(id);
1124            if component.and_then(|component| component.cyclic.value()) == Some(&true) {
1125                detail.push(
1126                    "Cycle",
1127                    format!(
1128                        "↻ {}",
1129                        join(
1130                            component
1131                                .and_then(|component| component.artifacts.value())
1132                                .into_iter()
1133                                .flatten(),
1134                            ", "
1135                        )
1136                    ),
1137                );
1138            }
1139            let gates = component
1140                .and_then(|component| component.gates.value())
1141                .into_iter()
1142                .flatten()
1143                .map(|gate| mark(gate, saved.status(gate)));
1144            detail.push("Gates", join(gates, "\n"));
1145            let evals = saved.evals(id).into_iter();
1146            detail.push(
1147                "Evals",
1148                join(evals.map(|(eval, _)| mark(eval, saved.status(eval))), "\n"),
1149            );
1150            for (key, input) in [("Inputs", true), ("Used by", false)] {
1151                let relations = saved.relations(id, input).into_iter();
1152                let relations = relations.map(|relation| {
1153                    format!(
1154                        "{} — {}",
1155                        if input {
1156                            &relation.source
1157                        } else {
1158                            &relation.target
1159                        },
1160                        relation_kind(relation)
1161                    )
1162                });
1163                detail.push(key, join(relations, "\n"));
1164            }
1165        }
1166    }
1167    detail
1168}
1169
1170fn counter_pairs(counters: &std::collections::BTreeMap<String, u64>) -> String {
1171    join(
1172        counters
1173            .iter()
1174            .map(|(name, value)| format!("{name} {value}")),
1175        ", ",
1176    )
1177}
1178
1179#[cfg(any(test, feature = "test-support"))]
1180pub fn derivation_count(run: &RunView, requests: &[RequestView]) -> usize {
1181    states::States::new(&Saved { run, requests })
1182        .derivations
1183        .get()
1184}