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)
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" | "derived" | "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        StoredProfile::Dependency { .. } => "dependency".into(),
291    }
292}
293
294fn text(states: &States, view: &EvalView, request: Option<&RequestView>) -> Vec<Segment> {
295    let plain = |text: &str| vec![Segment::plain(text)];
296    match view {
297        EvalView::Done(Source::Provisional) => plain("PROVISIONAL · sign-offs remain"),
298        EvalView::Done(Source::Executed) => plain("GREEN"),
299        EvalView::Done(Source::Reused) => plain("GREEN · reused"),
300        EvalView::Done(Source::Derived) => plain("GREEN · derived"),
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))
325            if request.is_some_and(|view| {
326                view.request.status() == crate::types::RequestStatus::Deferred
327            }) =>
328        {
329            plain("DEFERRED · Human sign-off · unclaimed")
330        }
331        EvalView::InProgress(Activity::Human(None)) => plain("Human sign-off · unclaimed"),
332        EvalView::InProgress(Activity::Human(Some(reviewer))) => vec![Segment::plain(format!(
333            "Human sign-off · claimed by {reviewer}"
334        ))],
335        EvalView::InProgress(Activity::Queued(Queue::Ready)) => plain("queued"),
336        EvalView::InProgress(Activity::Queued(Queue::Jobs)) => plain("queued · --jobs slots full"),
337        EvalView::InProgress(Activity::Queued(Queue::Slot(backend))) => {
338            vec![Segment::plain(format!("queued · {backend} slots full"))]
339        }
340        EvalView::InProgress(Activity::Queued(Queue::Joined)) => {
341            plain("queued · joins the running review")
342        }
343        EvalView::WaitingOn(x) => waits(states, "waits for ", x),
344        EvalView::BlockedBy(x) => {
345            let mut segments = vec![Segment::plain(if states.running {
346                "blocked by "
347            } else {
348                "not run: blocked by "
349            })];
350            segments.extend(tokens(states, x, true));
351            segments
352        }
353        EvalView::NotRun(NotRun::Dependency(x)) => waits(states, "not run: waited for ", x),
354        EvalView::NotRun(NotRun::Budget) => plain("not started · maxExecutions reached"),
355        EvalView::NotRun(NotRun::Stale) => plain("not run · no reusable result"),
356        EvalView::NotRun(NotRun::Unreviewed) => plain("not reviewed"),
357        EvalView::NotRun(NotRun::Absent) => plain("not in this Run"),
358    }
359}
360
361/// Start and end of executed, failed, running and Human evals; an open end runs to now.
362fn clock(view: &EvalView, request: Option<&RequestView>) -> Option<Clock> {
363    let timed = matches!(
364        view,
365        EvalView::Done(Source::Executed)
366            | EvalView::Failed { .. }
367            | EvalView::InProgress(Activity::Running | Activity::Human(_))
368    );
369    let request = &request.filter(|_| timed)?.request;
370    let start = request.started_at.unwrap_or(request.created_at).time();
371    let end = request.completed_at().map(crate::types::Timestamp::time);
372    Some(Clock { start, end })
373}
374
375fn compact(view: &EvalView, right: &str) -> String {
376    let x = match view {
377        EvalView::WaitingOn(waits) | EvalView::NotRun(NotRun::Dependency(waits)) => {
378            &waits.artifacts
379        }
380        EvalView::BlockedBy(x) => x,
381        _ => return right.to_owned(),
382    };
383    match x.as_slice() {
384        [one] => format!("←{one}"),
385        many => format!("←{}", many.len()),
386    }
387}
388
389fn local(eval: &EvalId) -> &str {
390    eval.rsplit_once('/')
391        .map_or(eval.as_str(), |(_, local)| local)
392}
393
394fn eval_node<'a>(states: &States<'a>, eval: &'a EvalId, now: OffsetDateTime) -> Node {
395    let view = states.view(eval);
396    let request = states.request(eval);
397    let (glyph, tone, weight) = style(&view);
398    let clock = clock(&view, request);
399    let right = clock.map_or_else(String::new, |clock| clock.elapsed(now));
400    Node {
401        clock,
402        id: super::NodeId::eval(eval.clone()),
403        glyph,
404        tone,
405        weight,
406        name: local(eval).to_owned(),
407        dependency: states.index.dependency.contains(eval),
408        marks: if states.index.dependency.contains(eval) {
409            "  [dep]".into()
410        } else {
411            String::new()
412        },
413        text: text(states, &view, request),
414        compact: compact(&view, &right),
415        right,
416        changed: false,
417        upstream: states.upstream_states(eval),
418        children: Vec::new(),
419        kind: Kind::Eval(view),
420    }
421}
422
423/// Up to two eval names, then `+N`.
424fn names(nodes: &[&Node]) -> String {
425    let mut names = nodes
426        .iter()
427        .take(SHOWN)
428        .map(|node| node.name.as_str())
429        .collect::<Vec<_>>()
430        .join(", ");
431    if nodes.len() > SHOWN {
432        names = format!("{names} +{}", nodes.len() - SHOWN);
433    }
434    names
435}
436
437/// One clause for the most urgent class of eval rows, plus in-progress ones after a failure.
438fn summary(states: &States, id: &ArtifactName, children: &[Node]) -> Vec<Segment> {
439    let view = |node: &Node| match &node.kind {
440        Kind::Eval(view) => Some(view.clone()),
441        Kind::Artifact { .. } => None,
442    };
443    let Some(top) = children
444        .iter()
445        .filter_map(view)
446        .map(|view| view.rank())
447        .min()
448    else {
449        return Vec::new();
450    };
451    let class = |rank: Rank| -> Vec<&Node> {
452        children
453            .iter()
454            .filter(|node| view(node).is_some_and(|view| view.rank() == rank))
455            .collect()
456    };
457    let x = |ids: Vec<crate::types::ArtifactName>| tokens(states, &ids, false);
458    let mut segments = match top {
459        Rank::Failed => {
460            let failed = class(Rank::Failed);
461            let errors: Vec<_> = failed
462                .iter()
463                .copied()
464                .filter(|node| node.kind == Kind::Eval(EvalView::Failed { verdict: false }))
465                .collect();
466            let (label, nodes) = if errors.is_empty() {
467                ("RED", failed)
468            } else {
469                ("ERROR", errors)
470            };
471            vec![Segment::plain(format!("{label}: {}", names(&nodes)))]
472        }
473        Rank::InProgress => vec![Segment::plain(format!(
474            "in progress: {}",
475            names(&class(Rank::InProgress))
476        ))],
477        Rank::Blocked => {
478            let mut segments = vec![Segment::plain(if states.running {
479                "blocked by "
480            } else {
481                "not run: blocked by "
482            })];
483            segments.extend(x(states.blockers_of(id)));
484            segments
485        }
486        Rank::Waiting => {
487            let mut segments = vec![Segment::plain("waits for ")];
488            segments.extend(x(states.waits_of(id)));
489            segments
490        }
491        Rank::NotRun => {
492            let first = class(Rank::NotRun)[0];
493            match &first.kind {
494                Kind::Eval(EvalView::NotRun(NotRun::Dependency(_))) => {
495                    let mut segments = vec![Segment::plain("not run: waited for ")];
496                    segments.extend(x(states.waits_of(id)));
497                    segments
498                }
499                _ => first.text.clone(),
500            }
501        }
502        // Every eval row is done, but graph.rs still holds them on their own gates.
503        Rank::Done => match states.completion(id) {
504            Completion::Waiting { blocked: true } => {
505                let mut segments = vec![Segment::plain("done, but blocked by ")];
506                segments.extend(x(states.blockers_of(id)));
507                segments
508            }
509            Completion::Waiting { blocked: false } => {
510                let mut segments = vec![Segment::plain("done, but waits for ")];
511                segments.extend(x(states.waits_of(id)));
512                segments
513            }
514            _ => Vec::new(),
515        },
516    };
517    if top == Rank::Failed {
518        let busy = class(Rank::InProgress);
519        if !busy.is_empty() {
520            segments.push(Segment::plain(format!(" · in progress: {}", names(&busy))));
521        }
522    }
523    segments
524}
525
526fn artifact_node<'a>(
527    states: &States<'a>,
528    saved: &Saved<'a>,
529    id: &ArtifactName,
530    now: OffsetDateTime,
531) -> Node {
532    let artifact = saved.artifact(id);
533    let basis = artifact.and_then(|artifact| artifact.basis.value()) == Some(&true);
534    let children: Vec<_> = states
535        .evals(id)
536        .iter()
537        .map(|&eval| eval_node(states, eval, now))
538        .collect();
539    let mut marks = String::new();
540    if states.index.cyclic.contains(id) {
541        let peers = states.index.peers.get(id).map_or(&[][..], Vec::as_slice);
542        marks = match peers {
543            [] => "  ↻".into(),
544            [peer] => format!("  ↻ {peer}"),
545            [peer, rest @ ..] => format!("  ↻ {peer} +{}", rest.len()),
546        };
547    }
548    if artifact.and_then(|artifact| artifact.kind.value())
549        == Some(&crate::config::ArtifactKind::File)
550    {
551        marks.push_str("  [file]");
552    }
553    let completion = states.completion(id);
554    // GREEN as the graph counts it: a done eval held on its own gates does not pass.
555    let passed = states
556        .evals(id)
557        .iter()
558        .filter(|eval| states.effective(eval) == crate::graph::EvalStatus::Green)
559        .count();
560    let right = if children.is_empty() {
561        String::new()
562    } else {
563        format!("{passed}/{}", children.len())
564    };
565    let (glyph, tone, weight, text) = if basis {
566        ("◇", Tone::Muted, Weight::Dim, vec![Segment::plain("basis")])
567    } else if children.is_empty() {
568        (
569            "○",
570            Tone::Muted,
571            Weight::Dim,
572            vec![Segment::plain("no evals")],
573        )
574    } else {
575        // The row takes the glyph of its most urgent eval row; ERROR before RED.
576        let top = children
577            .iter()
578            .min_by_key(|node| match &node.kind {
579                Kind::Eval(EvalView::Failed { verdict: false }) => (Rank::Failed, false),
580                Kind::Eval(view) => (view.rank(), true),
581                Kind::Artifact { .. } => (Rank::Done, true),
582            })
583            .expect("children");
584        let (glyph, tone, weight) = match (&top.kind, completion) {
585            (_, Completion::InProgress(busy)) => {
586                let (glyph, tone, _) = self::completion(Completion::InProgress(busy));
587                (glyph, tone, Weight::Bold)
588            }
589            // Every eval row is done, yet the graph holds them on their own gates.
590            (Kind::Eval(EvalView::Done(_)), Completion::Waiting { blocked }) => {
591                let (glyph, tone, _) = self::completion(Completion::Waiting { blocked });
592                let weight = if blocked { Weight::Normal } else { Weight::Dim };
593                (glyph, tone, weight)
594            }
595            _ => (top.glyph, top.tone, top.weight),
596        };
597        (glyph, tone, weight, summary(states, id, &children))
598    };
599    Node {
600        id: super::NodeId::artifact(id.clone()),
601        clock: None,
602        kind: Kind::Artifact { completion, basis },
603        glyph,
604        tone,
605        weight,
606        name: id.to_string(),
607        marks,
608        text,
609        compact: right.clone(),
610        right,
611        changed: false,
612        upstream: Vec::new(),
613        dependency: false,
614        children,
615    }
616}
617
618/// Every saved or requested Artifact appears exactly once, upstream first where the graph
619/// allows: dependency-first component order, cycle peers by name.
620pub fn tree(run: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Vec<Node> {
621    let mut nodes = build(run, requests, now);
622    // A finished Run: derive the same snapshot once more as of its end and mark every row
623    // whose derived state differs.
624    if let Some(ended) = at_end(run, requests) {
625        mark(&mut nodes, &build(run, &ended, now));
626    }
627    nodes
628}
629
630fn build(run: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Vec<Node> {
631    let saved = Saved { run, requests };
632    let states = States::new(&saved);
633    states
634        .index
635        .order
636        .iter()
637        .map(|id| artifact_node(&states, &saved, id, now))
638        .collect()
639}
640
641/// The requests as they stood when the Run ended, or `None` when nothing changed since
642/// (or the Run has not ended). A request that completed later goes back to waiting for its
643/// Human (or running), and a later claim is dropped; the Run's recorded evidence is fixed.
644fn at_end(run: &RunView, requests: &[RequestView]) -> Option<Vec<RequestView>> {
645    let end = run.run.completed_at()?;
646    let late = |time: Option<crate::types::Timestamp>| time.is_some_and(|time| time > end);
647    let changed = requests.iter().any(|view| {
648        late(view.request.completed_at()) || late(view.claim.as_ref().map(|claim| claim.claimed_at))
649    });
650    if !changed {
651        return None;
652    }
653    let mut ended = requests.to_vec();
654    for view in &mut ended {
655        if late(view.claim.as_ref().map(|claim| claim.claimed_at)) {
656            view.claim = None;
657        }
658        let request = &mut view.request;
659        if late(request.completed_at()) {
660            request.state = if request.profile.kind() == crate::config::ProfileKind::Human {
661                if run.run.modes.defer_human
662                    && request.profile.kind() == crate::config::ProfileKind::Human
663                    && run
664                        .run
665                        .definitions
666                        .graph()
667                        .and_then(|graph| {
668                            graph.evals().iter().find(|eval| eval.id == request.eval_id)
669                        })
670                        .and_then(|eval| eval.declaration.value())
671                        .is_some_and(|declaration| {
672                            declaration.gate.value() != Some(&crate::config::Gate::Blocking)
673                        })
674                {
675                    crate::store::RequestState::Deferred
676                } else {
677                    crate::store::RequestState::WaitingHuman
678                }
679            } else {
680                crate::store::RequestState::Running
681            };
682        }
683    }
684    Some(ended)
685}
686
687/// The state a row derives, without the root-cause note (an explanation, not a state).
688fn state(node: &Node) -> (Kind, &'static str, String) {
689    let kind = match &node.kind {
690        Kind::Eval(EvalView::WaitingOn(waits)) => Kind::Eval(EvalView::WaitingOn(Waits {
691            artifacts: waits.artifacts.clone(),
692            root: None,
693        })),
694        Kind::Eval(EvalView::NotRun(NotRun::Dependency(waits))) => {
695            Kind::Eval(EvalView::NotRun(NotRun::Dependency(Waits {
696                artifacts: waits.artifacts.clone(),
697                root: None,
698            })))
699        }
700        kind => kind.clone(),
701    };
702    // Artifact rows compare their whole summary; eval rows their state and glyph.
703    let summary = match node.kind {
704        Kind::Artifact { .. } => format!("{} {}", node.status(), node.right),
705        Kind::Eval(_) => String::new(),
706    };
707    (kind, node.glyph, summary)
708}
709
710/// Mark the rows whose derived state differs from the Run-end derivation.
711fn mark(nodes: &mut [Node], ended: &[Node]) {
712    let mut before = std::collections::HashMap::new();
713    for node in ended {
714        before.insert(&node.id, state(node));
715        for child in &node.children {
716            before.insert(&child.id, state(child));
717        }
718    }
719    for node in nodes {
720        node.changed = before.get(&node.id) != Some(&state(node));
721        for child in &mut node.children {
722            child.changed = before.get(&child.id) != Some(&state(child));
723        }
724    }
725}
726
727/// Every eval's direct dependency Artifacts, from the Run's saved definitions.
728pub fn upstream_index(run: &RunView, requests: &[RequestView]) -> Vec<(EvalId, Vec<ArtifactName>)> {
729    let saved = Saved { run, requests };
730    let index = super::states::Index::new(&saved);
731    index
732        .upstream
733        .iter()
734        .map(|(eval, artifacts)| {
735            (
736                (*eval).clone(),
737                artifacts.iter().map(|id| (*id).clone()).collect(),
738            )
739        })
740        .collect()
741}
742
743/// The Artifact detail's current status, rolled up like its tree row.
744pub(super) fn artifact_status(saved: &Saved, id: &ArtifactName, now: OffsetDateTime) -> String {
745    let states = States::new(saved);
746    let node = artifact_node(&states, saved, id, now);
747    let (_, _, word) = completion(states.completion(id));
748    let status = node.status();
749    let status = if status.is_empty() {
750        word.to_owned()
751    } else {
752        status
753    };
754    if node.right.is_empty() {
755        format!("{} {status}", node.glyph)
756    } else {
757        format!("{} {status} · {} Evals GREEN", node.glyph, node.right)
758    }
759}
760
761/// The eval detail's `Waits for`: every dependency Artifact in the tree's order, with its
762/// completion and why the eval depends on it, then its evals that are not done yet.
763pub(super) fn waits_for(saved: &Saved, eval: &EvalId, now: OffsetDateTime) -> String {
764    let states = States::new(saved);
765    let Some((&target, _)) = states
766        .index
767        .evals
768        .iter()
769        .find(|(_, evals)| evals.contains(&eval))
770    else {
771        return String::new();
772    };
773    let peers = |id: &ArtifactName| states.index.peers.get(id).cloned().unwrap_or_default();
774    let mut own = peers(target);
775    own.push(target);
776    let mut lines = Vec::new();
777    for upstream in states.upstream_states(eval) {
778        let (glyph, _, word) = completion(upstream.completion);
779        let id = &upstream.artifact;
780        let mut origins: Vec<String> = saved
781            .relations(id, false)
782            .into_iter()
783            .filter(|relation| own.contains(&&relation.target))
784            .map(|relation| {
785                let kind = super::relation_kind(relation);
786                if &relation.target == target {
787                    kind
788                } else {
789                    format!("{kind} → {}", relation.target)
790                }
791            })
792            .collect();
793        if origins.is_empty() && !peers(id).is_empty() {
794            origins.push(format!("↻ peer of {}", super::join(peers(id), ", ")));
795        }
796        let origins = if origins.is_empty() {
797            String::new()
798        } else {
799            format!(" · {}", origins.join(", "))
800        };
801        lines.push(format!("↑ {id} {glyph} {word}{origins}"));
802        if upstream.completion == Completion::Complete {
803            continue;
804        }
805        for &pending in states.evals(id) {
806            let node = eval_node(&states, pending, now);
807            if !matches!(node.kind, Kind::Eval(EvalView::Done(_))) {
808                lines.push(format!("  {} {}  {}", node.glyph, node.name, node.status()));
809            }
810        }
811    }
812    lines.join("\n")
813}