1mod graph;
3pub use graph::{Graph, GraphEdge, GraphNode, Light, graph};
4mod node_id;
5pub use node_id::NodeId;
6mod states;
7mod status;
8#[cfg(test)]
9mod tests;
10pub(crate) mod tree;
11pub use states::{Activity, Busy, Completion, EvalView, NotRun, Queue, Source, Upstream, Waits};
12pub use status::Status;
13pub use tree::{Clock, Kind, Node, Segment, Tone, Weight, completion, tree, upstream_index};
14
15use serde_json::Value;
16use time::OffsetDateTime;
17
18use crate::{
19 query,
20 store::{RequestView, RunSummary, RunView},
21 types::{ArtifactName, EvalId},
22};
23
24#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
26pub enum Target {
27 Run,
29 Artifact(crate::types::ArtifactName),
30 Eval(crate::types::EvalId),
31}
32
33impl Target {
34 pub fn parse(id: &str) -> Option<Self> {
35 let (kind, name) = id.split_once(':')?;
36 match kind {
37 "run" => name.parse::<crate::types::RunId>().ok().map(|_| Self::Run),
38 "a" => name.parse().ok().map(Self::Artifact),
39 "e" => name.parse().ok().map(Self::Eval),
40 _ => None,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
47pub enum Section {
48 Outcome,
49 Waits,
51 What,
52 Provenance,
53 Technical,
54}
55
56impl Section {
57 pub const ALL: [Self; 5] = [
58 Self::Outcome,
59 Self::Waits,
60 Self::What,
61 Self::Provenance,
62 Self::Technical,
63 ];
64
65 pub fn of(key: &str) -> Self {
67 match key {
68 "Status" | "Error" | "Reason" | "Blocked by" | "Result" | "Claim" | "Source"
69 | "At Run end" | "Validation" | "Counts" | "Errors" | "Waiting" | "Running"
70 | "Evals" | "Gates" => Self::Outcome,
71 "Waits for" => Self::Waits,
72 "Timing" | "Usage" | "Budget" | "Work" | "Saved usage" => Self::Provenance,
73 "Execution" | "Fingerprint" | "Key" | "Key covers" | "Path" | "Tags" | "Cycle"
74 | "Run" | "Repository" => Self::Technical,
75 _ => Self::What,
76 }
77 }
78
79 pub fn name(self) -> &'static str {
80 match self {
81 Self::Outcome => "Outcome",
82 Self::Waits => "Waits for",
83 Self::What => "What",
84 Self::Provenance => "Provenance",
85 Self::Technical => "Technical",
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct RunRow {
92 pub id: crate::types::RunId,
93 pub repo: std::path::PathBuf,
94 pub status: crate::types::RunStatus,
95 pub counts: String,
97 pub age: String,
98 pub took: String,
100 pub comment: String,
102}
103
104#[derive(Debug, Clone)]
105pub struct Progress {
106 pub status: Status,
107 pub repo: String,
108 pub comment: Option<String>,
110 pub validation: String,
111 pub timing: String,
112 pub elapsed: String,
114 pub finished: bool,
115 pub satisfied: Option<bool>,
117 pub counts: Vec<(Status, u64)>,
118 pub budget: String,
120 pub work: String,
122 pub usage: String,
124 pub saved: String,
125 pub tokens: Option<u64>,
127 pub running: Vec<(String, String)>,
129 pub waiting: Vec<(String, String)>,
131 pub errors: Vec<(String, String)>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Strip {
138 pub status: Status,
139 pub headline: String,
140 pub attention: Vec<(Status, String)>,
142 pub more: usize,
144}
145
146#[derive(Debug, Clone, Default)]
147pub struct Detail {
148 pub title: String,
149 pub summary: String,
151 pub fields: Vec<(&'static str, String)>,
152}
153
154impl Detail {
155 pub fn field(&self, name: &str) -> Option<&str> {
156 self.fields
157 .iter()
158 .find(|(key, _)| *key == name)
159 .map(|(_, value)| value.as_str())
160 }
161
162 fn push(&mut self, key: &'static str, value: impl Into<String>) {
163 let value = value.into();
164 if !value.is_empty() {
165 self.fields.push((key, value));
166 }
167 }
168
169 fn insert_after(&mut self, after: &str, key: &'static str, value: String) {
170 if value.is_empty() {
171 return;
172 }
173 let index = self.fields.iter().position(|(name, _)| *name == after);
174 let index = index.map_or(self.fields.len(), |index| index + 1);
175 self.fields.insert(index, (key, value));
176 }
177}
178
179const ATTENTION_LINES: usize = 3;
181
182const ABSENT: &str = "not in Run";
183
184pub fn glyph(status: Option<Status>) -> &'static str {
186 status.map_or("-", Status::glyph)
187}
188
189fn join<T: AsRef<str>>(items: impl IntoIterator<Item = T>, separator: &str) -> String {
190 let items: Vec<_> = items
191 .into_iter()
192 .map(|item| item.as_ref().to_owned())
193 .collect();
194 items.join(separator)
195}
196
197fn mark(name: &EvalId, status: Option<Status>) -> String {
198 format!(
199 "{} {name} {}",
200 glyph(status),
201 status.map_or(ABSENT, Status::as_str)
202 )
203}
204
205const MINUTE: i64 = 60;
207const HOUR: i64 = 60 * MINUTE;
208const DAY: i64 = 24 * HOUR;
209
210pub fn duration(elapsed: time::Duration) -> String {
211 let seconds = elapsed.whole_seconds().max(0);
212 match seconds {
213 0..MINUTE => format!("{seconds}s"),
214 MINUTE..HOUR => format!("{}m {:02}s", seconds / MINUTE, seconds % MINUTE),
215 HOUR..DAY => format!("{}h {:02}m", seconds / HOUR, seconds % HOUR / MINUTE),
216 _ => format!("{}d {}h", seconds / DAY, seconds % DAY / HOUR),
217 }
218}
219
220#[cfg(test)]
221#[test]
222fn typed_durations_keep_the_display_units_and_clamp_clock_skew() {
223 for (elapsed, expected) in [
224 (time::Duration::seconds(-1), "0s"),
225 (time::Duration::ZERO, "0s"),
226 (time::Duration::milliseconds(59_999), "59s"),
227 (time::Duration::minutes(1), "1m 00s"),
228 (time::Duration::seconds(125), "2m 05s"),
229 (time::Duration::hours(1), "1h 00m"),
230 (time::Duration::minutes(61), "1h 01m"),
231 (time::Duration::days(1), "1d 0h"),
232 (time::Duration::hours(25), "1d 1h"),
233 ] {
234 assert_eq!(duration(elapsed), expected);
235 }
236}
237
238pub(crate) fn attempt_tokens(attempts: &[crate::llm::Attempt]) -> Option<u64> {
241 let tokens = attempts
242 .iter()
243 .filter_map(|attempt| attempt.usage.get("totalTokens"))
244 .fold(0u64, |total, tokens| total.saturating_add(*tokens));
245 (tokens > 0).then_some(tokens)
246}
247
248#[test]
249fn attempt_tokens_saturate_on_saved_counts() {
250 let attempt = |tokens: u64| -> crate::llm::Attempt {
251 serde_json::from_value(serde_json::json!({ "usage": { "totalTokens": tokens } }))
252 .expect("a saved attempt")
253 };
254 assert_eq!(attempt_tokens(&[]), None);
255 assert_eq!(attempt_tokens(&[attempt(0)]), None);
256 assert_eq!(attempt_tokens(&[attempt(14), attempt(8)]), Some(22));
257 assert_eq!(
258 attempt_tokens(&[attempt(u64::MAX), attempt(1)]),
259 Some(u64::MAX)
260 );
261}
262
263pub(crate) fn span(
265 start: crate::types::Timestamp,
266 end: Option<crate::types::Timestamp>,
267 now: OffsetDateTime,
268) -> Option<String> {
269 let end = end.map_or(now, crate::types::Timestamp::time);
270 Some(duration(end - start.time()))
271}
272
273pub fn counts(counts: impl IntoIterator<Item = (Status, u64)>) -> String {
275 let mut counts: Vec<_> = counts.into_iter().filter(|(_, n)| *n > 0).collect();
276 counts.sort_by_key(|(status, _)| *status);
277 join(
278 counts
279 .into_iter()
280 .map(|(status, n)| format!("{}{n}", status.glyph())),
281 " ",
282 )
283}
284
285pub fn tokens(total: u64) -> String {
287 const THOUSAND: u64 = 1_000;
289 const MILLION: u64 = 1_000_000;
290 match total {
291 0..THOUSAND => total.to_string(),
292 THOUSAND..MILLION => format!("{:.1}k", total as f64 / THOUSAND as f64),
293 _ => format!("{:.1}M", total as f64 / MILLION as f64),
294 }
295}
296
297pub fn run_rows(runs: &[RunSummary], now: OffsetDateTime) -> Vec<RunRow> {
298 runs.iter()
299 .map(|run| RunRow {
300 id: run.id.clone(),
301 repo: run.repo_path.clone(),
302 status: run.status,
303 counts: counts(run.counts.iter().map(|(status, n)| ((*status).into(), *n))),
304 age: span(run.created_at, None, now).unwrap_or_default(),
305 took: run
306 .completed_at
307 .and_then(|end| span(run.created_at, Some(end), now))
308 .unwrap_or_default(),
309 comment: run.comment.as_deref().unwrap_or_default().to_owned(),
310 })
311 .collect()
312}
313
314fn reused(view: &RequestView) -> bool {
315 query::reused(&view.request)
316}
317
318fn elapsed(view: &RequestView, now: OffsetDateTime) -> Option<String> {
319 let request = &view.request;
320 if reused(view) {
321 return Some("reused".into());
322 }
323 let active = matches!(
324 request.status(),
325 crate::types::RequestStatus::Running
326 | crate::types::RequestStatus::WaitingHuman
327 | crate::types::RequestStatus::Deferred
328 );
329 let start = request
330 .started_at
331 .or(active.then_some(request.created_at))?;
332 span(start, request.completed_at(), now)
333}
334
335fn claim(view: &RequestView) -> String {
336 view.claim.as_ref().map_or("unclaimed".into(), |claim| {
337 format!("claimed by {} at {}", claim.reviewer, claim.claimed_at)
338 })
339}
340
341fn error(view: &RequestView) -> Option<String> {
342 let request = &view.request;
343 let error = request.error().map(str::to_owned)?;
344 Some(match &request.error_code() {
345 Some(code) => format!("[{code}] {error}"),
346 None => error,
347 })
348}
349
350pub fn progress(view: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Progress {
351 let run = &view.run;
352 let (summary, saved_usage) = query::run_summary(view, now);
353 let with = |status: crate::types::RequestStatus| {
354 requests
355 .iter()
356 .filter(move |view| view.request.status() == status)
357 };
358 let usage = counter_pairs(&summary.usage.usage);
359 let saved = counter_pairs(&saved_usage);
360 let spent = &summary.usage.usage;
361 let tokens = spent.get("totalTokens").copied().or_else(|| {
362 let input = spent.get("inputTokens").copied();
363 let output = spent.get("outputTokens").copied();
364 (input.is_some() || output.is_some())
365 .then(|| input.unwrap_or(0).saturating_add(output.unwrap_or(0)))
366 });
367 let validation = run.validation.snapshot();
368 let unmet = validation
369 .and_then(|v| v.obligations.value())
370 .map(|ids| {
371 ids.iter()
372 .map(ToString::to_string)
373 .collect::<Vec<_>>()
374 .join(", ")
375 })
376 .unwrap_or_default();
377 let wall = span(run.created_at, run.completed_at(), now).unwrap_or_default();
378 Progress {
379 status: run.status().into(),
380 repo: crate::platform::path_text(&run.repo_path),
381 comment: run.comment.as_ref().map(ToString::to_string),
382 validation: match validation.and_then(|v| v.satisfied.value()) {
384 None => "pending".into(),
385 Some(true) => "SATISFIED at Run end".into(),
386 Some(_) if unmet.is_empty() => "NOT SATISFIED at Run end".into(),
387 Some(_) => format!("NOT SATISFIED at Run end (unmet: {unmet})"),
388 },
389 timing: format!(
390 "started {} · {} {wall}",
391 run.created_at,
392 if run.completed_at().is_some() {
393 "took"
394 } else {
395 "elapsed"
396 },
397 ),
398 elapsed: wall,
399 finished: run.completed_at().is_some(),
400 satisfied: validation.and_then(|v| v.satisfied.value()).copied(),
401 counts: summary
402 .counts
403 .iter()
404 .map(|(status, count)| ((*status).into(), *count))
405 .collect(),
406 budget: format!(
407 "executions {}/{} · jobs {}",
408 run.executions_started,
409 run.max_executions
410 .map_or("unlimited".into(), |max| max.to_string()),
411 run.jobs
412 .map_or("unlimited".into(), |limit| limit.to_string()),
413 ),
414 work: format!(
415 "executed {} · reused {}",
416 summary.executed.total, summary.reused.kinds.total,
417 ),
418 usage,
419 saved,
420 tokens,
421 running: with(crate::types::RequestStatus::Running)
422 .map(|view| {
423 (
424 view.request.eval_id.to_string(),
425 elapsed(view, now).unwrap_or_default(),
426 )
427 })
428 .collect(),
429 waiting: requests
430 .iter()
431 .filter(|view| view.request.status().is_human_wait())
432 .map(|view| (view.request.eval_id.to_string(), claim(view)))
433 .collect(),
434 errors: with(crate::types::RequestStatus::Error)
435 .map(|view| {
436 (
437 view.request.eval_id.to_string(),
438 error(view).unwrap_or_default(),
439 )
440 })
441 .chain(
442 run.error()
443 .map(str::to_owned)
444 .map(|error| ("Run".into(), error)),
445 )
446 .chain(
447 view.unreadable
448 .iter()
449 .map(|record| (record.id.clone(), record.to_string())),
450 )
451 .collect(),
452 }
453}
454
455pub fn strip(progress: &Progress) -> Strip {
457 let validation = match progress.satisfied {
458 None => "validation pending".to_owned(),
459 Some(true) => "SATISFIED at Run end".to_owned(),
460 Some(false) => "NOT SATISFIED at Run end".to_owned(),
461 };
462 let counts = counts(progress.counts.iter().copied());
463 let time = format!(
464 "{} {}",
465 if progress.finished { "took" } else { "elapsed" },
466 progress.elapsed
467 );
468 let tokens = progress
469 .tokens
470 .map(|total| format!("tokens {}", tokens(total)));
471 let headline = join(
472 [Some(validation), Some(counts), Some(time), tokens]
473 .into_iter()
474 .flatten()
475 .filter(|part| !part.is_empty()),
476 " · ",
477 );
478 let mut attention: Vec<(Status, String)> = progress
479 .errors
480 .iter()
481 .map(|(eval, error)| (Status::Error, format!("{eval} {error}")))
482 .chain(progress.waiting.iter().map(|(eval, claim)| {
483 (
484 Status::WaitingHuman,
485 format!("{eval} waiting Human · {claim}"),
486 )
487 }))
488 .chain(progress.running.iter().map(|(eval, time)| {
489 (
490 Status::Running,
491 format!("{eval} running {time}").trim_end().to_owned(),
492 )
493 }))
494 .collect();
495 let more = attention.len().saturating_sub(ATTENTION_LINES);
496 attention.truncate(ATTENTION_LINES);
497 Strip {
498 status: progress.status,
499 headline,
500 attention,
501 more,
502 }
503}
504
505pub fn run_node(run: &RunView) -> Node {
507 let status = Status::from(run.run.status());
508 Node {
509 id: NodeId::run(&run.run.id),
510 clock: None,
511 kind: Kind::Artifact {
512 completion: Completion::Complete,
513 basis: false,
514 },
515 glyph: status.glyph(),
516 tone: match status {
517 Status::Green => Tone::Green,
518 Status::Red => Tone::Red,
519 Status::Error => Tone::Error,
520 Status::Running => Tone::Running,
521 Status::Blocked | Status::BudgetExhausted | Status::Incomplete => Tone::Blocked,
522 _ => Tone::Muted,
523 },
524 weight: Weight::Normal,
525 name: "Run".into(),
526 marks: String::new(),
527 text: vec![Segment {
528 text: "Enter: usage, budgets, counts".into(),
529 tone: Some(Tone::Muted),
530 }],
531 right: String::new(),
532 compact: String::new(),
533 changed: false,
534 upstream: Vec::new(),
535 held: false,
536 parts: Vec::new(),
537 children: Vec::new(),
538 }
539}
540
541fn result_summary(result: &crate::store::ExecutionResult) -> String {
544 let result = result.to_json();
545 let Some(object) = result.as_object() else {
546 return result
547 .to_string()
548 .lines()
549 .next()
550 .unwrap_or_default()
551 .to_owned();
552 };
553 let mut entries: Vec<_> = object
555 .iter()
556 .filter(|(key, _)| !matches!(key.as_str(), "stdout" | "stderr"))
557 .collect();
558 entries.sort_by_key(|(key, _)| *key != "verdict");
559 let parts = entries.into_iter().map(|(key, value)| match value {
560 Value::String(text) => {
561 let line = text.lines().next().unwrap_or_default();
562 if line.len() < text.trim_end().len() {
563 format!("{key} {line} …")
564 } else {
565 format!("{key} {line}")
566 }
567 }
568 Value::Array(items) => format!("{key} [{} items]", items.len()),
569 Value::Object(fields) => format!("{key} {{{} fields}}", fields.len()),
570 other => format!("{key} {other}"),
571 });
572 join(parts, "\n")
573}
574
575struct Saved<'a> {
577 run: &'a RunView,
578 requests: &'a [RequestView],
579}
580
581impl<'a> Saved<'a> {
582 fn definitions(&self) -> Option<&'a crate::store::definitions::Graph> {
583 self.run.run.definitions.graph()
584 }
585 fn artifacts(
586 &self,
587 ) -> impl Iterator<
588 Item = (
589 &'a crate::types::ArtifactName,
590 &'a crate::store::definitions::Artifact,
591 ),
592 > {
593 self.definitions()
594 .into_iter()
595 .flat_map(|graph| graph.artifacts())
596 }
597 fn artifact(&self, id: &ArtifactName) -> Option<&'a crate::store::definitions::Artifact> {
598 self.definitions().and_then(|graph| graph.artifact(id))
599 }
600 fn eval_definitions(&self) -> &'a [crate::store::definitions::Eval] {
601 self.definitions().map_or(&[], |graph| graph.evals())
602 }
603 fn components(&self) -> &'a [crate::store::definitions::Component] {
604 self.definitions().map_or(&[], |graph| graph.components())
605 }
606
607 fn request(&self, eval: &EvalId) -> Option<&'a RequestView> {
608 self.requests
609 .iter()
610 .find(|view| &view.request.eval_id == eval)
611 }
612
613 fn status(&self, eval: &EvalId) -> Option<Status> {
614 self.request(eval).map(|view| view.request.status().into())
615 }
616
617 fn artifact_ids(&self) -> Vec<&'a ArtifactName> {
620 let components = self.components().iter();
621 let saved = components
622 .flat_map(|component| {
623 let mut members: Vec<_> =
624 component.artifacts.value().into_iter().flatten().collect();
625 members.sort_unstable();
626 members
627 })
628 .chain(self.artifacts().map(|(id, _)| id))
629 .chain(self.requests.iter().map(|view| &view.request.target));
630 let mut seen = std::collections::HashSet::new();
631 saved.filter(|id| seen.insert(*id)).collect()
632 }
633
634 fn evals(
636 &self,
637 artifact: &ArtifactName,
638 ) -> Vec<(&'a EvalId, Option<&'a crate::store::definitions::Eval>)> {
639 let mut evals: Vec<_> = self
640 .eval_definitions()
641 .iter()
642 .filter(|eval| &eval.target == artifact)
643 .map(|eval| (&eval.id, Some(eval)))
644 .collect();
645 for request in self.requests.iter().map(|view| &view.request) {
646 if &request.target == artifact && !evals.iter().any(|(id, _)| *id == &request.eval_id) {
647 evals.push((&request.eval_id, None));
648 }
649 }
650 evals
651 }
652
653 fn validation(&self, artifact: &ArtifactName) -> Option<&'a crate::store::ArtifactValidation> {
654 self.run
655 .run
656 .validation
657 .snapshot()?
658 .artifacts
659 .value()?
660 .iter()
661 .find(|a| &a.id == artifact)
662 }
663
664 fn component(&self, id: &ArtifactName) -> Option<&'a crate::store::definitions::Component> {
665 self.components().iter().find(|component| {
666 component
667 .artifacts
668 .value()
669 .is_some_and(|artifacts| artifacts.iter().any(|artifact| artifact == id))
670 })
671 }
672
673 fn parts(&self) -> std::collections::BTreeMap<&'a ArtifactName, Vec<&'a ArtifactName>> {
675 let mut parts: std::collections::BTreeMap<_, Vec<_>> = Default::default();
676 for relation in self
677 .definitions()
678 .into_iter()
679 .flat_map(|graph| graph.relations())
680 {
681 if matches!(relation.kind, crate::store::definitions::RelationKind::Part) {
682 parts
683 .entry(&relation.target)
684 .or_default()
685 .push(&relation.source);
686 }
687 }
688 parts
689 }
690
691 fn relations(
692 &self,
693 artifact: &ArtifactName,
694 input: bool,
695 ) -> Vec<&'a crate::store::definitions::Relation> {
696 self.definitions()
697 .into_iter()
698 .flat_map(|graph| graph.relations())
699 .filter(|relation| {
700 if input {
701 &relation.target == artifact
702 } else {
703 &relation.source == artifact
704 }
705 })
706 .collect()
707 }
708}
709
710fn relation_kind(relation: &crate::store::definitions::Relation) -> String {
711 use crate::store::definitions::RelationKind;
712 let kind = match &relation.kind {
713 RelationKind::Unknown { .. } => serde_json::to_string(relation).unwrap_or_default(),
714 RelationKind::Part => "part".into(),
715 RelationKind::Child { path } => format!(
716 "child {}",
717 path.value().map_or("", crate::config::ChildPrefix::as_str)
718 ),
719 RelationKind::Instruction { name, eval_id } => format!("{{{name}}} in {eval_id}"),
720 RelationKind::Argument {
721 eval_id,
722 index,
723 name,
724 ..
725 } => format!("argv {eval_id}[{index}] {{{name}}}"),
726 };
727 if relation.cyclic.value() == Some(&true) {
728 format!("{kind} ↻")
729 } else {
730 kind
731 }
732}
733
734fn profile(profile: &crate::config::StoredProfile) -> String {
735 use crate::config::{Field, StoredProfile};
736 match profile {
737 StoredProfile::Agent {
738 backend,
739 model,
740 reasoning,
741 ..
742 } => {
743 let reasoning = match reasoning {
744 Field::Value(reasoning) => format!(" reasoning {reasoning}"),
745 _ => String::new(),
746 };
747 format!(
748 "agent {} {model}{reasoning}",
749 serde_json::to_value(backend)
750 .expect("backend is JSON")
751 .as_str()
752 .expect("backend is a string")
753 )
754 }
755 StoredProfile::Runtime { command, args, .. } => {
756 format!("runtime {command} {}", args.join(" "))
757 }
758 StoredProfile::Human {} => "human".into(),
759 }
760}
761
762fn options(options: &crate::store::ExecutionOptions) -> String {
764 let value = serde_json::to_value(options).unwrap_or_default();
765 let set: Vec<_> = value
766 .as_object()
767 .into_iter()
768 .flatten()
769 .map(|(key, value)| match value.as_str() {
770 Some(text) => format!("{key} {text}"),
771 None => format!("{key} {value}"),
772 })
773 .collect();
774 if set.is_empty() {
775 "declared defaults".into()
776 } else {
777 set.join(" · ")
778 }
779}
780
781fn request_detail(view: &RequestView, now: OffsetDateTime) -> Detail {
782 let request = &view.request;
783 let summary = query::request_summary(view, now);
784 let deps = join(&request.deps, ", ");
785 let mut detail = Detail {
786 title: format!("{} · {}", request.eval_id, request.title),
787 summary: join(
788 [
789 format!(
790 "{}{}",
791 request.status(),
792 elapsed(view, now).map_or(String::new(), |time| format!(" {time}"))
793 ),
794 profile(&request.profile),
795 if deps.is_empty() {
796 deps
797 } else {
798 format!("← {deps}")
799 },
800 ]
801 .into_iter()
802 .filter(|part| !part.is_empty()),
803 " · ",
804 ),
805 fields: Vec::new(),
806 };
807 detail.push(
808 "Status",
809 format!(
810 "{} — {}",
811 request.status(),
812 Status::from(request.status()).meaning()
813 ),
814 );
815 detail.push("Request", request.id.as_str());
816 detail.push("Reason", request.blocked_reason.clone().unwrap_or_default());
817 detail.push("Error", error(view).unwrap_or_default());
818 detail.push("Instruction", request.payload.instruction());
819 detail.push("Profile", profile(&request.profile));
820 if request.requested_profile != request.profile {
821 detail.push("Requested", profile(&request.requested_profile));
822 }
823 detail.push("Options", options(&request.options));
824 detail.push(
825 "Fingerprint",
826 request.fingerprint.as_deref().unwrap_or("none"),
827 );
828 detail.push("Key", request.key.as_deref().unwrap_or("none (no reuse)"));
829 detail.push(
830 "Key covers",
831 join(
832 request
833 .fingerprints
834 .iter()
835 .map(|(name, fingerprint)| format!("{name} {fingerprint}")),
836 "\n",
837 ),
838 );
839 if let Some(source) = &request.provenance {
840 detail.push(
841 "Source",
842 if reused(view) {
843 format!(
844 "reused from Run {} request {} ({}, {}) completed {}{}",
845 source.run_id,
846 source.request_id,
847 source.eval_id,
848 crate::platform::path_text(&source.repo_path),
849 source
850 .completed_at
851 .map_or_else(|| "?".to_owned(), |time| time.to_string()),
852 request
853 .producer
854 .as_ref()
855 .map_or(String::new(), |producer| format!(" by {}", producer.name))
856 )
857 } else {
858 "executed in this Run".into()
859 },
860 );
861 }
862 if let Some(execution) = &view.execution {
863 let owner = execution
864 .owner
865 .map_or(String::new(), |owner| format!(" · owner pid {}", owner.pid));
866 detail.push(
867 "Execution",
868 format!("{} {}{owner}", execution.id, execution.status()),
869 );
870 }
871 if request.profile.kind() == crate::config::ProfileKind::Human || view.claim.is_some() {
872 detail.push("Claim", claim(view));
873 }
874 detail.push(
875 "Timing",
876 format!(
877 "created {} · started {} · completed {}{}",
878 request.created_at,
879 request
880 .started_at
881 .map_or_else(|| "-".to_owned(), |time| time.to_string()),
882 request
883 .completed_at()
884 .map_or_else(|| "-".to_owned(), |time| time.to_string()),
885 elapsed(view, now).map_or(String::new(), |time| format!(" · {time}"))
886 ),
887 );
888 detail.push("Deps", request.deps.join(", "));
889 detail.push(
890 "Argv",
891 request
892 .argv
893 .as_ref()
894 .map_or(String::new(), |argv| argv.join(" ")),
895 );
896 if let Some(result) = &request.result() {
897 detail.push("Result", result_summary(result));
898 detail.push(
899 "Raw result",
900 serde_json::to_string_pretty(result).unwrap_or_default(),
901 );
902 }
903 let total = counter_pairs(&summary.usage.usage);
904 let attempts = request.usage.as_ref().or(request.reused_usage.as_ref());
905 let attempts = attempts.map_or(&[][..], Vec::as_slice);
906 let original = attempts.len();
907 let attempts = attempts.iter().map(|attempt| {
908 let error = attempt.error.as_deref();
909 let error = error.map_or(String::new(), |error| format!(" error: {error}"));
910 format!(
911 "turn {} attempt {}: {}{error}",
912 attempt.turn,
913 attempt.attempt,
914 counter_pairs(&attempt.usage)
915 )
916 });
917 let state = if reused(view) {
918 format!("reused: spent none · original attempts {original}")
919 } else {
920 format!(
921 "{} · attempts {}{}",
922 summary.usage.usage_state,
923 summary.usage.attempts,
924 if total.is_empty() {
925 total
926 } else {
927 format!(" · total {total}")
928 }
929 )
930 };
931 detail.push("Usage", join(std::iter::once(state).chain(attempts), "\n"));
932 detail
933}
934
935#[derive(Debug, Clone, Default, PartialEq)]
939pub struct Card {
940 pub instruction: String,
941 pub result: Option<serde_json::Map<String, Value>>,
943 pub exit_code: Option<i32>,
944 pub error: Option<String>,
945 pub reused: bool,
946 pub took: Option<String>,
948 pub tokens: Option<u64>,
950 pub model: Option<String>,
951}
952
953pub(crate) fn entries(result: &crate::store::ExecutionResult) -> serde_json::Map<String, Value> {
955 let mut entries = result.to_json().as_object().cloned().unwrap_or_default();
956 for key in [
957 "verdict",
958 "stdout",
959 "stderr",
960 "exitCode",
961 "durationMs",
962 "truncated",
963 ] {
964 entries.remove(key);
965 }
966 entries
967}
968
969pub fn card(view: &RequestView, now: OffsetDateTime) -> Card {
970 use crate::config::StoredProfile;
971 use crate::store::ExecutionResult;
972 let request = &view.request;
973 let reused = reused(view);
974 let result = request.result();
975 let attempts = if reused {
976 request.reused_usage.as_ref()
977 } else {
978 request.usage.as_ref()
979 };
980 let tokens = attempts.and_then(|attempts| attempt_tokens(attempts));
981 Card {
982 instruction: request.payload.instruction().to_owned(),
983 result: result.map(entries),
984 exit_code: match result {
985 Some(ExecutionResult::Runtime(result)) => result.exit_code.value().copied(),
986 _ => None,
987 },
988 error: error(view),
989 reused,
990 took: view
991 .execution
992 .as_ref()
993 .and_then(|execution| span(execution.started_at, execution.completed_at(), now)),
994 tokens,
995 model: match &request.profile {
996 StoredProfile::Agent { model, .. } => Some(model.to_string()),
997 _ => None,
998 },
999 }
1000}
1001
1002pub fn detail(
1003 run: &RunView,
1004 requests: &[RequestView],
1005 target: &Target,
1006 now: OffsetDateTime,
1007) -> Detail {
1008 let saved = Saved { run, requests };
1009 let mut detail = Detail::default();
1010 match target {
1011 Target::Run => {
1012 let progress = progress(run, requests, now);
1013 let strip = strip(&progress);
1014 detail.title = format!("Run {}", run.run.id);
1015 detail.summary = format!("{} · {}", progress.status, strip.headline);
1016 detail.push("Status", progress.status.as_str());
1017 detail.push("Comment", progress.comment.clone().unwrap_or_default());
1018 detail.push("Validation", progress.validation.as_str());
1019 detail.push(
1020 "Counts",
1021 join(
1022 progress
1023 .counts
1024 .iter()
1025 .filter(|(_, n)| *n > 0)
1026 .map(|(status, n)| format!("{} {status} {n}", status.glyph())),
1027 " · ",
1028 ),
1029 );
1030 let lines = |items: &[(String, String)]| {
1031 join(
1032 items.iter().map(|(eval, text)| format!("{eval} {text}")),
1033 "\n",
1034 )
1035 };
1036 detail.push("Errors", lines(&progress.errors));
1037 detail.push("Waiting", lines(&progress.waiting));
1038 detail.push("Running", lines(&progress.running));
1039 detail.push("Timing", progress.timing.as_str());
1040 detail.push("Budget", progress.budget.as_str());
1041 detail.push("Work", progress.work.as_str());
1042 detail.push("Usage", progress.usage.as_str());
1043 detail.push("Saved usage", progress.saved.as_str());
1044 detail.push("Run", run.run.id.as_str());
1045 detail.push("Repository", progress.repo.as_str());
1046 }
1047 Target::Eval(id) => {
1048 let waits = tree::waits_for(&saved, id, now);
1049 if let Some(view) = saved.request(id) {
1050 let mut detail = request_detail(view, now);
1051 detail.insert_after("Status", "Waits for", waits);
1052 return detail;
1053 }
1054 let declaration = saved
1055 .eval_definitions()
1056 .iter()
1057 .find(|eval| &eval.id == id)
1058 .and_then(|eval| eval.declaration.value());
1059 detail.title = format!(
1060 "{id} · {}",
1061 declaration
1062 .and_then(|declaration| declaration.title.value())
1063 .map_or("", String::as_str)
1064 );
1065 detail.push("Status", "not in this Run (outside the selection)");
1066 detail.push("Waits for", waits);
1067 detail.push(
1068 "Instruction",
1069 declaration
1070 .and_then(|declaration| declaration.payload.value())
1071 .map_or("", |payload| payload.instruction()),
1072 );
1073 detail.push(
1074 "Profile",
1075 declaration
1076 .and_then(|declaration| declaration.profile.value())
1077 .map_or_else(|| "unknown".into(), |saved| profile(&saved.known)),
1078 );
1079 }
1080 Target::Artifact(id) => {
1081 let artifact = saved.artifact(id);
1082 let basis = if artifact.and_then(|artifact| artifact.basis.value()) == Some(&true) {
1083 " [basis]"
1084 } else {
1085 ""
1086 };
1087 let kind = if artifact.and_then(|artifact| artifact.kind.value())
1088 == Some(&crate::config::ArtifactKind::File)
1089 {
1090 " [file]"
1091 } else {
1092 ""
1093 };
1094 detail.title = format!("Artifact {id}{basis}{kind}");
1095 detail.push(
1096 "Tags",
1097 artifact
1098 .and_then(|artifact| artifact.tags.value())
1099 .map_or_else(String::new, |tags| tags.join(", ")),
1100 );
1101 detail.summary = tree::artifact_status(&saved, id, now);
1102 detail.push("Status", detail.summary.clone());
1103 detail.push("Waits for", tree::artifact_waits(&saved, id, now));
1104 let snapshot = saved.validation(id);
1105 if let Some(status) = snapshot.and_then(|s| s.status.value()) {
1106 detail.push(
1107 "At Run end",
1108 format!(
1109 "{status} · {}/{} Evals GREEN",
1110 snapshot
1111 .and_then(|s| s.passed.value())
1112 .copied()
1113 .unwrap_or(0),
1114 snapshot.and_then(|s| s.total.value()).copied().unwrap_or(0)
1115 ),
1116 );
1117 }
1118 detail.push(
1119 "Path",
1120 artifact
1121 .and_then(|artifact| artifact.path.value())
1122 .map_or_else(
1123 || "-".into(),
1124 |path| {
1125 if path.as_os_str().is_empty() {
1126 ".".into()
1127 } else {
1128 crate::platform::path_text(path)
1129 }
1130 },
1131 ),
1132 );
1133 detail.push(
1134 "Fingerprint",
1135 saved
1136 .validation(id)
1137 .and_then(|s| s.fingerprint.value())
1138 .map(|f| f.as_str())
1139 .unwrap_or_default(),
1140 );
1141 let component = saved.component(id);
1142 if component.and_then(|component| component.cyclic.value()) == Some(&true) {
1143 detail.push(
1144 "Cycle",
1145 format!(
1146 "↻ {}",
1147 join(
1148 component
1149 .and_then(|component| component.artifacts.value())
1150 .into_iter()
1151 .flatten(),
1152 ", "
1153 )
1154 ),
1155 );
1156 }
1157 let gates = component
1158 .and_then(|component| component.gates.value())
1159 .into_iter()
1160 .flatten()
1161 .map(|gate| mark(gate, saved.status(gate)));
1162 detail.push("Gates", join(gates, "\n"));
1163 let evals = saved.evals(id).into_iter();
1164 detail.push(
1165 "Evals",
1166 join(evals.map(|(eval, _)| mark(eval, saved.status(eval))), "\n"),
1167 );
1168 for (key, input) in [("Inputs", true), ("Used by", false)] {
1169 let relations = saved.relations(id, input).into_iter();
1170 let relations = relations.map(|relation| {
1171 format!(
1172 "{} — {}",
1173 if input {
1174 &relation.source
1175 } else {
1176 &relation.target
1177 },
1178 relation_kind(relation)
1179 )
1180 });
1181 detail.push(key, join(relations, "\n"));
1182 }
1183 }
1184 }
1185 detail
1186}
1187
1188fn counter_pairs(counters: &std::collections::BTreeMap<String, u64>) -> String {
1189 join(
1190 counters
1191 .iter()
1192 .map(|(name, value)| format!("{name} {value}")),
1193 ", ",
1194 )
1195}
1196
1197#[cfg(any(test, feature = "test-support"))]
1198pub fn derivation_count(run: &RunView, requests: &[RequestView]) -> usize {
1199 states::States::new(&Saved { run, requests })
1200 .derivations
1201 .get()
1202}