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