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