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 children: Vec::new(),
537 }
538}
539
540fn result_summary(result: &crate::store::ExecutionResult) -> String {
543 let result = result.to_json();
544 let Some(object) = result.as_object() else {
545 return result
546 .to_string()
547 .lines()
548 .next()
549 .unwrap_or_default()
550 .to_owned();
551 };
552 let mut entries: Vec<_> = object
554 .iter()
555 .filter(|(key, _)| !matches!(key.as_str(), "stdout" | "stderr"))
556 .collect();
557 entries.sort_by_key(|(key, _)| *key != "verdict");
558 let parts = entries.into_iter().map(|(key, value)| match value {
559 Value::String(text) => {
560 let line = text.lines().next().unwrap_or_default();
561 if line.len() < text.trim_end().len() {
562 format!("{key} {line} …")
563 } else {
564 format!("{key} {line}")
565 }
566 }
567 Value::Array(items) => format!("{key} [{} items]", items.len()),
568 Value::Object(fields) => format!("{key} {{{} fields}}", fields.len()),
569 other => format!("{key} {other}"),
570 });
571 join(parts, "\n")
572}
573
574struct Saved<'a> {
576 run: &'a RunView,
577 requests: &'a [RequestView],
578}
579
580impl<'a> Saved<'a> {
581 fn definitions(&self) -> Option<&'a crate::store::definitions::Graph> {
582 self.run.run.definitions.graph()
583 }
584 fn artifacts(
585 &self,
586 ) -> impl Iterator<
587 Item = (
588 &'a crate::types::ArtifactName,
589 &'a crate::store::definitions::Artifact,
590 ),
591 > {
592 self.definitions()
593 .into_iter()
594 .flat_map(|graph| graph.artifacts())
595 }
596 fn artifact(&self, id: &ArtifactName) -> Option<&'a crate::store::definitions::Artifact> {
597 self.definitions().and_then(|graph| graph.artifact(id))
598 }
599 fn eval_definitions(&self) -> &'a [crate::store::definitions::Eval] {
600 self.definitions().map_or(&[], |graph| graph.evals())
601 }
602 fn components(&self) -> &'a [crate::store::definitions::Component] {
603 self.definitions().map_or(&[], |graph| graph.components())
604 }
605
606 fn request(&self, eval: &EvalId) -> Option<&'a RequestView> {
607 self.requests
608 .iter()
609 .find(|view| &view.request.eval_id == eval)
610 }
611
612 fn status(&self, eval: &EvalId) -> Option<Status> {
613 self.request(eval).map(|view| view.request.status().into())
614 }
615
616 fn artifact_ids(&self) -> Vec<&'a ArtifactName> {
619 let components = self.components().iter();
620 let saved = components
621 .flat_map(|component| {
622 let mut members: Vec<_> =
623 component.artifacts.value().into_iter().flatten().collect();
624 members.sort_unstable();
625 members
626 })
627 .chain(self.artifacts().map(|(id, _)| id))
628 .chain(self.requests.iter().map(|view| &view.request.target));
629 let mut seen = std::collections::HashSet::new();
630 saved.filter(|id| seen.insert(*id)).collect()
631 }
632
633 fn evals(
635 &self,
636 artifact: &ArtifactName,
637 ) -> Vec<(&'a EvalId, Option<&'a crate::store::definitions::Eval>)> {
638 let mut evals: Vec<_> = self
639 .eval_definitions()
640 .iter()
641 .filter(|eval| &eval.target == artifact)
642 .map(|eval| (&eval.id, Some(eval)))
643 .collect();
644 for request in self.requests.iter().map(|view| &view.request) {
645 if &request.target == artifact && !evals.iter().any(|(id, _)| *id == &request.eval_id) {
646 evals.push((&request.eval_id, None));
647 }
648 }
649 evals
650 }
651
652 fn validation(&self, artifact: &ArtifactName) -> Option<&'a crate::store::ArtifactValidation> {
653 self.run
654 .run
655 .validation
656 .snapshot()?
657 .artifacts
658 .value()?
659 .iter()
660 .find(|a| &a.id == artifact)
661 }
662
663 fn component(&self, id: &ArtifactName) -> Option<&'a crate::store::definitions::Component> {
664 self.components().iter().find(|component| {
665 component
666 .artifacts
667 .value()
668 .is_some_and(|artifacts| artifacts.iter().any(|artifact| artifact == id))
669 })
670 }
671
672 fn relations(
673 &self,
674 artifact: &ArtifactName,
675 input: bool,
676 ) -> Vec<&'a crate::store::definitions::Relation> {
677 self.definitions()
678 .into_iter()
679 .flat_map(|graph| graph.relations())
680 .filter(|relation| {
681 if input {
682 &relation.target == artifact
683 } else {
684 &relation.source == artifact
685 }
686 })
687 .collect()
688 }
689}
690
691fn relation_kind(relation: &crate::store::definitions::Relation) -> String {
692 use crate::store::definitions::RelationKind;
693 let kind = match &relation.kind {
694 RelationKind::Unknown { .. } => serde_json::to_string(relation).unwrap_or_default(),
695 RelationKind::Child { path } => {
696 format!(
697 "child {}",
698 path.value().map_or("", crate::config::ChildPrefix::as_str)
699 )
700 }
701 RelationKind::Instruction { name, eval_id } => format!("{{{name}}} in {eval_id}"),
702 RelationKind::Argument {
703 eval_id,
704 index,
705 name,
706 ..
707 } => format!("argv {eval_id}[{index}] {{{name}}}"),
708 };
709 if relation.cyclic.value() == Some(&true) {
710 format!("{kind} ↻")
711 } else {
712 kind
713 }
714}
715
716fn profile(profile: &crate::config::StoredProfile) -> String {
717 use crate::config::{Field, StoredProfile};
718 match profile {
719 StoredProfile::Agent {
720 backend,
721 model,
722 reasoning,
723 ..
724 } => {
725 let reasoning = match reasoning {
726 Field::Value(reasoning) => format!(" reasoning {reasoning}"),
727 _ => String::new(),
728 };
729 format!(
730 "agent {} {model}{reasoning}",
731 serde_json::to_value(backend)
732 .expect("backend is JSON")
733 .as_str()
734 .expect("backend is a string")
735 )
736 }
737 StoredProfile::Runtime { command, args, .. } => {
738 format!("runtime {command} {}", args.join(" "))
739 }
740 StoredProfile::Human {} => "human".into(),
741 }
742}
743
744fn options(options: &crate::store::ExecutionOptions) -> String {
746 let value = serde_json::to_value(options).unwrap_or_default();
747 let set: Vec<_> = value
748 .as_object()
749 .into_iter()
750 .flatten()
751 .map(|(key, value)| match value.as_str() {
752 Some(text) => format!("{key} {text}"),
753 None => format!("{key} {value}"),
754 })
755 .collect();
756 if set.is_empty() {
757 "declared defaults".into()
758 } else {
759 set.join(" · ")
760 }
761}
762
763fn request_detail(view: &RequestView, now: OffsetDateTime) -> Detail {
764 let request = &view.request;
765 let summary = query::request_summary(view, now);
766 let deps = join(&request.deps, ", ");
767 let mut detail = Detail {
768 title: format!("{} · {}", request.eval_id, request.title),
769 summary: join(
770 [
771 format!(
772 "{}{}",
773 request.status(),
774 elapsed(view, now).map_or(String::new(), |time| format!(" {time}"))
775 ),
776 profile(&request.profile),
777 if deps.is_empty() {
778 deps
779 } else {
780 format!("← {deps}")
781 },
782 ]
783 .into_iter()
784 .filter(|part| !part.is_empty()),
785 " · ",
786 ),
787 fields: Vec::new(),
788 };
789 detail.push(
790 "Status",
791 format!(
792 "{} — {}",
793 request.status(),
794 Status::from(request.status()).meaning()
795 ),
796 );
797 detail.push("Request", request.id.as_str());
798 detail.push("Reason", request.blocked_reason.clone().unwrap_or_default());
799 detail.push("Error", error(view).unwrap_or_default());
800 detail.push("Instruction", request.payload.instruction());
801 detail.push("Profile", profile(&request.profile));
802 if request.requested_profile != request.profile {
803 detail.push("Requested", profile(&request.requested_profile));
804 }
805 detail.push("Options", options(&request.options));
806 detail.push(
807 "Fingerprint",
808 request.fingerprint.as_deref().unwrap_or("none"),
809 );
810 detail.push("Key", request.key.as_deref().unwrap_or("none (no reuse)"));
811 detail.push(
812 "Key covers",
813 join(
814 request
815 .fingerprints
816 .iter()
817 .map(|(name, fingerprint)| format!("{name} {fingerprint}")),
818 "\n",
819 ),
820 );
821 if let Some(source) = &request.provenance {
822 detail.push(
823 "Source",
824 if reused(view) {
825 format!(
826 "reused from Run {} request {} ({}, {}) completed {}{}",
827 source.run_id,
828 source.request_id,
829 source.eval_id,
830 crate::platform::path_text(&source.repo_path),
831 source
832 .completed_at
833 .map_or_else(|| "?".to_owned(), |time| time.to_string()),
834 request
835 .producer
836 .as_ref()
837 .map_or(String::new(), |producer| format!(" by {}", producer.name))
838 )
839 } else {
840 "executed in this Run".into()
841 },
842 );
843 }
844 if let Some(execution) = &view.execution {
845 let owner = execution
846 .owner
847 .map_or(String::new(), |owner| format!(" · owner pid {}", owner.pid));
848 detail.push(
849 "Execution",
850 format!("{} {}{owner}", execution.id, execution.status()),
851 );
852 }
853 if request.profile.kind() == crate::config::ProfileKind::Human || view.claim.is_some() {
854 detail.push("Claim", claim(view));
855 }
856 detail.push(
857 "Timing",
858 format!(
859 "created {} · started {} · completed {}{}",
860 request.created_at,
861 request
862 .started_at
863 .map_or_else(|| "-".to_owned(), |time| time.to_string()),
864 request
865 .completed_at()
866 .map_or_else(|| "-".to_owned(), |time| time.to_string()),
867 elapsed(view, now).map_or(String::new(), |time| format!(" · {time}"))
868 ),
869 );
870 detail.push("Deps", request.deps.join(", "));
871 detail.push(
872 "Argv",
873 request
874 .argv
875 .as_ref()
876 .map_or(String::new(), |argv| argv.join(" ")),
877 );
878 if let Some(result) = &request.result() {
879 detail.push("Result", result_summary(result));
880 detail.push(
881 "Raw result",
882 serde_json::to_string_pretty(result).unwrap_or_default(),
883 );
884 }
885 let total = counter_pairs(&summary.usage.usage);
886 let attempts = request.usage.as_ref().or(request.reused_usage.as_ref());
887 let attempts = attempts.map_or(&[][..], Vec::as_slice);
888 let original = attempts.len();
889 let attempts = attempts.iter().map(|attempt| {
890 let error = attempt.error.as_deref();
891 let error = error.map_or(String::new(), |error| format!(" error: {error}"));
892 format!(
893 "turn {} attempt {}: {}{error}",
894 attempt.turn,
895 attempt.attempt,
896 counter_pairs(&attempt.usage)
897 )
898 });
899 let state = if reused(view) {
900 format!("reused: spent none · original attempts {original}")
901 } else {
902 format!(
903 "{} · attempts {}{}",
904 summary.usage.usage_state,
905 summary.usage.attempts,
906 if total.is_empty() {
907 total
908 } else {
909 format!(" · total {total}")
910 }
911 )
912 };
913 detail.push("Usage", join(std::iter::once(state).chain(attempts), "\n"));
914 detail
915}
916
917#[derive(Debug, Clone, Default, PartialEq)]
921pub struct Card {
922 pub instruction: String,
923 pub result: Option<serde_json::Map<String, Value>>,
925 pub exit_code: Option<i32>,
926 pub error: Option<String>,
927 pub reused: bool,
928 pub took: Option<String>,
930 pub tokens: Option<u64>,
932 pub model: Option<String>,
933}
934
935pub(crate) fn entries(result: &crate::store::ExecutionResult) -> serde_json::Map<String, Value> {
937 let mut entries = result.to_json().as_object().cloned().unwrap_or_default();
938 for key in [
939 "verdict",
940 "stdout",
941 "stderr",
942 "exitCode",
943 "durationMs",
944 "truncated",
945 ] {
946 entries.remove(key);
947 }
948 entries
949}
950
951pub fn card(view: &RequestView, now: OffsetDateTime) -> Card {
952 use crate::config::StoredProfile;
953 use crate::store::ExecutionResult;
954 let request = &view.request;
955 let reused = reused(view);
956 let result = request.result();
957 let attempts = if reused {
958 request.reused_usage.as_ref()
959 } else {
960 request.usage.as_ref()
961 };
962 let tokens = attempts.and_then(|attempts| attempt_tokens(attempts));
963 Card {
964 instruction: request.payload.instruction().to_owned(),
965 result: result.map(entries),
966 exit_code: match result {
967 Some(ExecutionResult::Runtime(result)) => result.exit_code.value().copied(),
968 _ => None,
969 },
970 error: error(view),
971 reused,
972 took: view
973 .execution
974 .as_ref()
975 .and_then(|execution| span(execution.started_at, execution.completed_at(), now)),
976 tokens,
977 model: match &request.profile {
978 StoredProfile::Agent { model, .. } => Some(model.to_string()),
979 _ => None,
980 },
981 }
982}
983
984pub fn detail(
985 run: &RunView,
986 requests: &[RequestView],
987 target: &Target,
988 now: OffsetDateTime,
989) -> Detail {
990 let saved = Saved { run, requests };
991 let mut detail = Detail::default();
992 match target {
993 Target::Run => {
994 let progress = progress(run, requests, now);
995 let strip = strip(&progress);
996 detail.title = format!("Run {}", run.run.id);
997 detail.summary = format!("{} · {}", progress.status, strip.headline);
998 detail.push("Status", progress.status.as_str());
999 detail.push("Comment", progress.comment.clone().unwrap_or_default());
1000 detail.push("Validation", progress.validation.as_str());
1001 detail.push(
1002 "Counts",
1003 join(
1004 progress
1005 .counts
1006 .iter()
1007 .filter(|(_, n)| *n > 0)
1008 .map(|(status, n)| format!("{} {status} {n}", status.glyph())),
1009 " · ",
1010 ),
1011 );
1012 let lines = |items: &[(String, String)]| {
1013 join(
1014 items.iter().map(|(eval, text)| format!("{eval} {text}")),
1015 "\n",
1016 )
1017 };
1018 detail.push("Errors", lines(&progress.errors));
1019 detail.push("Waiting", lines(&progress.waiting));
1020 detail.push("Running", lines(&progress.running));
1021 detail.push("Timing", progress.timing.as_str());
1022 detail.push("Budget", progress.budget.as_str());
1023 detail.push("Work", progress.work.as_str());
1024 detail.push("Usage", progress.usage.as_str());
1025 detail.push("Saved usage", progress.saved.as_str());
1026 detail.push("Run", run.run.id.as_str());
1027 detail.push("Repository", progress.repo.as_str());
1028 }
1029 Target::Eval(id) => {
1030 let waits = tree::waits_for(&saved, id, now);
1031 if let Some(view) = saved.request(id) {
1032 let mut detail = request_detail(view, now);
1033 detail.insert_after("Status", "Waits for", waits);
1034 return detail;
1035 }
1036 let declaration = saved
1037 .eval_definitions()
1038 .iter()
1039 .find(|eval| &eval.id == id)
1040 .and_then(|eval| eval.declaration.value());
1041 detail.title = format!(
1042 "{id} · {}",
1043 declaration
1044 .and_then(|declaration| declaration.title.value())
1045 .map_or("", String::as_str)
1046 );
1047 detail.push("Status", "not in this Run (outside the selection)");
1048 detail.push("Waits for", waits);
1049 detail.push(
1050 "Instruction",
1051 declaration
1052 .and_then(|declaration| declaration.payload.value())
1053 .map_or("", |payload| payload.instruction()),
1054 );
1055 detail.push(
1056 "Profile",
1057 declaration
1058 .and_then(|declaration| declaration.profile.value())
1059 .map_or_else(|| "unknown".into(), |saved| profile(&saved.known)),
1060 );
1061 }
1062 Target::Artifact(id) => {
1063 let artifact = saved.artifact(id);
1064 let basis = if artifact.and_then(|artifact| artifact.basis.value()) == Some(&true) {
1065 " [basis]"
1066 } else {
1067 ""
1068 };
1069 let kind = if artifact.and_then(|artifact| artifact.kind.value())
1070 == Some(&crate::config::ArtifactKind::File)
1071 {
1072 " [file]"
1073 } else {
1074 ""
1075 };
1076 detail.title = format!("Artifact {id}{basis}{kind}");
1077 detail.push(
1078 "Tags",
1079 artifact
1080 .and_then(|artifact| artifact.tags.value())
1081 .map_or_else(String::new, |tags| tags.join(", ")),
1082 );
1083 detail.summary = tree::artifact_status(&saved, id, now);
1084 detail.push("Status", detail.summary.clone());
1085 detail.push("Waits for", tree::artifact_waits(&saved, id, now));
1086 let snapshot = saved.validation(id);
1087 if let Some(status) = snapshot.and_then(|s| s.status.value()) {
1088 detail.push(
1089 "At Run end",
1090 format!(
1091 "{status} · {}/{} Evals GREEN",
1092 snapshot
1093 .and_then(|s| s.passed.value())
1094 .copied()
1095 .unwrap_or(0),
1096 snapshot.and_then(|s| s.total.value()).copied().unwrap_or(0)
1097 ),
1098 );
1099 }
1100 detail.push(
1101 "Path",
1102 artifact
1103 .and_then(|artifact| artifact.path.value())
1104 .map_or_else(
1105 || "-".into(),
1106 |path| {
1107 if path.as_os_str().is_empty() {
1108 ".".into()
1109 } else {
1110 crate::platform::path_text(path)
1111 }
1112 },
1113 ),
1114 );
1115 detail.push(
1116 "Fingerprint",
1117 saved
1118 .validation(id)
1119 .and_then(|s| s.fingerprint.value())
1120 .map(|f| f.as_str())
1121 .unwrap_or_default(),
1122 );
1123 let component = saved.component(id);
1124 if component.and_then(|component| component.cyclic.value()) == Some(&true) {
1125 detail.push(
1126 "Cycle",
1127 format!(
1128 "↻ {}",
1129 join(
1130 component
1131 .and_then(|component| component.artifacts.value())
1132 .into_iter()
1133 .flatten(),
1134 ", "
1135 )
1136 ),
1137 );
1138 }
1139 let gates = component
1140 .and_then(|component| component.gates.value())
1141 .into_iter()
1142 .flatten()
1143 .map(|gate| mark(gate, saved.status(gate)));
1144 detail.push("Gates", join(gates, "\n"));
1145 let evals = saved.evals(id).into_iter();
1146 detail.push(
1147 "Evals",
1148 join(evals.map(|(eval, _)| mark(eval, saved.status(eval))), "\n"),
1149 );
1150 for (key, input) in [("Inputs", true), ("Used by", false)] {
1151 let relations = saved.relations(id, input).into_iter();
1152 let relations = relations.map(|relation| {
1153 format!(
1154 "{} — {}",
1155 if input {
1156 &relation.source
1157 } else {
1158 &relation.target
1159 },
1160 relation_kind(relation)
1161 )
1162 });
1163 detail.push(key, join(relations, "\n"));
1164 }
1165 }
1166 }
1167 detail
1168}
1169
1170fn counter_pairs(counters: &std::collections::BTreeMap<String, u64>) -> String {
1171 join(
1172 counters
1173 .iter()
1174 .map(|(name, value)| format!("{name} {value}")),
1175 ", ",
1176 )
1177}
1178
1179#[cfg(any(test, feature = "test-support"))]
1180pub fn derivation_count(run: &RunView, requests: &[RequestView]) -> usize {
1181 states::States::new(&Saved { run, requests })
1182 .derivations
1183 .get()
1184}