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(crate) held: 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" | "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
359fn 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
417fn 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
431fn 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 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 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 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 (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
612pub fn tree(run: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Vec<Node> {
615 let mut nodes = build(run, requests, now);
616 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
635fn 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
681fn 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 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
704fn 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
721pub 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
737pub(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
755pub(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
768pub(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}