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