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