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]`, `[dep]`.
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 dependency eval row: its verdict derives from its `depends_on` Artifacts.
88    pub dependency: 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).whole_seconds())
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(_) => ("✓", Tone::Green, Weight::Normal),
185        EvalView::Failed { verdict: true } => ("✗", Tone::Red, Weight::Normal),
186        EvalView::Failed { verdict: false } => ("!", Tone::Error, Weight::Normal),
187        EvalView::InProgress(Activity::Running) => ("◐", Tone::Running, Weight::Bold),
188        EvalView::InProgress(Activity::Human(_)) => ("?", Tone::Human, Weight::Bold),
189        EvalView::InProgress(Activity::Queued(_)) => ("·", Tone::Queued, Weight::Normal),
190        EvalView::WaitingOn(_) => ("…", Tone::Muted, Weight::Dim),
191        EvalView::BlockedBy(_) => ("⊘", Tone::Blocked, Weight::Normal),
192        EvalView::NotRun(NotRun::Budget) => ("$", Tone::Blocked, Weight::Normal),
193        EvalView::NotRun(NotRun::Absent) => ("-", Tone::Muted, Weight::Dim),
194        EvalView::NotRun(_) => ("○", Tone::Muted, Weight::Dim),
195    }
196}
197
198/// Shown X per row; the rest become `+N`.
199const SHOWN: usize = 2;
200/// A shown text field keeps this many characters of its first line, enough for a short value
201/// or the start of a sentence while two fields still fit on one row.
202const SHOWN_TEXT: usize = 24;
203
204/// `cli ◐ in progress`, or `a ◐, b ? +1`; an ERROR-failed X asks for a retry.
205fn tokens(states: &States, x: &[crate::types::ArtifactName], glyphs: bool) -> Vec<Segment> {
206    let mut segments = Vec::new();
207    for (index, id) in x.iter().take(SHOWN).enumerate() {
208        if index > 0 {
209            segments.push(Segment::plain(", "));
210        }
211        let (glyph, tone, word) = completion(states.completion(id));
212        let text = match (glyphs, x.len()) {
213            (false, _) => id.to_string(),
214            (true, 1) => format!("{id} {glyph} {word}"),
215            (true, _) => format!("{id} {glyph}"),
216        };
217        segments.push(Segment::toned(text, tone));
218    }
219    if x.len() > SHOWN {
220        segments.push(Segment::plain(format!(" +{}", x.len() - SHOWN)));
221    }
222    if glyphs
223        && let Some(failed) = x
224            .iter()
225            .find(|id| states.completion(id) == Completion::Failed { verdict: false })
226    {
227        segments.push(Segment::plain(format!(" (retry {failed} first)")));
228    }
229    segments
230}
231
232fn waits(states: &States, prefix: &str, waits: &Waits) -> Vec<Segment> {
233    let mut segments = vec![Segment::plain(prefix)];
234    segments.extend(tokens(states, &waits.artifacts, true));
235    if let Some((via, root)) = &waits.root {
236        segments.push(Segment::plain(format!(" ({via} waits for ")));
237        let (_, tone, _) = completion(states.completion(root));
238        segments.push(Segment::toned(root.clone(), tone));
239        segments.push(Segment::plain(")"));
240    }
241    segments
242}
243
244/// The top-level Result keys behind a RED verdict, such as `violations 3`.
245fn findings(result: Option<&crate::store::ExecutionResult>) -> String {
246    let result = result.map(crate::store::ExecutionResult::to_json);
247    let fields = result
248        .as_ref()
249        .and_then(Value::as_object)
250        .into_iter()
251        .flatten();
252    let fields = fields.filter(|(key, _)| {
253        !matches!(
254            key.as_str(),
255            "verdict" | "stdout" | "stderr" | "truncated" | "derived" | "durationMs"
256        )
257    });
258    let parts: Vec<_> = fields
259        .take(SHOWN)
260        .map(|(key, value)| match value {
261            Value::Array(items) => format!("{key} {}", items.len()),
262            Value::Object(fields) => format!("{key} {{{}}}", fields.len()),
263            Value::String(text) => {
264                let text = text.lines().next().unwrap_or_default();
265                let mut short: String = text.chars().take(SHOWN_TEXT).collect();
266                if short.len() < text.len() {
267                    short.push('…');
268                }
269                format!("{key} {short}")
270            }
271            value => format!("{key} {value}"),
272        })
273        .collect();
274    parts.join(", ")
275}
276
277fn short_profile(profile: &crate::config::StoredProfile) -> String {
278    use crate::config::StoredProfile;
279    match profile {
280        StoredProfile::Agent { backend, model, .. } => format!(
281            "agent {} {model}",
282            serde_json::to_value(backend)
283                .ok()
284                .and_then(|value| value.as_str().map(str::to_owned))
285                .unwrap_or_default()
286        ),
287        StoredProfile::Runtime { command, .. } => format!("runtime {command}"),
288        StoredProfile::Human {} => "human".into(),
289        StoredProfile::Dependency { .. } => "dependency".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::Executed) => plain("GREEN"),
297        EvalView::Done(Source::Reused) => plain("GREEN · reused"),
298        EvalView::Done(Source::Derived) => plain("GREEN · derived"),
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::Jobs)) => plain("queued"),
327        EvalView::InProgress(Activity::Queued(Queue::Slot(backend))) => {
328            vec![Segment::plain(format!("queued · {backend} slots full"))]
329        }
330        EvalView::InProgress(Activity::Queued(Queue::Joined)) => {
331            plain("queued · joins the running review")
332        }
333        EvalView::WaitingOn(x) => waits(states, "waits for ", x),
334        EvalView::BlockedBy(x) => {
335            let mut segments = vec![Segment::plain(if states.running {
336                "blocked by "
337            } else {
338                "not run: blocked by "
339            })];
340            segments.extend(tokens(states, x, true));
341            segments
342        }
343        EvalView::NotRun(NotRun::Dependency(x)) => waits(states, "not run: waited for ", x),
344        EvalView::NotRun(NotRun::Budget) => plain("not started · maxExecutions reached"),
345        EvalView::NotRun(NotRun::Stale) => plain("not run · no reusable result"),
346        EvalView::NotRun(NotRun::Unreviewed) => plain("not reviewed"),
347        EvalView::NotRun(NotRun::Absent) => plain("not in this Run"),
348    }
349}
350
351/// Start and end of executed, failed, running and Human evals; an open end runs to now.
352fn clock(view: &EvalView, request: Option<&RequestView>) -> Option<Clock> {
353    let timed = matches!(
354        view,
355        EvalView::Done(Source::Executed)
356            | EvalView::Failed { .. }
357            | EvalView::InProgress(Activity::Running | Activity::Human(_))
358    );
359    let request = &request.filter(|_| timed)?.request;
360    let start = request.started_at.unwrap_or(request.created_at).time();
361    let end = request.completed_at().map(crate::types::Timestamp::time);
362    Some(Clock { start, end })
363}
364
365fn compact(view: &EvalView, right: &str) -> String {
366    let x = match view {
367        EvalView::WaitingOn(waits) | EvalView::NotRun(NotRun::Dependency(waits)) => {
368            &waits.artifacts
369        }
370        EvalView::BlockedBy(x) => x,
371        _ => return right.to_owned(),
372    };
373    match x.as_slice() {
374        [one] => format!("←{one}"),
375        many => format!("←{}", many.len()),
376    }
377}
378
379fn local(eval: &EvalId) -> &str {
380    eval.rsplit_once('/')
381        .map_or(eval.as_str(), |(_, local)| local)
382}
383
384fn eval_node<'a>(states: &States<'a>, eval: &'a EvalId, now: OffsetDateTime) -> Node {
385    let view = states.view(eval);
386    let request = states.request(eval);
387    let (glyph, tone, weight) = style(&view);
388    let clock = clock(&view, request);
389    let right = clock.map_or_else(String::new, |clock| clock.elapsed(now));
390    Node {
391        clock,
392        id: super::NodeId::eval(eval.clone()),
393        glyph,
394        tone,
395        weight,
396        name: local(eval).to_owned(),
397        dependency: states.index.dependency.contains(eval),
398        marks: if states.index.dependency.contains(eval) {
399            "  [dep]".into()
400        } else {
401            String::new()
402        },
403        text: text(states, &view, request),
404        compact: compact(&view, &right),
405        right,
406        changed: false,
407        upstream: states.upstream_states(eval),
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 (glyph, tone, weight, text) = if basis {
556        ("◇", Tone::Muted, Weight::Dim, vec![Segment::plain("basis")])
557    } else if children.is_empty() {
558        (
559            "○",
560            Tone::Muted,
561            Weight::Dim,
562            vec![Segment::plain("no evals")],
563        )
564    } else {
565        // The row takes the glyph of its most urgent eval row; ERROR before RED.
566        let top = children
567            .iter()
568            .min_by_key(|node| match &node.kind {
569                Kind::Eval(EvalView::Failed { verdict: false }) => (Rank::Failed, false),
570                Kind::Eval(view) => (view.rank(), true),
571                Kind::Artifact { .. } => (Rank::Done, true),
572            })
573            .expect("children");
574        let (glyph, tone, weight) = match (&top.kind, completion) {
575            (_, Completion::InProgress(busy)) => {
576                let (glyph, tone, _) = self::completion(Completion::InProgress(busy));
577                (glyph, tone, Weight::Bold)
578            }
579            // Every eval row is done, yet the graph holds them on their own gates.
580            (Kind::Eval(EvalView::Done(_)), Completion::Waiting { blocked }) => {
581                let (glyph, tone, _) = self::completion(Completion::Waiting { blocked });
582                let weight = if blocked { Weight::Normal } else { Weight::Dim };
583                (glyph, tone, weight)
584            }
585            _ => (top.glyph, top.tone, top.weight),
586        };
587        (glyph, tone, weight, summary(states, id, &children))
588    };
589    Node {
590        id: super::NodeId::artifact(id.clone()),
591        clock: None,
592        kind: Kind::Artifact { completion, basis },
593        glyph,
594        tone,
595        weight,
596        name: id.to_string(),
597        marks,
598        text,
599        compact: right.clone(),
600        right,
601        changed: false,
602        upstream: Vec::new(),
603        dependency: false,
604        children,
605    }
606}
607
608/// Every saved or requested Artifact appears exactly once, upstream first where the graph
609/// allows: dependency-first component order, cycle peers by name.
610pub fn tree(run: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Vec<Node> {
611    let mut nodes = build(run, requests, now);
612    // A finished Run: derive the same snapshot once more as of its end and mark every row
613    // whose derived state differs.
614    if let Some(ended) = at_end(run, requests) {
615        mark(&mut nodes, &build(run, &ended, now));
616    }
617    nodes
618}
619
620fn build(run: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Vec<Node> {
621    let saved = Saved { run, requests };
622    let states = States::new(&saved);
623    states
624        .index
625        .order
626        .iter()
627        .map(|id| artifact_node(&states, &saved, id, now))
628        .collect()
629}
630
631/// The requests as they stood when the Run ended, or `None` when nothing changed since
632/// (or the Run has not ended). A request that completed later goes back to waiting for its
633/// Human (or running), and a later claim is dropped; the Run's recorded evidence is fixed.
634fn at_end(run: &RunView, requests: &[RequestView]) -> Option<Vec<RequestView>> {
635    let end = run.run.completed_at()?;
636    let late = |time: Option<crate::types::Timestamp>| time.is_some_and(|time| time > end);
637    let changed = requests.iter().any(|view| {
638        late(view.request.completed_at()) || late(view.claim.as_ref().map(|claim| claim.claimed_at))
639    });
640    if !changed {
641        return None;
642    }
643    let mut ended = requests.to_vec();
644    for view in &mut ended {
645        if late(view.claim.as_ref().map(|claim| claim.claimed_at)) {
646            view.claim = None;
647        }
648        let request = &mut view.request;
649        if late(request.completed_at()) {
650            request.state = if request.profile.kind() == crate::config::ProfileKind::Human {
651                crate::store::RequestState::WaitingHuman
652            } else {
653                crate::store::RequestState::Running
654            };
655        }
656    }
657    Some(ended)
658}
659
660/// The state a row derives, without the root-cause note (an explanation, not a state).
661fn state(node: &Node) -> (Kind, &'static str, String) {
662    let kind = match &node.kind {
663        Kind::Eval(EvalView::WaitingOn(waits)) => Kind::Eval(EvalView::WaitingOn(Waits {
664            artifacts: waits.artifacts.clone(),
665            root: None,
666        })),
667        Kind::Eval(EvalView::NotRun(NotRun::Dependency(waits))) => {
668            Kind::Eval(EvalView::NotRun(NotRun::Dependency(Waits {
669                artifacts: waits.artifacts.clone(),
670                root: None,
671            })))
672        }
673        kind => kind.clone(),
674    };
675    // Artifact rows compare their whole summary; eval rows their state and glyph.
676    let summary = match node.kind {
677        Kind::Artifact { .. } => format!("{} {}", node.status(), node.right),
678        Kind::Eval(_) => String::new(),
679    };
680    (kind, node.glyph, summary)
681}
682
683/// Mark the rows whose derived state differs from the Run-end derivation.
684fn mark(nodes: &mut [Node], ended: &[Node]) {
685    let mut before = std::collections::HashMap::new();
686    for node in ended {
687        before.insert(&node.id, state(node));
688        for child in &node.children {
689            before.insert(&child.id, state(child));
690        }
691    }
692    for node in nodes {
693        node.changed = before.get(&node.id) != Some(&state(node));
694        for child in &mut node.children {
695            child.changed = before.get(&child.id) != Some(&state(child));
696        }
697    }
698}
699
700/// Every eval's direct dependency Artifacts, from the Run's saved definitions.
701pub fn upstream_index(run: &RunView, requests: &[RequestView]) -> Vec<(EvalId, Vec<ArtifactName>)> {
702    let saved = Saved { run, requests };
703    let index = super::states::Index::new(&saved);
704    index
705        .upstream
706        .iter()
707        .map(|(eval, artifacts)| {
708            (
709                (*eval).clone(),
710                artifacts.iter().map(|id| (*id).clone()).collect(),
711            )
712        })
713        .collect()
714}
715
716/// The Artifact detail's current status, rolled up like its tree row.
717pub(super) fn artifact_status(saved: &Saved, id: &ArtifactName, now: OffsetDateTime) -> String {
718    let states = States::new(saved);
719    let node = artifact_node(&states, saved, id, now);
720    let (_, _, word) = completion(states.completion(id));
721    let status = node.status();
722    let status = if status.is_empty() {
723        word.to_owned()
724    } else {
725        status
726    };
727    if node.right.is_empty() {
728        format!("{} {status}", node.glyph)
729    } else {
730        format!("{} {status} · {} Evals GREEN", node.glyph, node.right)
731    }
732}
733
734/// The eval detail's `Waits for`: every dependency Artifact in the tree's order, with its
735/// completion and why the eval depends on it, then its evals that are not done yet.
736pub(super) fn waits_for(saved: &Saved, eval: &EvalId, now: OffsetDateTime) -> String {
737    let states = States::new(saved);
738    let Some((&target, _)) = states
739        .index
740        .evals
741        .iter()
742        .find(|(_, evals)| evals.contains(&eval))
743    else {
744        return String::new();
745    };
746    let peers = |id: &ArtifactName| states.index.peers.get(id).cloned().unwrap_or_default();
747    let mut own = peers(target);
748    own.push(target);
749    let mut lines = Vec::new();
750    for upstream in states.upstream_states(eval) {
751        let (glyph, _, word) = completion(upstream.completion);
752        let id = &upstream.artifact;
753        let mut origins: Vec<String> = saved
754            .relations(id, false)
755            .into_iter()
756            .filter(|relation| own.contains(&&relation.target))
757            .map(|relation| {
758                let kind = super::relation_kind(relation);
759                if &relation.target == target {
760                    kind
761                } else {
762                    format!("{kind} → {}", relation.target)
763                }
764            })
765            .collect();
766        if origins.is_empty() && !peers(id).is_empty() {
767            origins.push(format!("↻ peer of {}", super::join(peers(id), ", ")));
768        }
769        let origins = if origins.is_empty() {
770            String::new()
771        } else {
772            format!(" · {}", origins.join(", "))
773        };
774        lines.push(format!("↑ {id} {glyph} {word}{origins}"));
775        if upstream.completion == Completion::Complete {
776            continue;
777        }
778        for &pending in states.evals(id) {
779            let node = eval_node(&states, pending, now);
780            if !matches!(node.kind, Kind::Eval(EvalView::Done(_))) {
781                lines.push(format!("  {} {}  {}", node.glyph, node.name, node.status()));
782            }
783        }
784    }
785    lines.join("\n")
786}