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