1use 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Weight {
30 Bold,
31 Normal,
32 Dim,
33}
34
35#[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 Artifact {
61 completion: Completion,
62 basis: bool,
63 },
64 Eval(EvalView),
65}
66
67#[derive(Debug, Clone)]
68pub struct Node {
69 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 pub marks: String,
78 pub text: Vec<Segment>,
79 pub right: String,
81 pub compact: String,
83 pub changed: bool,
85 pub upstream: Vec<Upstream>,
87 pub dependency: bool,
89 pub clock: Option<Clock>,
91 pub children: Vec<Node>,
92}
93
94#[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 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 pub fn status(&self) -> String {
119 self.text
120 .iter()
121 .map(|segment| segment.text.as_str())
122 .collect()
123 }
124
125 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 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 pub fn urgent(&self) -> bool {
152 matches!(
153 self.kind,
154 Kind::Eval(EvalView::Failed { .. } | EvalView::InProgress(Activity::Human(_)))
155 )
156 }
157
158 pub fn attention(&self) -> bool {
160 matches!(
161 self.kind,
162 Kind::Eval(EvalView::InProgress(_) | EvalView::Failed { .. })
163 )
164 }
165}
166
167pub 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
181fn 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
199const SHOWN: usize = 2;
201const SHOWN_TEXT: usize = 24;
204
205fn 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
245fn 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
361fn 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
423fn 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
437fn 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 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 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 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 (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
618pub fn tree(run: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Vec<Node> {
621 let mut nodes = build(run, requests, now);
622 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
641fn 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
687fn 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 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
710fn 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
727pub 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
743pub(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
761pub(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}