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