Skip to main content

artifactize_core/monitor/
model.rs

1//! Pure projections of saved state for the monitor screens.
2mod 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/// What a tree node shows in the detail pane, parsed from its stable tree identifier.
25#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
26pub enum Target {
27    /// The open Run itself, from the node above its Artifacts.
28    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/// Where a Detail field goes, in display order. Technical is folded by default.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
47pub enum Section {
48    Outcome,
49    /// The Artifacts an eval waits for, each with its pending evals.
50    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    /// The verdict first; technical identifiers and hashes last. The request stays visible.
66    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    /// Request status glyphs with their counts, most urgent first, such as `✗1 ✓2`.
96    pub counts: String,
97    pub age: String,
98    /// Wall time of a finished Run.
99    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    /// Elapsed time of a running Run, or how long a finished one took.
109    pub elapsed: String,
110    pub finished: bool,
111    /// The saved validation snapshot: `None` until the Run ends.
112    pub satisfied: Option<bool>,
113    pub counts: Vec<(Status, u64)>,
114    /// Executions against their budget, and jobs.
115    pub budget: String,
116    /// Executed, reused and derived requests.
117    pub work: String,
118    /// Every spent token counter, and the counters reuse saved.
119    pub usage: String,
120    pub saved: String,
121    /// The spent token total, the only usage the Run headline shows.
122    pub tokens: Option<u64>,
123    /// Eval ID and elapsed time.
124    pub running: Vec<(String, String)>,
125    /// Eval ID and claim state.
126    pub waiting: Vec<(String, String)>,
127    /// Eval ID (or "Run") and message.
128    pub errors: Vec<(String, String)>,
129}
130
131/// The Run headline: one status line, then what needs attention.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct Strip {
134    pub status: Status,
135    pub headline: String,
136    /// Status and text: errors, then waiting Human reviews, then running evals.
137    pub attention: Vec<(Status, String)>,
138    /// Attention lines beyond the shown ones.
139    pub more: usize,
140}
141
142#[derive(Debug, Clone, Default)]
143pub struct Detail {
144    pub title: String,
145    /// One-line outcome: state, elapsed time, profile and dependencies.
146    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
175/// Attention lines shown in the Run headline before `+N more`.
176const ATTENTION_LINES: usize = 3;
177
178const ABSENT: &str = "not in Run";
179
180/// The mark for `status`, or `-` for an eval without one.
181pub 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
201/// Seconds per minute, hour and day, the units a duration is shown in.
202const 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
234/// Saved RFC 3339 times; an open end is measured to `now`.
235fn 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
244/// Status counts as glyphs, most urgent first and zero counts left out: `!2 ✓1`.
245pub 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
256/// Token totals for a headline: `950`, `12.3k`, `3.9M`.
257pub fn tokens(total: u64) -> String {
258    // Headlines abbreviate decimal thousands and millions as k and M, respectively.
259    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 view.request.profile.kind() == crate::config::ProfileKind::Dependency {
291        return Some("derived".into());
292    }
293    if reused(view) {
294        return Some("reused".into());
295    }
296    let active = matches!(
297        request.status(),
298        crate::types::RequestStatus::Running
299            | crate::types::RequestStatus::WaitingHuman
300            | crate::types::RequestStatus::Deferred
301    );
302    let start = request
303        .started_at
304        .or(active.then_some(request.created_at))?;
305    span(start, request.completed_at(), now)
306}
307
308fn claim(view: &RequestView) -> String {
309    view.claim.as_ref().map_or("unclaimed".into(), |claim| {
310        format!("claimed by {} at {}", claim.reviewer, claim.claimed_at)
311    })
312}
313
314fn error(view: &RequestView) -> Option<String> {
315    let request = &view.request;
316    let error = request.error().map(str::to_owned)?;
317    Some(match &request.error_code() {
318        Some(code) => format!("[{code}] {error}"),
319        None => error,
320    })
321}
322
323pub fn progress(view: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Progress {
324    let run = &view.run;
325    let (summary, saved_usage) = query::run_summary(view, now);
326    let with = |status: crate::types::RequestStatus| {
327        requests
328            .iter()
329            .filter(move |view| view.request.status() == status)
330    };
331    let usage = counter_pairs(&summary.usage.usage);
332    let saved = counter_pairs(&saved_usage);
333    let spent = &summary.usage.usage;
334    let tokens = spent.get("totalTokens").copied().or_else(|| {
335        let input = spent.get("inputTokens").copied();
336        let output = spent.get("outputTokens").copied();
337        (input.is_some() || output.is_some())
338            .then(|| input.unwrap_or(0).saturating_add(output.unwrap_or(0)))
339    });
340    let validation = run.validation.snapshot();
341    let unmet = validation
342        .and_then(|v| v.obligations.value())
343        .map(|ids| {
344            ids.iter()
345                .map(ToString::to_string)
346                .collect::<Vec<_>>()
347                .join(", ")
348        })
349        .unwrap_or_default();
350    let wall = span(run.created_at, run.completed_at(), now).unwrap_or_default();
351    Progress {
352        status: run.status().into(),
353        repo: crate::platform::path_text(&run.repo_path),
354        // The saved snapshot; the tree shows the current state.
355        validation: match validation.and_then(|v| v.satisfied.value()) {
356            None => "pending".into(),
357            Some(true) => "SATISFIED at Run end".into(),
358            Some(_) if unmet.is_empty() => "NOT SATISFIED at Run end".into(),
359            Some(_) => format!("NOT SATISFIED at Run end (unmet: {unmet})"),
360        },
361        timing: format!(
362            "started {} · {} {wall}",
363            run.created_at,
364            if run.completed_at().is_some() {
365                "took"
366            } else {
367                "elapsed"
368            },
369        ),
370        elapsed: wall,
371        finished: run.completed_at().is_some(),
372        satisfied: validation.and_then(|v| v.satisfied.value()).copied(),
373        counts: summary
374            .counts
375            .iter()
376            .map(|(status, count)| ((*status).into(), *count))
377            .collect(),
378        budget: format!(
379            "executions {}/{} · jobs {}",
380            run.executions_started,
381            run.max_executions
382                .map_or("unlimited".into(), |max| max.to_string()),
383            run.jobs
384                .map_or("unlimited".into(), |limit| limit.to_string()),
385        ),
386        work: format!(
387            "executed {} · reused {} · derived {}",
388            summary.executed.total, summary.reused.kinds.total, summary.derived,
389        ),
390        usage,
391        saved,
392        tokens,
393        running: with(crate::types::RequestStatus::Running)
394            .map(|view| {
395                (
396                    view.request.eval_id.to_string(),
397                    elapsed(view, now).unwrap_or_default(),
398                )
399            })
400            .collect(),
401        waiting: requests
402            .iter()
403            .filter(|view| view.request.status().is_human_wait())
404            .map(|view| (view.request.eval_id.to_string(), claim(view)))
405            .collect(),
406        errors: with(crate::types::RequestStatus::Error)
407            .map(|view| {
408                (
409                    view.request.eval_id.to_string(),
410                    error(view).unwrap_or_default(),
411                )
412            })
413            .chain(
414                run.error()
415                    .map(str::to_owned)
416                    .map(|error| ("Run".into(), error)),
417            )
418            .chain(
419                view.unreadable
420                    .iter()
421                    .map(|record| (record.id.clone(), record.to_string())),
422            )
423            .collect(),
424    }
425}
426
427/// The Run headline from its progress: snapshot verdict, counts, time and token total.
428pub fn strip(progress: &Progress) -> Strip {
429    let validation = match progress.satisfied {
430        None => "validation pending".to_owned(),
431        Some(true) => "SATISFIED at Run end".to_owned(),
432        Some(false) => "NOT SATISFIED at Run end".to_owned(),
433    };
434    let counts = counts(progress.counts.iter().copied());
435    let time = format!(
436        "{} {}",
437        if progress.finished { "took" } else { "elapsed" },
438        progress.elapsed
439    );
440    let tokens = progress
441        .tokens
442        .map(|total| format!("tokens {}", tokens(total)));
443    let headline = join(
444        [Some(validation), Some(counts), Some(time), tokens]
445            .into_iter()
446            .flatten()
447            .filter(|part| !part.is_empty()),
448        " · ",
449    );
450    let mut attention: Vec<(Status, String)> = progress
451        .errors
452        .iter()
453        .map(|(eval, error)| (Status::Error, format!("{eval}  {error}")))
454        .chain(progress.waiting.iter().map(|(eval, claim)| {
455            (
456                Status::WaitingHuman,
457                format!("{eval}  waiting Human · {claim}"),
458            )
459        }))
460        .chain(progress.running.iter().map(|(eval, time)| {
461            (
462                Status::Running,
463                format!("{eval}  running {time}").trim_end().to_owned(),
464            )
465        }))
466        .collect();
467    let more = attention.len().saturating_sub(ATTENTION_LINES);
468    attention.truncate(ATTENTION_LINES);
469    Strip {
470        status: progress.status,
471        headline,
472        attention,
473        more,
474    }
475}
476
477/// The row above the Artifacts; Enter on it opens the Run detail.
478pub fn run_node(run: &RunView) -> Node {
479    let status = Status::from(run.run.status());
480    Node {
481        id: NodeId::run(&run.run.id),
482        clock: None,
483        kind: Kind::Artifact {
484            completion: Completion::Complete,
485            basis: false,
486        },
487        glyph: status.glyph(),
488        tone: match status {
489            Status::Green => Tone::Green,
490            Status::Red => Tone::Red,
491            Status::Error => Tone::Error,
492            Status::Running => Tone::Running,
493            Status::Blocked | Status::BudgetExhausted | Status::Incomplete => Tone::Blocked,
494            _ => Tone::Muted,
495        },
496        weight: Weight::Normal,
497        name: "Run".into(),
498        marks: String::new(),
499        text: vec![Segment {
500            text: "Enter: usage, budgets, counts".into(),
501            tone: Some(Tone::Muted),
502        }],
503        right: String::new(),
504        compact: String::new(),
505        changed: false,
506        upstream: Vec::new(),
507        dependency: false,
508        children: Vec::new(),
509    }
510}
511
512/// Top-level entries of a result, one line each, such as `verdict GREEN`. Saved runtime
513/// logs are left to the evidence view.
514fn result_summary(result: &crate::store::ExecutionResult) -> String {
515    let result = result.to_json();
516    let Some(object) = result.as_object() else {
517        return result
518            .to_string()
519            .lines()
520            .next()
521            .unwrap_or_default()
522            .to_owned();
523    };
524    // The verdict first, then the other entries in their saved order.
525    let mut entries: Vec<_> = object
526        .iter()
527        .filter(|(key, _)| !matches!(key.as_str(), "stdout" | "stderr"))
528        .collect();
529    entries.sort_by_key(|(key, _)| *key != "verdict");
530    let parts = entries.into_iter().map(|(key, value)| match value {
531        Value::String(text) => {
532            let line = text.lines().next().unwrap_or_default();
533            if line.len() < text.trim_end().len() {
534                format!("{key} {line} …")
535            } else {
536                format!("{key} {line}")
537            }
538        }
539        Value::Array(items) => format!("{key} [{} items]", items.len()),
540        Value::Object(fields) => format!("{key} {{{} fields}}", fields.len()),
541        other => format!("{key} {other}"),
542    });
543    join(parts, "\n")
544}
545
546/// Saved Run definitions joined with the Run's requests.
547struct Saved<'a> {
548    run: &'a RunView,
549    requests: &'a [RequestView],
550}
551
552impl<'a> Saved<'a> {
553    fn definitions(&self) -> Option<&'a crate::store::definitions::Graph> {
554        self.run.run.definitions.graph()
555    }
556    fn artifacts(
557        &self,
558    ) -> impl Iterator<
559        Item = (
560            &'a crate::types::ArtifactName,
561            &'a crate::store::definitions::Artifact,
562        ),
563    > {
564        self.definitions()
565            .into_iter()
566            .flat_map(|graph| graph.artifacts())
567    }
568    fn artifact(&self, id: &ArtifactName) -> Option<&'a crate::store::definitions::Artifact> {
569        self.definitions().and_then(|graph| graph.artifact(id))
570    }
571    fn eval_definitions(&self) -> &'a [crate::store::definitions::Eval] {
572        self.definitions().map_or(&[], |graph| graph.evals())
573    }
574    fn components(&self) -> &'a [crate::store::definitions::Component] {
575        self.definitions().map_or(&[], |graph| graph.components())
576    }
577
578    fn request(&self, eval: &EvalId) -> Option<&'a RequestView> {
579        self.requests
580            .iter()
581            .find(|view| &view.request.eval_id == eval)
582    }
583
584    fn status(&self, eval: &EvalId) -> Option<Status> {
585        self.request(eval).map(|view| view.request.status().into())
586    }
587
588    /// Dependency-first component order with cycle peers by name, then any other saved or
589    /// requested Artifact.
590    fn artifact_ids(&self) -> Vec<&'a ArtifactName> {
591        let components = self.components().iter();
592        let saved = components
593            .flat_map(|component| {
594                let mut members: Vec<_> =
595                    component.artifacts.value().into_iter().flatten().collect();
596                members.sort_unstable();
597                members
598            })
599            .chain(self.artifacts().map(|(id, _)| id))
600            .chain(self.requests.iter().map(|view| &view.request.target));
601        let mut seen = std::collections::HashSet::new();
602        saved.filter(|id| seen.insert(*id)).collect()
603    }
604
605    /// Saved Eval definitions targeting the Artifact, then requests without one.
606    fn evals(
607        &self,
608        artifact: &ArtifactName,
609    ) -> Vec<(&'a EvalId, Option<&'a crate::store::definitions::Eval>)> {
610        let mut evals: Vec<_> = self
611            .eval_definitions()
612            .iter()
613            .filter(|eval| &eval.target == artifact)
614            .map(|eval| (&eval.id, Some(eval)))
615            .collect();
616        for request in self.requests.iter().map(|view| &view.request) {
617            if &request.target == artifact && !evals.iter().any(|(id, _)| *id == &request.eval_id) {
618                evals.push((&request.eval_id, None));
619            }
620        }
621        evals
622    }
623
624    fn validation(&self, artifact: &ArtifactName) -> Option<&'a crate::store::ArtifactValidation> {
625        self.run
626            .run
627            .validation
628            .snapshot()?
629            .artifacts
630            .value()?
631            .iter()
632            .find(|a| &a.id == artifact)
633    }
634
635    fn component(&self, id: &ArtifactName) -> Option<&'a crate::store::definitions::Component> {
636        self.components().iter().find(|component| {
637            component
638                .artifacts
639                .value()
640                .is_some_and(|artifacts| artifacts.iter().any(|artifact| artifact == id))
641        })
642    }
643
644    fn relations(
645        &self,
646        artifact: &ArtifactName,
647        input: bool,
648    ) -> Vec<&'a crate::store::definitions::Relation> {
649        self.definitions()
650            .into_iter()
651            .flat_map(|graph| graph.relations())
652            .filter(|relation| {
653                if input {
654                    &relation.target == artifact
655                } else {
656                    &relation.source == artifact
657                }
658            })
659            .collect()
660    }
661}
662
663fn relation_kind(relation: &crate::store::definitions::Relation) -> String {
664    use crate::store::definitions::RelationKind;
665    let kind = match &relation.kind {
666        RelationKind::Unknown { .. } => serde_json::to_string(relation).unwrap_or_default(),
667        RelationKind::Child { path } => {
668            format!(
669                "child {}",
670                path.value().map_or("", crate::config::ChildPrefix::as_str)
671            )
672        }
673        RelationKind::Mount { alias } => {
674            format!(
675                "mount {}",
676                alias.value().map_or("", crate::config::MountAlias::as_str)
677            )
678        }
679        RelationKind::Dependency { name, eval_id } => format!(
680            "dependency {} in {}",
681            name.value()
682                .map_or("", crate::config::ReferenceName::as_str),
683            eval_id.value().map_or("", crate::types::EvalId::as_str)
684        ),
685        RelationKind::Instruction { name, eval_id } => format!("{{{name}}} in {eval_id}"),
686        RelationKind::Argument {
687            eval_id,
688            index,
689            name,
690            ..
691        } => format!("argv {eval_id}[{index}] {{{name}}}"),
692    };
693    if relation.cyclic.value() == Some(&true) {
694        format!("{kind} ↻")
695    } else {
696        kind
697    }
698}
699
700fn profile(profile: &crate::config::StoredProfile) -> String {
701    use crate::config::{Field, StoredProfile};
702    match profile {
703        StoredProfile::Agent {
704            backend,
705            model,
706            reasoning,
707            ..
708        } => {
709            let reasoning = match reasoning {
710                Field::Value(reasoning) => format!(" reasoning {reasoning}"),
711                _ => String::new(),
712            };
713            format!(
714                "agent {} {model}{reasoning}",
715                serde_json::to_value(backend)
716                    .expect("backend is JSON")
717                    .as_str()
718                    .expect("backend is a string")
719            )
720        }
721        StoredProfile::Runtime { command, args, .. } => {
722            format!("runtime {command} {}", args.join(" "))
723        }
724        StoredProfile::Human {} => "human".into(),
725        StoredProfile::Dependency { depends_on } => format!("dependency {}", depends_on.join(", ")),
726    }
727}
728
729/// Execution options that are set, such as `variant fast · timeoutMs 60000`.
730fn options(options: &crate::store::ExecutionOptions) -> String {
731    let value = serde_json::to_value(options).unwrap_or_default();
732    let set: Vec<_> = value
733        .as_object()
734        .into_iter()
735        .flatten()
736        .map(|(key, value)| match value.as_str() {
737            Some(text) => format!("{key} {text}"),
738            None => format!("{key} {value}"),
739        })
740        .collect();
741    if set.is_empty() {
742        "declared defaults".into()
743    } else {
744        set.join(" · ")
745    }
746}
747
748fn request_detail(view: &RequestView, now: OffsetDateTime) -> Detail {
749    let request = &view.request;
750    let summary = query::request_summary(view, now);
751    let deps = join(&request.deps, ", ");
752    let mut detail = Detail {
753        title: format!("{} · {}", request.eval_id, request.title),
754        summary: join(
755            [
756                format!(
757                    "{}{}",
758                    request.status(),
759                    elapsed(view, now).map_or(String::new(), |time| format!(" {time}"))
760                ),
761                profile(&request.profile),
762                if deps.is_empty() {
763                    deps
764                } else {
765                    format!("← {deps}")
766                },
767            ]
768            .into_iter()
769            .filter(|part| !part.is_empty()),
770            " · ",
771        ),
772        fields: Vec::new(),
773    };
774    detail.push(
775        "Status",
776        format!(
777            "{} — {}",
778            request.status(),
779            Status::from(request.status()).meaning()
780        ),
781    );
782    detail.push("Request", request.id.as_str());
783    detail.push("Reason", request.blocked_reason.clone().unwrap_or_default());
784    if request.profile.kind() == crate::config::ProfileKind::Dependency {
785        detail.push("Source", "derived (no execution)");
786        detail.push(
787            "Blocked by",
788            request
789                .blocked_by
790                .iter()
791                .map(ToString::to_string)
792                .collect::<Vec<_>>()
793                .join(", "),
794        );
795    }
796    detail.push("Error", error(view).unwrap_or_default());
797    detail.push("Instruction", request.payload.instruction());
798    detail.push("Profile", profile(&request.profile));
799    if request.requested_profile != request.profile {
800        detail.push("Requested", profile(&request.requested_profile));
801    }
802    detail.push("Options", options(&request.options));
803    detail.push(
804        "Fingerprint",
805        request.fingerprint.as_deref().unwrap_or("none"),
806    );
807    detail.push("Key", request.key.as_deref().unwrap_or("none (no reuse)"));
808    detail.push(
809        "Key covers",
810        join(
811            request
812                .fingerprints
813                .iter()
814                .map(|(name, fingerprint)| format!("{name} {fingerprint}")),
815            "\n",
816        ),
817    );
818    if let Some(source) = &request.provenance {
819        detail.push(
820            "Source",
821            if reused(view) {
822                format!(
823                    "reused from Run {} request {} ({}, {}) completed {}{}",
824                    source.run_id,
825                    source.request_id,
826                    source.eval_id,
827                    crate::platform::path_text(&source.repo_path),
828                    source
829                        .completed_at
830                        .map_or_else(|| "?".to_owned(), |time| time.to_string()),
831                    request
832                        .producer
833                        .as_ref()
834                        .map_or(String::new(), |producer| format!(" by {}", producer.name))
835                )
836            } else {
837                "executed in this Run".into()
838            },
839        );
840    }
841    if let Some(execution) = &view.execution {
842        let owner = execution
843            .owner
844            .map_or(String::new(), |owner| format!(" · owner pid {}", owner.pid));
845        detail.push(
846            "Execution",
847            format!("{} {}{owner}", execution.id, execution.status()),
848        );
849    }
850    if request.profile.kind() == crate::config::ProfileKind::Human || view.claim.is_some() {
851        detail.push("Claim", claim(view));
852    }
853    detail.push(
854        "Timing",
855        format!(
856            "created {} · started {} · completed {}{}",
857            request.created_at,
858            request
859                .started_at
860                .map_or_else(|| "-".to_owned(), |time| time.to_string()),
861            request
862                .completed_at()
863                .map_or_else(|| "-".to_owned(), |time| time.to_string()),
864            elapsed(view, now).map_or(String::new(), |time| format!(" · {time}"))
865        ),
866    );
867    detail.push("Deps", request.deps.join(", "));
868    detail.push(
869        "Argv",
870        request
871            .argv
872            .as_ref()
873            .map_or(String::new(), |argv| argv.join(" ")),
874    );
875    if let Some(result) = &request.result() {
876        detail.push("Result", result_summary(result));
877        detail.push(
878            "Raw result",
879            serde_json::to_string_pretty(result).unwrap_or_default(),
880        );
881    }
882    let total = counter_pairs(&summary.usage.usage);
883    let attempts = request.usage.as_ref().or(request.reused_usage.as_ref());
884    let attempts = attempts.map_or(&[][..], Vec::as_slice);
885    let original = attempts.len();
886    let attempts = attempts.iter().map(|attempt| {
887        let error = attempt.error.as_deref();
888        let error = error.map_or(String::new(), |error| format!(" error: {error}"));
889        format!(
890            "turn {} attempt {}: {}{error}",
891            attempt.turn,
892            attempt.attempt,
893            counter_pairs(&attempt.usage)
894        )
895    });
896    let state = if reused(view) {
897        format!("reused: spent none · original attempts {original}")
898    } else {
899        format!(
900            "{} · attempts {}{}",
901            summary.usage.usage_state,
902            summary.usage.attempts,
903            if total.is_empty() {
904                total
905            } else {
906                format!(" · total {total}")
907            }
908        )
909    };
910    detail.push("Usage", join(std::iter::once(state).chain(attempts), "\n"));
911    detail
912}
913
914pub fn detail(
915    run: &RunView,
916    requests: &[RequestView],
917    target: &Target,
918    now: OffsetDateTime,
919) -> Detail {
920    let saved = Saved { run, requests };
921    let mut detail = Detail::default();
922    match target {
923        Target::Run => {
924            let progress = progress(run, requests, now);
925            let strip = strip(&progress);
926            detail.title = format!("Run {}", run.run.id);
927            detail.summary = format!("{} · {}", progress.status, strip.headline);
928            detail.push("Status", progress.status.as_str());
929            detail.push("Validation", progress.validation.as_str());
930            detail.push(
931                "Counts",
932                join(
933                    progress
934                        .counts
935                        .iter()
936                        .filter(|(_, n)| *n > 0)
937                        .map(|(status, n)| format!("{} {status} {n}", status.glyph())),
938                    " · ",
939                ),
940            );
941            let lines = |items: &[(String, String)]| {
942                join(
943                    items.iter().map(|(eval, text)| format!("{eval}  {text}")),
944                    "\n",
945                )
946            };
947            detail.push("Errors", lines(&progress.errors));
948            detail.push("Waiting", lines(&progress.waiting));
949            detail.push("Running", lines(&progress.running));
950            detail.push("Timing", progress.timing.as_str());
951            detail.push("Budget", progress.budget.as_str());
952            detail.push("Work", progress.work.as_str());
953            detail.push("Usage", progress.usage.as_str());
954            detail.push("Saved usage", progress.saved.as_str());
955            detail.push("Run", run.run.id.as_str());
956            detail.push("Repository", progress.repo.as_str());
957        }
958        Target::Eval(id) => {
959            let waits = tree::waits_for(&saved, id, now);
960            if let Some(view) = saved.request(id) {
961                let mut detail = request_detail(view, now);
962                detail.insert_after("Status", "Waits for", waits);
963                return detail;
964            }
965            let declaration = saved
966                .eval_definitions()
967                .iter()
968                .find(|eval| &eval.id == id)
969                .and_then(|eval| eval.declaration.value());
970            detail.title = format!(
971                "{id} · {}",
972                declaration
973                    .and_then(|declaration| declaration.title.value())
974                    .map_or("", String::as_str)
975            );
976            detail.push("Status", "not in this Run (outside the selection)");
977            detail.push("Waits for", waits);
978            detail.push(
979                "Instruction",
980                declaration
981                    .and_then(|declaration| declaration.payload.value())
982                    .map_or("", |payload| payload.instruction()),
983            );
984            detail.push(
985                "Profile",
986                declaration
987                    .and_then(|declaration| declaration.profile.value())
988                    .map_or_else(|| "unknown".into(), |saved| profile(&saved.known)),
989            );
990        }
991        Target::Artifact(id) => {
992            let artifact = saved.artifact(id);
993            let basis = if artifact.and_then(|artifact| artifact.basis.value()) == Some(&true) {
994                " [basis]"
995            } else {
996                ""
997            };
998            let kind = if artifact.and_then(|artifact| artifact.kind.value())
999                == Some(&crate::config::ArtifactKind::File)
1000            {
1001                " [file]"
1002            } else {
1003                ""
1004            };
1005            detail.title = format!("Artifact {id}{basis}{kind}");
1006            detail.push(
1007                "Tags",
1008                artifact
1009                    .and_then(|artifact| artifact.tags.value())
1010                    .map_or_else(String::new, |tags| tags.join(", ")),
1011            );
1012            detail.summary = tree::artifact_status(&saved, id, now);
1013            detail.push("Status", detail.summary.clone());
1014            let snapshot = saved.validation(id);
1015            if let Some(status) = snapshot.and_then(|s| s.status.value()) {
1016                detail.push(
1017                    "At Run end",
1018                    format!(
1019                        "{status} · {}/{} Evals GREEN",
1020                        snapshot
1021                            .and_then(|s| s.passed.value())
1022                            .copied()
1023                            .unwrap_or(0),
1024                        snapshot.and_then(|s| s.total.value()).copied().unwrap_or(0)
1025                    ),
1026                );
1027            }
1028            detail.push(
1029                "Path",
1030                artifact
1031                    .and_then(|artifact| artifact.path.value())
1032                    .map_or_else(
1033                        || "-".into(),
1034                        |path| {
1035                            if path.as_os_str().is_empty() {
1036                                ".".into()
1037                            } else {
1038                                crate::platform::path_text(path)
1039                            }
1040                        },
1041                    ),
1042            );
1043            detail.push(
1044                "Fingerprint",
1045                saved
1046                    .validation(id)
1047                    .and_then(|s| s.fingerprint.value())
1048                    .map(|f| f.as_str())
1049                    .unwrap_or_default(),
1050            );
1051            let component = saved.component(id);
1052            if component.and_then(|component| component.cyclic.value()) == Some(&true) {
1053                detail.push(
1054                    "Cycle",
1055                    format!(
1056                        "↻ {}",
1057                        join(
1058                            component
1059                                .and_then(|component| component.artifacts.value())
1060                                .into_iter()
1061                                .flatten(),
1062                            ", "
1063                        )
1064                    ),
1065                );
1066            }
1067            let gates = component
1068                .and_then(|component| component.gates.value())
1069                .into_iter()
1070                .flatten()
1071                .map(|gate| mark(gate, saved.status(gate)));
1072            detail.push("Gates", join(gates, "\n"));
1073            let evals = saved.evals(id).into_iter();
1074            detail.push(
1075                "Evals",
1076                join(evals.map(|(eval, _)| mark(eval, saved.status(eval))), "\n"),
1077            );
1078            for (key, input) in [("Inputs", true), ("Used by", false)] {
1079                let relations = saved.relations(id, input).into_iter();
1080                let relations = relations.map(|relation| {
1081                    format!(
1082                        "{} — {}",
1083                        if input {
1084                            &relation.source
1085                        } else {
1086                            &relation.target
1087                        },
1088                        relation_kind(relation)
1089                    )
1090                });
1091                detail.push(key, join(relations, "\n"));
1092            }
1093        }
1094    }
1095    detail
1096}
1097
1098fn counter_pairs(counters: &std::collections::BTreeMap<String, u64>) -> String {
1099    join(
1100        counters
1101            .iter()
1102            .map(|(name, value)| format!("{name} {value}")),
1103        ", ",
1104    )
1105}
1106
1107#[cfg(any(test, feature = "test-support"))]
1108pub fn derivation_count(run: &RunView, requests: &[RequestView]) -> usize {
1109    states::States::new(&Saved { run, requests })
1110        .derivations
1111        .get()
1112}