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