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;
10mod 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`.
235fn 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
904pub fn card(view: &RequestView, now: OffsetDateTime) -> Card {
905    use crate::config::StoredProfile;
906    use crate::store::ExecutionResult;
907    let request = &view.request;
908    let reused = reused(view);
909    let result = request.result();
910    let attempts = if reused {
911        request.reused_usage.as_ref()
912    } else {
913        request.usage.as_ref()
914    };
915    let tokens = attempts
916        .map(|attempts| {
917            attempts
918                .iter()
919                .filter_map(|attempt| attempt.usage.get("totalTokens"))
920                .sum::<u64>()
921        })
922        .filter(|tokens| *tokens > 0);
923    Card {
924        instruction: request.payload.instruction().to_owned(),
925        result: result.map(|result| {
926            let mut entries = result.to_json().as_object().cloned().unwrap_or_default();
927            for key in [
928                "verdict",
929                "stdout",
930                "stderr",
931                "exitCode",
932                "durationMs",
933                "truncated",
934            ] {
935                entries.remove(key);
936            }
937            entries
938        }),
939        exit_code: match result {
940            Some(ExecutionResult::Runtime(result)) => result.exit_code.value().copied(),
941            _ => None,
942        },
943        error: error(view),
944        reused,
945        took: view
946            .execution
947            .as_ref()
948            .and_then(|execution| span(execution.started_at, execution.completed_at(), now)),
949        tokens,
950        model: match &request.profile {
951            StoredProfile::Agent { model, .. } => Some(model.to_string()),
952            _ => None,
953        },
954    }
955}
956
957pub fn detail(
958    run: &RunView,
959    requests: &[RequestView],
960    target: &Target,
961    now: OffsetDateTime,
962) -> Detail {
963    let saved = Saved { run, requests };
964    let mut detail = Detail::default();
965    match target {
966        Target::Run => {
967            let progress = progress(run, requests, now);
968            let strip = strip(&progress);
969            detail.title = format!("Run {}", run.run.id);
970            detail.summary = format!("{} · {}", progress.status, strip.headline);
971            detail.push("Status", progress.status.as_str());
972            detail.push("Validation", progress.validation.as_str());
973            detail.push(
974                "Counts",
975                join(
976                    progress
977                        .counts
978                        .iter()
979                        .filter(|(_, n)| *n > 0)
980                        .map(|(status, n)| format!("{} {status} {n}", status.glyph())),
981                    " · ",
982                ),
983            );
984            let lines = |items: &[(String, String)]| {
985                join(
986                    items.iter().map(|(eval, text)| format!("{eval}  {text}")),
987                    "\n",
988                )
989            };
990            detail.push("Errors", lines(&progress.errors));
991            detail.push("Waiting", lines(&progress.waiting));
992            detail.push("Running", lines(&progress.running));
993            detail.push("Timing", progress.timing.as_str());
994            detail.push("Budget", progress.budget.as_str());
995            detail.push("Work", progress.work.as_str());
996            detail.push("Usage", progress.usage.as_str());
997            detail.push("Saved usage", progress.saved.as_str());
998            detail.push("Run", run.run.id.as_str());
999            detail.push("Repository", progress.repo.as_str());
1000        }
1001        Target::Eval(id) => {
1002            let waits = tree::waits_for(&saved, id, now);
1003            if let Some(view) = saved.request(id) {
1004                let mut detail = request_detail(view, now);
1005                detail.insert_after("Status", "Waits for", waits);
1006                return detail;
1007            }
1008            let declaration = saved
1009                .eval_definitions()
1010                .iter()
1011                .find(|eval| &eval.id == id)
1012                .and_then(|eval| eval.declaration.value());
1013            detail.title = format!(
1014                "{id} · {}",
1015                declaration
1016                    .and_then(|declaration| declaration.title.value())
1017                    .map_or("", String::as_str)
1018            );
1019            detail.push("Status", "not in this Run (outside the selection)");
1020            detail.push("Waits for", waits);
1021            detail.push(
1022                "Instruction",
1023                declaration
1024                    .and_then(|declaration| declaration.payload.value())
1025                    .map_or("", |payload| payload.instruction()),
1026            );
1027            detail.push(
1028                "Profile",
1029                declaration
1030                    .and_then(|declaration| declaration.profile.value())
1031                    .map_or_else(|| "unknown".into(), |saved| profile(&saved.known)),
1032            );
1033        }
1034        Target::Artifact(id) => {
1035            let artifact = saved.artifact(id);
1036            let basis = if artifact.and_then(|artifact| artifact.basis.value()) == Some(&true) {
1037                " [basis]"
1038            } else {
1039                ""
1040            };
1041            let kind = if artifact.and_then(|artifact| artifact.kind.value())
1042                == Some(&crate::config::ArtifactKind::File)
1043            {
1044                " [file]"
1045            } else {
1046                ""
1047            };
1048            detail.title = format!("Artifact {id}{basis}{kind}");
1049            detail.push(
1050                "Tags",
1051                artifact
1052                    .and_then(|artifact| artifact.tags.value())
1053                    .map_or_else(String::new, |tags| tags.join(", ")),
1054            );
1055            detail.summary = tree::artifact_status(&saved, id, now);
1056            detail.push("Status", detail.summary.clone());
1057            detail.push("Waits for", tree::artifact_waits(&saved, id, now));
1058            let snapshot = saved.validation(id);
1059            if let Some(status) = snapshot.and_then(|s| s.status.value()) {
1060                detail.push(
1061                    "At Run end",
1062                    format!(
1063                        "{status} · {}/{} Evals GREEN",
1064                        snapshot
1065                            .and_then(|s| s.passed.value())
1066                            .copied()
1067                            .unwrap_or(0),
1068                        snapshot.and_then(|s| s.total.value()).copied().unwrap_or(0)
1069                    ),
1070                );
1071            }
1072            detail.push(
1073                "Path",
1074                artifact
1075                    .and_then(|artifact| artifact.path.value())
1076                    .map_or_else(
1077                        || "-".into(),
1078                        |path| {
1079                            if path.as_os_str().is_empty() {
1080                                ".".into()
1081                            } else {
1082                                crate::platform::path_text(path)
1083                            }
1084                        },
1085                    ),
1086            );
1087            detail.push(
1088                "Fingerprint",
1089                saved
1090                    .validation(id)
1091                    .and_then(|s| s.fingerprint.value())
1092                    .map(|f| f.as_str())
1093                    .unwrap_or_default(),
1094            );
1095            let component = saved.component(id);
1096            if component.and_then(|component| component.cyclic.value()) == Some(&true) {
1097                detail.push(
1098                    "Cycle",
1099                    format!(
1100                        "↻ {}",
1101                        join(
1102                            component
1103                                .and_then(|component| component.artifacts.value())
1104                                .into_iter()
1105                                .flatten(),
1106                            ", "
1107                        )
1108                    ),
1109                );
1110            }
1111            let gates = component
1112                .and_then(|component| component.gates.value())
1113                .into_iter()
1114                .flatten()
1115                .map(|gate| mark(gate, saved.status(gate)));
1116            detail.push("Gates", join(gates, "\n"));
1117            let evals = saved.evals(id).into_iter();
1118            detail.push(
1119                "Evals",
1120                join(evals.map(|(eval, _)| mark(eval, saved.status(eval))), "\n"),
1121            );
1122            for (key, input) in [("Inputs", true), ("Used by", false)] {
1123                let relations = saved.relations(id, input).into_iter();
1124                let relations = relations.map(|relation| {
1125                    format!(
1126                        "{} — {}",
1127                        if input {
1128                            &relation.source
1129                        } else {
1130                            &relation.target
1131                        },
1132                        relation_kind(relation)
1133                    )
1134                });
1135                detail.push(key, join(relations, "\n"));
1136            }
1137        }
1138    }
1139    detail
1140}
1141
1142fn counter_pairs(counters: &std::collections::BTreeMap<String, u64>) -> String {
1143    join(
1144        counters
1145            .iter()
1146            .map(|(name, value)| format!("{name} {value}")),
1147        ", ",
1148    )
1149}
1150
1151#[cfg(any(test, feature = "test-support"))]
1152pub fn derivation_count(run: &RunView, requests: &[RequestView]) -> usize {
1153    states::States::new(&Saved { run, requests })
1154        .derivations
1155        .get()
1156}