Skip to main content

artifactize_core/monitor/model/
tree.rs

1//! The Artifacts and evals tree: one row per eval, Artifact rows rolled up from them.
2use crate::types::{ArtifactName, EvalId};
3use serde_json::Value;
4use time::OffsetDateTime;
5
6use super::{
7    Saved,
8    states::{
9        Activity, Busy, Completion, EvalView, NotRun, Queue, Rank, Source, States, Upstream, Waits,
10    },
11};
12use crate::store::{RequestView, RunView};
13
14/// Colour classes; the renderer maps them to terminal colours.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum Tone {
17    Green,
18    Red,
19    Error,
20    Running,
21    Human,
22    Queued,
23    Blocked,
24    Muted,
25}
26
27/// In-progress rows are bold; rows that wait or did not run are dimmed.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Weight {
30    Bold,
31    Normal,
32    Dim,
33}
34
35/// Status text; a toned segment (an X token) keeps its colour inside a dimmed row.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Segment {
38    pub text: String,
39    pub tone: Option<Tone>,
40}
41
42impl Segment {
43    fn plain(text: impl Into<String>) -> Self {
44        Self {
45            text: text.into(),
46            tone: None,
47        }
48    }
49    fn toned(text: impl Into<String>, tone: Tone) -> Self {
50        Self {
51            text: text.into(),
52            tone: Some(tone),
53        }
54    }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum Kind {
59    /// An Artifact rolled up from its eval rows; basis Artifacts are complete.
60    Artifact {
61        completion: Completion,
62        basis: bool,
63    },
64    Eval(EvalView),
65}
66
67#[derive(Debug, Clone)]
68pub struct Node {
69    /// Stable tree identifier: `a:<artifact>` or `e:<eval>`.
70    pub id: super::NodeId,
71    pub kind: Kind,
72    pub glyph: &'static str,
73    pub tone: Tone,
74    pub weight: Weight,
75    pub name: String,
76    /// Dim markers after the name: `↻ peer`, `[file]`.
77    pub marks: String,
78    pub text: Vec<Segment>,
79    /// Right column: elapsed time for evals, `passed/total` for Artifacts.
80    pub right: String,
81    /// Right column of the compact tree: elapsed time, `←X`/`←N`, or `passed/total`.
82    pub compact: String,
83    /// The state changed after the Run ended (rendered as a dim `*`).
84    pub changed: bool,
85    /// Eval rows: every dependency Artifact, unmet ones first in X order.
86    pub upstream: Vec<Upstream>,
87    /// A waiting Eval cannot proceed in this Run because an actual external gate failed.
88    pub(crate) held: bool,
89    /// Artifact rows: the Artifacts it is made of directly, by name.
90    pub parts: Vec<ArtifactName>,
91    /// Timed eval rows, so a cached tree can redraw its elapsed times.
92    pub clock: Option<Clock>,
93    pub children: Vec<Node>,
94}
95
96/// When a timed eval started and, once settled, ended.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct Clock {
99    pub start: OffsetDateTime,
100    pub end: Option<OffsetDateTime>,
101}
102
103impl Clock {
104    pub fn elapsed(self, now: OffsetDateTime) -> String {
105        super::duration(self.end.unwrap_or(now) - self.start)
106    }
107}
108
109impl Node {
110    /// The right column (or the compact one) as of `now`.
111    pub fn right_at(&self, now: OffsetDateTime, compact: bool) -> String {
112        match self.clock {
113            Some(clock) => clock.elapsed(now),
114            None if compact => self.compact.clone(),
115            None => self.right.clone(),
116        }
117    }
118
119    /// The status text without styling.
120    pub fn status(&self) -> String {
121        self.text
122            .iter()
123            .map(|segment| segment.text.as_str())
124            .collect()
125    }
126
127    /// The whole row as plain text, for tests and logs.
128    pub fn line(&self) -> String {
129        let mut line = format!("{} {}{}", self.glyph, self.name, self.marks);
130        for part in [self.status(), self.right.clone()] {
131            if !part.is_empty() {
132                line = format!("{line}  {part}");
133            }
134        }
135        if self.changed {
136            line.push_str(" *");
137        }
138        line
139    }
140
141    /// An Artifact whose evals are all done; folded by default.
142    pub fn done(&self) -> bool {
143        matches!(
144            self.kind,
145            Kind::Artifact {
146                completion: Completion::Complete,
147                ..
148            }
149        ) && !self.children.is_empty()
150    }
151
152    /// An ERROR, RED or waiting Human eval row: what `!` (next attention) visits.
153    pub fn urgent(&self) -> bool {
154        matches!(
155            self.kind,
156            Kind::Eval(EvalView::Failed { .. } | EvalView::InProgress(Activity::Human(_)))
157        )
158    }
159
160    /// An eval row that is in progress or failed: where the cursor starts.
161    pub fn attention(&self) -> bool {
162        matches!(
163            self.kind,
164            Kind::Eval(EvalView::InProgress(_) | EvalView::Failed { .. })
165        )
166    }
167}
168
169/// Glyph, colour and word of an Artifact's completion where another row names it.
170pub fn completion(completion: Completion) -> (&'static str, Tone, &'static str) {
171    match completion {
172        Completion::Complete => ("✓", Tone::Green, "complete"),
173        Completion::InProgress(Busy::Running) => ("◐", Tone::Running, "in progress"),
174        Completion::InProgress(Busy::Human) => ("?", Tone::Human, "in progress"),
175        Completion::InProgress(Busy::Queued) => ("·", Tone::Queued, "in progress"),
176        Completion::Waiting { blocked: false } => ("…", Tone::Muted, "waiting"),
177        Completion::Waiting { blocked: true } => ("⊘", Tone::Blocked, "blocked"),
178        Completion::Failed { verdict: true } => ("✗", Tone::Red, "failed"),
179        Completion::Failed { verdict: false } => ("!", Tone::Error, "failed"),
180    }
181}
182
183/// Glyph, colour and weight of one eval row.
184fn style(view: &EvalView) -> (&'static str, Tone, Weight) {
185    match view {
186        EvalView::Done(Source::Provisional) => ("○", Tone::Muted, Weight::Normal),
187        EvalView::Done(_) => ("✓", Tone::Green, Weight::Normal),
188        EvalView::Failed { verdict: true } => ("✗", Tone::Red, Weight::Normal),
189        EvalView::Failed { verdict: false } => ("!", Tone::Error, Weight::Normal),
190        EvalView::InProgress(Activity::Running) => ("◐", Tone::Running, Weight::Bold),
191        EvalView::InProgress(Activity::Human(_)) => ("?", Tone::Human, Weight::Bold),
192        EvalView::InProgress(Activity::Queued(_)) => ("·", Tone::Queued, Weight::Normal),
193        EvalView::WaitingOn(_) => ("…", Tone::Muted, Weight::Dim),
194        EvalView::BlockedBy(_) => ("⊘", Tone::Blocked, Weight::Normal),
195        EvalView::NotRun(NotRun::Budget) => ("$", Tone::Blocked, Weight::Normal),
196        EvalView::NotRun(NotRun::Absent) => ("-", Tone::Muted, Weight::Dim),
197        EvalView::NotRun(_) => ("○", Tone::Muted, Weight::Dim),
198    }
199}
200
201/// Shown X per row; the rest become `+N`.
202const SHOWN: usize = 2;
203/// A shown text field keeps this many characters of its first line, enough for a short value
204/// or the start of a sentence while two fields still fit on one row.
205const SHOWN_TEXT: usize = 24;
206
207/// `cli ◐ in progress`, or `a ◐, b ? +1`; an ERROR-failed X asks for a retry.
208fn tokens(states: &States, x: &[crate::types::ArtifactName], glyphs: bool) -> Vec<Segment> {
209    let mut segments = Vec::new();
210    for (index, id) in x.iter().take(SHOWN).enumerate() {
211        if index > 0 {
212            segments.push(Segment::plain(", "));
213        }
214        let (glyph, tone, word) = completion(states.completion(id));
215        let text = match (glyphs, x.len()) {
216            (false, _) => id.to_string(),
217            (true, 1) => format!("{id} {glyph} {word}"),
218            (true, _) => format!("{id} {glyph}"),
219        };
220        segments.push(Segment::toned(text, tone));
221    }
222    if x.len() > SHOWN {
223        segments.push(Segment::plain(format!(" +{}", x.len() - SHOWN)));
224    }
225    if glyphs
226        && let Some(failed) = x
227            .iter()
228            .find(|id| states.completion(id) == Completion::Failed { verdict: false })
229    {
230        segments.push(Segment::plain(format!(" (retry {failed} first)")));
231    }
232    segments
233}
234
235fn waits(states: &States, prefix: &str, waits: &Waits) -> Vec<Segment> {
236    let mut segments = vec![Segment::plain(prefix)];
237    segments.extend(tokens(states, &waits.artifacts, true));
238    if let Some((via, root)) = &waits.root {
239        segments.push(Segment::plain(format!(" ({via} waits for ")));
240        let (_, tone, _) = completion(states.completion(root));
241        segments.push(Segment::toned(root.clone(), tone));
242        segments.push(Segment::plain(")"));
243    }
244    segments
245}
246
247/// The top-level Result keys behind a RED verdict, such as `violations 3`.
248fn findings(result: Option<&crate::store::ExecutionResult>) -> String {
249    let result = result.map(crate::store::ExecutionResult::to_json);
250    let fields = result
251        .as_ref()
252        .and_then(Value::as_object)
253        .into_iter()
254        .flatten();
255    let fields = fields.filter(|(key, _)| {
256        !matches!(
257            key.as_str(),
258            "verdict" | "stdout" | "stderr" | "truncated" | "durationMs"
259        )
260    });
261    let parts: Vec<_> = fields
262        .take(SHOWN)
263        .map(|(key, value)| match value {
264            Value::Array(items) => format!("{key} {}", items.len()),
265            Value::Object(fields) => format!("{key} {{{}}}", fields.len()),
266            Value::String(text) => {
267                let text = text.lines().next().unwrap_or_default();
268                let mut short: String = text.chars().take(SHOWN_TEXT).collect();
269                if short.len() < text.len() {
270                    short.push('…');
271                }
272                format!("{key} {short}")
273            }
274            value => format!("{key} {value}"),
275        })
276        .collect();
277    parts.join(", ")
278}
279
280fn short_profile(profile: &crate::config::StoredProfile) -> String {
281    use crate::config::StoredProfile;
282    match profile {
283        StoredProfile::Agent { backend, model, .. } => format!(
284            "agent {} {model}",
285            serde_json::to_value(backend)
286                .ok()
287                .and_then(|value| value.as_str().map(str::to_owned))
288                .unwrap_or_default()
289        ),
290        StoredProfile::Runtime { command, .. } => format!("runtime {command}"),
291        StoredProfile::Human {} => "human".into(),
292    }
293}
294
295fn text(states: &States, view: &EvalView, request: Option<&RequestView>) -> Vec<Segment> {
296    let plain = |text: &str| vec![Segment::plain(text)];
297    match view {
298        EvalView::Done(Source::Provisional) => plain("PROVISIONAL · sign-offs remain"),
299        EvalView::Done(Source::Executed) => plain("GREEN"),
300        EvalView::Done(Source::Reused) => plain("GREEN · reused"),
301        EvalView::Done(Source::Saved) => plain("GREEN · saved result, not in this Run"),
302        EvalView::Failed { verdict: true } => {
303            let findings = findings(request.and_then(|view| view.request.result()));
304            if request.is_none() {
305                plain("RED · saved result, not in this Run")
306            } else if findings.is_empty() {
307                plain("RED")
308            } else {
309                vec![Segment::plain(format!("RED · {findings}"))]
310            }
311        }
312        EvalView::Failed { verdict: false } => match request.and_then(super::error) {
313            Some(error) => vec![Segment::plain(format!(
314                "ERROR · {}",
315                error.lines().next().unwrap_or_default()
316            ))],
317            None if request.is_none() => plain("ERROR · saved result, not in this Run"),
318            None => plain("ERROR"),
319        },
320        EvalView::InProgress(Activity::Running) => vec![Segment::plain(format!(
321            "running · {}",
322            request.map_or_else(String::new, |view| short_profile(&view.request.profile))
323        ))],
324        EvalView::InProgress(Activity::Human(None)) => plain("Human sign-off · unclaimed"),
325        EvalView::InProgress(Activity::Human(Some(reviewer))) => vec![Segment::plain(format!(
326            "Human sign-off · claimed by {reviewer}"
327        ))],
328        EvalView::InProgress(Activity::Queued(Queue::Ready)) => plain("queued"),
329        EvalView::InProgress(Activity::Queued(Queue::Jobs)) => plain("queued · --jobs slots full"),
330        EvalView::InProgress(Activity::Queued(Queue::Slot(backend))) => {
331            vec![Segment::plain(format!("queued · {backend} slots full"))]
332        }
333        EvalView::InProgress(Activity::Queued(Queue::Joined)) => {
334            plain("queued · joins the running review")
335        }
336        EvalView::WaitingOn(x) => waits(states, "waits for ", x),
337        EvalView::BlockedBy(x) => {
338            let mut segments = vec![Segment::plain(if states.running {
339                "blocked by "
340            } else {
341                "not run: blocked by "
342            })];
343            segments.extend(tokens(states, x, true));
344            segments
345        }
346        EvalView::NotRun(NotRun::Dependency(x)) => waits(states, "not run: waited for ", x),
347        EvalView::NotRun(NotRun::Budget) => plain("not started · maxExecutions reached"),
348        EvalView::NotRun(NotRun::Stale) => plain("not run · no reusable result"),
349        EvalView::NotRun(NotRun::Unreviewed) => plain("not reviewed"),
350        EvalView::NotRun(NotRun::Absent) => plain("not in this Run"),
351    }
352}
353
354/// Start and end of executed, failed, running and Human evals; an open end runs to now.
355fn clock(view: &EvalView, request: Option<&RequestView>) -> Option<Clock> {
356    let timed = matches!(
357        view,
358        EvalView::Done(Source::Executed)
359            | EvalView::Failed { .. }
360            | EvalView::InProgress(Activity::Running | Activity::Human(_))
361    );
362    let request = &request.filter(|_| timed)?.request;
363    let start = request.started_at.unwrap_or(request.created_at).time();
364    let end = request.completed_at().map(crate::types::Timestamp::time);
365    Some(Clock { start, end })
366}
367
368fn compact(view: &EvalView, right: &str) -> String {
369    let x = match view {
370        EvalView::WaitingOn(waits) | EvalView::NotRun(NotRun::Dependency(waits)) => {
371            &waits.artifacts
372        }
373        EvalView::BlockedBy(x) => x,
374        _ => return right.to_owned(),
375    };
376    match x.as_slice() {
377        [one] => format!("←{one}"),
378        many => format!("←{}", many.len()),
379    }
380}
381
382pub(crate) fn local(eval: &EvalId) -> &str {
383    eval.rsplit_once('/')
384        .map_or(eval.as_str(), |(_, local)| local)
385}
386
387fn eval_node<'a>(states: &States<'a>, eval: &'a EvalId, now: OffsetDateTime) -> Node {
388    let view = states.view(eval);
389    let request = states.request(eval);
390    let (glyph, tone, weight) = style(&view);
391    let clock = clock(&view, request);
392    let right = clock.map_or_else(String::new, |clock| clock.elapsed(now));
393    Node {
394        clock,
395        id: super::NodeId::eval(eval.clone()),
396        glyph,
397        tone,
398        weight,
399        name: local(eval).to_owned(),
400        marks: String::new(),
401        text: text(states, &view, request),
402        compact: compact(&view, &right),
403        right,
404        changed: false,
405        upstream: states.upstream_states(eval),
406        held: states.held_for_run(eval),
407        parts: Vec::new(),
408        children: Vec::new(),
409        kind: Kind::Eval(view),
410    }
411}
412
413/// Up to two eval names, then `+N`.
414fn names(nodes: &[&Node]) -> String {
415    let mut names = nodes
416        .iter()
417        .take(SHOWN)
418        .map(|node| node.name.as_str())
419        .collect::<Vec<_>>()
420        .join(", ");
421    if nodes.len() > SHOWN {
422        names = format!("{names} +{}", nodes.len() - SHOWN);
423    }
424    names
425}
426
427/// One clause for the most urgent class of eval rows, plus in-progress ones after a failure.
428fn summary(states: &States, id: &ArtifactName, children: &[Node]) -> Vec<Segment> {
429    let view = |node: &Node| match &node.kind {
430        Kind::Eval(view) => Some(view.clone()),
431        Kind::Artifact { .. } => None,
432    };
433    let Some(top) = children
434        .iter()
435        .filter_map(view)
436        .map(|view| view.rank())
437        .min()
438    else {
439        return Vec::new();
440    };
441    let class = |rank: Rank| -> Vec<&Node> {
442        children
443            .iter()
444            .filter(|node| view(node).is_some_and(|view| view.rank() == rank))
445            .collect()
446    };
447    let x = |ids: Vec<crate::types::ArtifactName>| tokens(states, &ids, false);
448    let mut segments = match top {
449        Rank::Failed => {
450            let failed = class(Rank::Failed);
451            let errors: Vec<_> = failed
452                .iter()
453                .copied()
454                .filter(|node| node.kind == Kind::Eval(EvalView::Failed { verdict: false }))
455                .collect();
456            let (label, nodes) = if errors.is_empty() {
457                ("RED", failed)
458            } else {
459                ("ERROR", errors)
460            };
461            vec![Segment::plain(format!("{label}: {}", names(&nodes)))]
462        }
463        Rank::InProgress => vec![Segment::plain(format!(
464            "in progress: {}",
465            names(&class(Rank::InProgress))
466        ))],
467        Rank::Blocked => {
468            let mut segments = vec![Segment::plain(if states.running {
469                "blocked by "
470            } else {
471                "not run: blocked by "
472            })];
473            segments.extend(x(states.blockers_of(id)));
474            segments
475        }
476        Rank::Waiting => {
477            let mut segments = vec![Segment::plain("waits for ")];
478            segments.extend(x(states.waits_of(id)));
479            segments
480        }
481        Rank::NotRun => {
482            let first = class(Rank::NotRun)[0];
483            match &first.kind {
484                Kind::Eval(EvalView::NotRun(NotRun::Dependency(_))) => {
485                    let mut segments = vec![Segment::plain("not run: waited for ")];
486                    segments.extend(x(states.waits_of(id)));
487                    segments
488                }
489                _ => first.text.clone(),
490            }
491        }
492        // Every eval row is done, but graph.rs still holds them on their own gates.
493        Rank::Done => match states.completion(id) {
494            Completion::Waiting { blocked: true } => {
495                let mut segments = vec![Segment::plain("done, but blocked by ")];
496                segments.extend(x(states.blockers_of(id)));
497                segments
498            }
499            Completion::Waiting { blocked: false } => {
500                let mut segments = vec![Segment::plain("done, but waits for ")];
501                segments.extend(x(states.waits_of(id)));
502                segments
503            }
504            _ => Vec::new(),
505        },
506    };
507    if top == Rank::Failed {
508        let busy = class(Rank::InProgress);
509        if !busy.is_empty() {
510            segments.push(Segment::plain(format!(" · in progress: {}", names(&busy))));
511        }
512    }
513    segments
514}
515
516fn artifact_node<'a>(
517    states: &States<'a>,
518    saved: &Saved<'a>,
519    id: &ArtifactName,
520    now: OffsetDateTime,
521) -> Node {
522    let artifact = saved.artifact(id);
523    let basis = artifact.and_then(|artifact| artifact.basis.value()) == Some(&true);
524    let children: Vec<_> = states
525        .evals(id)
526        .iter()
527        .map(|&eval| eval_node(states, eval, now))
528        .collect();
529    let mut marks = String::new();
530    if states.index.cyclic.contains(id) {
531        let peers = states.index.peers.get(id).map_or(&[][..], Vec::as_slice);
532        marks = match peers {
533            [] => "  ↻".into(),
534            [peer] => format!("  ↻ {peer}"),
535            [peer, rest @ ..] => format!("  ↻ {peer} +{}", rest.len()),
536        };
537    }
538    if artifact.and_then(|artifact| artifact.kind.value())
539        == Some(&crate::config::ArtifactKind::File)
540    {
541        marks.push_str("  [file]");
542    }
543    let completion = states.completion(id);
544    // GREEN as the graph counts it: a done eval held on its own gates does not pass.
545    let passed = states
546        .evals(id)
547        .iter()
548        .filter(|eval| states.effective(eval) == crate::graph::EvalStatus::Green)
549        .count();
550    let right = if children.is_empty() {
551        String::new()
552    } else {
553        format!("{passed}/{}", children.len())
554    };
555    let parts = states.index.parts.get(id).map_or(&[][..], Vec::as_slice);
556    // Parts that keep the Artifact from passing, most urgent first.
557    let mut pending: Vec<_> = parts
558        .iter()
559        .filter(|part| states.completion(part) != Completion::Complete)
560        .collect();
561    pending.sort_by_key(|part| states.completion(part).rank());
562    let listed = |label: &str| {
563        let shown: Vec<_> = pending.iter().take(SHOWN).map(|id| id.as_str()).collect();
564        let more = pending.len().saturating_sub(SHOWN);
565        let more = if more > 0 {
566            format!(" +{more}")
567        } else {
568            String::new()
569        };
570        Segment::plain(format!("{label}{}{more}", shown.join(", ")))
571    };
572    let (glyph, tone, weight, mut text) = if basis && pending.is_empty() {
573        ("◇", Tone::Muted, Weight::Dim, vec![Segment::plain("basis")])
574    } else if children.is_empty() && !parts.is_empty() {
575        // Without evals of its own, it is what its parts are.
576        let (glyph, tone, _) = self::completion(completion);
577        let text = if pending.is_empty() {
578            Segment::plain(format!("made of {}", parts.len()))
579        } else {
580            listed("pending: ")
581        };
582        (glyph, tone, Weight::Normal, vec![text])
583    } else if children.is_empty() {
584        (
585            "○",
586            Tone::Muted,
587            Weight::Dim,
588            vec![Segment::plain("no evals")],
589        )
590    } else {
591        // The row takes the glyph of its most urgent eval row; ERROR before RED.
592        let top = children
593            .iter()
594            .min_by_key(|node| match &node.kind {
595                Kind::Eval(EvalView::Failed { verdict: false }) => (Rank::Failed, false),
596                Kind::Eval(view) => (view.rank(), true),
597                Kind::Artifact { .. } => (Rank::Done, true),
598            })
599            .expect("children");
600        let (glyph, tone, weight) = match (&top.kind, completion) {
601            (_, Completion::InProgress(busy)) => {
602                let (glyph, tone, _) = self::completion(Completion::InProgress(busy));
603                (glyph, tone, Weight::Bold)
604            }
605            // Every eval row passes, yet a part fails.
606            (Kind::Eval(EvalView::Done(_)), Completion::Failed { verdict }) => {
607                let (glyph, tone, _) = self::completion(Completion::Failed { verdict });
608                (glyph, tone, Weight::Bold)
609            }
610            // Every eval row is done, yet the graph holds them on their own gates.
611            (Kind::Eval(EvalView::Done(_)), Completion::Waiting { blocked }) => {
612                let (glyph, tone, _) = self::completion(Completion::Waiting { blocked });
613                let weight = if blocked { Weight::Normal } else { Weight::Dim };
614                (glyph, tone, weight)
615            }
616            _ => (top.glyph, top.tone, top.weight),
617        };
618        (glyph, tone, weight, summary(states, id, &children))
619    };
620    if !children.is_empty() && !pending.is_empty() {
621        text.push(listed(if text.is_empty() {
622            "parts: "
623        } else {
624            " · parts: "
625        }));
626    }
627    Node {
628        id: super::NodeId::artifact(id.clone()),
629        clock: None,
630        kind: Kind::Artifact { completion, basis },
631        glyph,
632        tone,
633        weight,
634        name: id.to_string(),
635        marks,
636        text,
637        compact: right.clone(),
638        right,
639        changed: false,
640        upstream: Vec::new(),
641        held: false,
642        parts: parts.iter().map(|&part| part.clone()).collect(),
643        children,
644    }
645}
646
647/// Every saved or requested Artifact appears exactly once, upstream first where the graph
648/// allows: dependency-first component order, cycle peers by name.
649pub fn tree(run: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Vec<Node> {
650    let mut nodes = build(run, requests, now);
651    // A finished Run: derive the same snapshot once more as of its end and mark every row
652    // whose derived state differs.
653    if let Some(ended) = at_end(run, requests) {
654        mark(&mut nodes, &build(run, &ended, now));
655    }
656    nodes
657}
658
659fn build(run: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Vec<Node> {
660    let saved = Saved { run, requests };
661    let states = States::new(&saved);
662    states
663        .index
664        .order
665        .iter()
666        .map(|id| artifact_node(&states, &saved, id, now))
667        .collect()
668}
669
670/// The requests as they stood when the Run ended, or `None` when nothing changed since
671/// (or the Run has not ended). A request that completed later goes back to waiting for its
672/// Human (or running), and a later claim is dropped; the Run's recorded evidence is fixed.
673fn at_end(run: &RunView, requests: &[RequestView]) -> Option<Vec<RequestView>> {
674    let end = run.run.completed_at()?;
675    let late = |time: Option<crate::types::Timestamp>| time.is_some_and(|time| time > end);
676    let changed = requests.iter().any(|view| {
677        late(view.request.completed_at()) || late(view.claim.as_ref().map(|claim| claim.claimed_at))
678    });
679    if !changed {
680        return None;
681    }
682    // The views share their records: only a request that completed later is copied to change.
683    let mut ended = requests.to_vec();
684    for view in &mut ended {
685        if late(view.claim.as_ref().map(|claim| claim.claimed_at)) {
686            view.claim = None;
687        }
688        if late(view.request.completed_at()) {
689            let request = std::sync::Arc::make_mut(&mut view.request);
690            request.state = if request.profile.kind() == crate::config::ProfileKind::Human {
691                crate::store::RequestState::Deferred
692            } else {
693                crate::store::RequestState::Running
694            };
695        }
696    }
697    Some(ended)
698}
699
700/// The state a row derives, without the root-cause note (an explanation, not a state).
701fn state(node: &Node) -> (Kind, &'static str, String) {
702    let kind = match &node.kind {
703        Kind::Eval(EvalView::WaitingOn(waits)) => Kind::Eval(EvalView::WaitingOn(Waits {
704            artifacts: waits.artifacts.clone(),
705            root: None,
706        })),
707        Kind::Eval(EvalView::NotRun(NotRun::Dependency(waits))) => {
708            Kind::Eval(EvalView::NotRun(NotRun::Dependency(Waits {
709                artifacts: waits.artifacts.clone(),
710                root: None,
711            })))
712        }
713        kind => kind.clone(),
714    };
715    // Artifact rows compare their whole summary; eval rows their state and glyph.
716    let summary = match node.kind {
717        Kind::Artifact { .. } => format!("{} {}", node.status(), node.right),
718        Kind::Eval(_) => String::new(),
719    };
720    (kind, node.glyph, summary)
721}
722
723/// Mark the rows whose derived state differs from the Run-end derivation.
724fn mark(nodes: &mut [Node], ended: &[Node]) {
725    let mut before = std::collections::HashMap::new();
726    for node in ended {
727        before.insert(&node.id, state(node));
728        for child in &node.children {
729            before.insert(&child.id, state(child));
730        }
731    }
732    for node in nodes {
733        node.changed = before.get(&node.id) != Some(&state(node));
734        for child in &mut node.children {
735            child.changed = before.get(&child.id) != Some(&state(child));
736        }
737    }
738}
739
740/// Every eval's direct dependency Artifacts, from the Run's saved definitions.
741pub fn upstream_index(run: &RunView, requests: &[RequestView]) -> Vec<(EvalId, Vec<ArtifactName>)> {
742    let saved = Saved { run, requests };
743    let index = super::states::Index::new(&saved);
744    index
745        .upstream
746        .iter()
747        .map(|(eval, artifacts)| {
748            (
749                (*eval).clone(),
750                artifacts.iter().map(|id| (*id).clone()).collect(),
751            )
752        })
753        .collect()
754}
755
756/// The Artifact detail's current status, rolled up like its tree row.
757pub(super) fn artifact_status(saved: &Saved, id: &ArtifactName, now: OffsetDateTime) -> String {
758    let states = States::new(saved);
759    let node = artifact_node(&states, saved, id, now);
760    let (_, _, word) = completion(states.completion(id));
761    let status = node.status();
762    let status = if status.is_empty() {
763        word.to_owned()
764    } else {
765        status
766    };
767    if node.right.is_empty() {
768        format!("{} {status}", node.glyph)
769    } else {
770        format!("{} {status} · {} Evals GREEN", node.glyph, node.right)
771    }
772}
773
774/// The eval detail's `Waits for`: every dependency Artifact in the tree's order, with its
775/// completion and why the eval depends on it, then its evals that are not done yet.
776pub(super) fn waits_for(saved: &Saved, eval: &EvalId, now: OffsetDateTime) -> String {
777    let states = States::new(saved);
778    format_waits(
779        saved,
780        &states,
781        eval,
782        now,
783        &mut std::collections::HashSet::new(),
784    )
785}
786
787/// An Artifact combines its evals' input groups by identity, not by rendered text:
788/// different inputs may each have a pending eval named `check`.
789pub(super) fn artifact_waits(
790    saved: &Saved,
791    artifact: &ArtifactName,
792    now: OffsetDateTime,
793) -> String {
794    let states = States::new(saved);
795    let mut seen = std::collections::HashSet::new();
796    let blocks = states
797        .evals(artifact)
798        .iter()
799        .map(|eval| format_waits(saved, &states, eval, now, &mut seen))
800        .filter(|block| !block.is_empty());
801    super::join(blocks, "\n")
802}
803
804fn format_waits(
805    saved: &Saved,
806    states: &States<'_>,
807    eval: &EvalId,
808    now: OffsetDateTime,
809    seen: &mut std::collections::HashSet<ArtifactName>,
810) -> String {
811    let Some((&target, _)) = states
812        .index
813        .evals
814        .iter()
815        .find(|(_, evals)| evals.contains(&eval))
816    else {
817        return String::new();
818    };
819    let peers = |id: &ArtifactName| states.index.peers.get(id).cloned().unwrap_or_default();
820    let mut own = peers(target);
821    own.push(target);
822    let mut lines = Vec::new();
823    for upstream in states.upstream_states(eval) {
824        if !seen.insert(upstream.artifact.clone()) {
825            continue;
826        }
827        let (glyph, _, word) = completion(upstream.completion);
828        let id = &upstream.artifact;
829        let mut origins: Vec<String> = saved
830            .relations(id, false)
831            .into_iter()
832            .filter(|relation| own.contains(&&relation.target))
833            .map(|relation| {
834                let kind = super::relation_kind(relation);
835                if &relation.target == target {
836                    kind
837                } else {
838                    format!("{kind} → {}", relation.target)
839                }
840            })
841            .collect();
842        if origins.is_empty() && !peers(id).is_empty() {
843            origins.push(format!("↻ peer of {}", super::join(peers(id), ", ")));
844        }
845        let origins = if origins.is_empty() {
846            String::new()
847        } else {
848            format!(" · {}", origins.join(", "))
849        };
850        lines.push(format!("↑ {id} {glyph} {word}{origins}"));
851        if upstream.completion == Completion::Complete {
852            continue;
853        }
854        for &pending in states.evals(id) {
855            let node = eval_node(states, pending, now);
856            if !matches!(node.kind, Kind::Eval(EvalView::Done(_))) {
857                lines.push(format!("  {} {}  {}", node.glyph, node.name, node.status()));
858            }
859        }
860    }
861    lines.join("\n")
862}