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