use crate::types::{ArtifactName, EvalId};
use serde_json::Value;
use time::OffsetDateTime;
use super::{
Saved,
states::{
Activity, Busy, Completion, EvalView, NotRun, Queue, Rank, Source, States, Upstream, Waits,
},
};
use crate::store::{RequestView, RunView};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tone {
Green,
Red,
Error,
Running,
Human,
Queued,
Blocked,
Muted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Weight {
Bold,
Normal,
Dim,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Segment {
pub text: String,
pub tone: Option<Tone>,
}
impl Segment {
fn plain(text: impl Into<String>) -> Self {
Self {
text: text.into(),
tone: None,
}
}
fn toned(text: impl Into<String>, tone: Tone) -> Self {
Self {
text: text.into(),
tone: Some(tone),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Kind {
Artifact {
completion: Completion,
basis: bool,
},
Eval(EvalView),
}
#[derive(Debug, Clone)]
pub struct Node {
pub id: super::NodeId,
pub kind: Kind,
pub glyph: &'static str,
pub tone: Tone,
pub weight: Weight,
pub name: String,
pub marks: String,
pub text: Vec<Segment>,
pub right: String,
pub compact: String,
pub changed: bool,
pub upstream: Vec<Upstream>,
pub(crate) held: bool,
pub clock: Option<Clock>,
pub children: Vec<Node>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Clock {
pub start: OffsetDateTime,
pub end: Option<OffsetDateTime>,
}
impl Clock {
pub fn elapsed(self, now: OffsetDateTime) -> String {
super::duration(self.end.unwrap_or(now) - self.start)
}
}
impl Node {
pub fn right_at(&self, now: OffsetDateTime, compact: bool) -> String {
match self.clock {
Some(clock) => clock.elapsed(now),
None if compact => self.compact.clone(),
None => self.right.clone(),
}
}
pub fn status(&self) -> String {
self.text
.iter()
.map(|segment| segment.text.as_str())
.collect()
}
pub fn line(&self) -> String {
let mut line = format!("{} {}{}", self.glyph, self.name, self.marks);
for part in [self.status(), self.right.clone()] {
if !part.is_empty() {
line = format!("{line} {part}");
}
}
if self.changed {
line.push_str(" *");
}
line
}
pub fn done(&self) -> bool {
matches!(
self.kind,
Kind::Artifact {
completion: Completion::Complete,
..
}
) && !self.children.is_empty()
}
pub fn urgent(&self) -> bool {
matches!(
self.kind,
Kind::Eval(EvalView::Failed { .. } | EvalView::InProgress(Activity::Human(_)))
)
}
pub fn attention(&self) -> bool {
matches!(
self.kind,
Kind::Eval(EvalView::InProgress(_) | EvalView::Failed { .. })
)
}
}
pub fn completion(completion: Completion) -> (&'static str, Tone, &'static str) {
match completion {
Completion::Complete => ("✓", Tone::Green, "complete"),
Completion::InProgress(Busy::Running) => ("◐", Tone::Running, "in progress"),
Completion::InProgress(Busy::Human) => ("?", Tone::Human, "in progress"),
Completion::InProgress(Busy::Queued) => ("·", Tone::Queued, "in progress"),
Completion::Waiting { blocked: false } => ("…", Tone::Muted, "waiting"),
Completion::Waiting { blocked: true } => ("⊘", Tone::Blocked, "blocked"),
Completion::Failed { verdict: true } => ("✗", Tone::Red, "failed"),
Completion::Failed { verdict: false } => ("!", Tone::Error, "failed"),
}
}
fn style(view: &EvalView) -> (&'static str, Tone, Weight) {
match view {
EvalView::Done(Source::Provisional) => ("○", Tone::Muted, Weight::Normal),
EvalView::Done(_) => ("✓", Tone::Green, Weight::Normal),
EvalView::Failed { verdict: true } => ("✗", Tone::Red, Weight::Normal),
EvalView::Failed { verdict: false } => ("!", Tone::Error, Weight::Normal),
EvalView::InProgress(Activity::Running) => ("◐", Tone::Running, Weight::Bold),
EvalView::InProgress(Activity::Human(_)) => ("?", Tone::Human, Weight::Bold),
EvalView::InProgress(Activity::Queued(_)) => ("·", Tone::Queued, Weight::Normal),
EvalView::WaitingOn(_) => ("…", Tone::Muted, Weight::Dim),
EvalView::BlockedBy(_) => ("⊘", Tone::Blocked, Weight::Normal),
EvalView::NotRun(NotRun::Budget) => ("$", Tone::Blocked, Weight::Normal),
EvalView::NotRun(NotRun::Absent) => ("-", Tone::Muted, Weight::Dim),
EvalView::NotRun(_) => ("○", Tone::Muted, Weight::Dim),
}
}
const SHOWN: usize = 2;
const SHOWN_TEXT: usize = 24;
fn tokens(states: &States, x: &[crate::types::ArtifactName], glyphs: bool) -> Vec<Segment> {
let mut segments = Vec::new();
for (index, id) in x.iter().take(SHOWN).enumerate() {
if index > 0 {
segments.push(Segment::plain(", "));
}
let (glyph, tone, word) = completion(states.completion(id));
let text = match (glyphs, x.len()) {
(false, _) => id.to_string(),
(true, 1) => format!("{id} {glyph} {word}"),
(true, _) => format!("{id} {glyph}"),
};
segments.push(Segment::toned(text, tone));
}
if x.len() > SHOWN {
segments.push(Segment::plain(format!(" +{}", x.len() - SHOWN)));
}
if glyphs
&& let Some(failed) = x
.iter()
.find(|id| states.completion(id) == Completion::Failed { verdict: false })
{
segments.push(Segment::plain(format!(" (retry {failed} first)")));
}
segments
}
fn waits(states: &States, prefix: &str, waits: &Waits) -> Vec<Segment> {
let mut segments = vec![Segment::plain(prefix)];
segments.extend(tokens(states, &waits.artifacts, true));
if let Some((via, root)) = &waits.root {
segments.push(Segment::plain(format!(" ({via} waits for ")));
let (_, tone, _) = completion(states.completion(root));
segments.push(Segment::toned(root.clone(), tone));
segments.push(Segment::plain(")"));
}
segments
}
fn findings(result: Option<&crate::store::ExecutionResult>) -> String {
let result = result.map(crate::store::ExecutionResult::to_json);
let fields = result
.as_ref()
.and_then(Value::as_object)
.into_iter()
.flatten();
let fields = fields.filter(|(key, _)| {
!matches!(
key.as_str(),
"verdict" | "stdout" | "stderr" | "truncated" | "durationMs"
)
});
let parts: Vec<_> = fields
.take(SHOWN)
.map(|(key, value)| match value {
Value::Array(items) => format!("{key} {}", items.len()),
Value::Object(fields) => format!("{key} {{{}}}", fields.len()),
Value::String(text) => {
let text = text.lines().next().unwrap_or_default();
let mut short: String = text.chars().take(SHOWN_TEXT).collect();
if short.len() < text.len() {
short.push('…');
}
format!("{key} {short}")
}
value => format!("{key} {value}"),
})
.collect();
parts.join(", ")
}
fn short_profile(profile: &crate::config::StoredProfile) -> String {
use crate::config::StoredProfile;
match profile {
StoredProfile::Agent { backend, model, .. } => format!(
"agent {} {model}",
serde_json::to_value(backend)
.ok()
.and_then(|value| value.as_str().map(str::to_owned))
.unwrap_or_default()
),
StoredProfile::Runtime { command, .. } => format!("runtime {command}"),
StoredProfile::Human {} => "human".into(),
}
}
fn text(states: &States, view: &EvalView, request: Option<&RequestView>) -> Vec<Segment> {
let plain = |text: &str| vec![Segment::plain(text)];
match view {
EvalView::Done(Source::Provisional) => plain("PROVISIONAL · sign-offs remain"),
EvalView::Done(Source::Executed) => plain("GREEN"),
EvalView::Done(Source::Reused) => plain("GREEN · reused"),
EvalView::Done(Source::Saved) => plain("GREEN · saved result, not in this Run"),
EvalView::Failed { verdict: true } => {
let findings = findings(request.and_then(|view| view.request.result()));
if request.is_none() {
plain("RED · saved result, not in this Run")
} else if findings.is_empty() {
plain("RED")
} else {
vec![Segment::plain(format!("RED · {findings}"))]
}
}
EvalView::Failed { verdict: false } => match request.and_then(super::error) {
Some(error) => vec![Segment::plain(format!(
"ERROR · {}",
error.lines().next().unwrap_or_default()
))],
None if request.is_none() => plain("ERROR · saved result, not in this Run"),
None => plain("ERROR"),
},
EvalView::InProgress(Activity::Running) => vec![Segment::plain(format!(
"running · {}",
request.map_or_else(String::new, |view| short_profile(&view.request.profile))
))],
EvalView::InProgress(Activity::Human(None))
if request.is_some_and(|view| {
view.request.status() == crate::types::RequestStatus::Deferred
}) =>
{
plain("DEFERRED · Human sign-off · unclaimed")
}
EvalView::InProgress(Activity::Human(None)) => plain("Human sign-off · unclaimed"),
EvalView::InProgress(Activity::Human(Some(reviewer))) => vec![Segment::plain(format!(
"Human sign-off · claimed by {reviewer}"
))],
EvalView::InProgress(Activity::Queued(Queue::Ready)) => plain("queued"),
EvalView::InProgress(Activity::Queued(Queue::Jobs)) => plain("queued · --jobs slots full"),
EvalView::InProgress(Activity::Queued(Queue::Slot(backend))) => {
vec![Segment::plain(format!("queued · {backend} slots full"))]
}
EvalView::InProgress(Activity::Queued(Queue::Joined)) => {
plain("queued · joins the running review")
}
EvalView::WaitingOn(x) => waits(states, "waits for ", x),
EvalView::BlockedBy(x) => {
let mut segments = vec![Segment::plain(if states.running {
"blocked by "
} else {
"not run: blocked by "
})];
segments.extend(tokens(states, x, true));
segments
}
EvalView::NotRun(NotRun::Dependency(x)) => waits(states, "not run: waited for ", x),
EvalView::NotRun(NotRun::Budget) => plain("not started · maxExecutions reached"),
EvalView::NotRun(NotRun::Stale) => plain("not run · no reusable result"),
EvalView::NotRun(NotRun::Unreviewed) => plain("not reviewed"),
EvalView::NotRun(NotRun::Absent) => plain("not in this Run"),
}
}
fn clock(view: &EvalView, request: Option<&RequestView>) -> Option<Clock> {
let timed = matches!(
view,
EvalView::Done(Source::Executed)
| EvalView::Failed { .. }
| EvalView::InProgress(Activity::Running | Activity::Human(_))
);
let request = &request.filter(|_| timed)?.request;
let start = request.started_at.unwrap_or(request.created_at).time();
let end = request.completed_at().map(crate::types::Timestamp::time);
Some(Clock { start, end })
}
fn compact(view: &EvalView, right: &str) -> String {
let x = match view {
EvalView::WaitingOn(waits) | EvalView::NotRun(NotRun::Dependency(waits)) => {
&waits.artifacts
}
EvalView::BlockedBy(x) => x,
_ => return right.to_owned(),
};
match x.as_slice() {
[one] => format!("←{one}"),
many => format!("←{}", many.len()),
}
}
fn local(eval: &EvalId) -> &str {
eval.rsplit_once('/')
.map_or(eval.as_str(), |(_, local)| local)
}
fn eval_node<'a>(states: &States<'a>, eval: &'a EvalId, now: OffsetDateTime) -> Node {
let view = states.view(eval);
let request = states.request(eval);
let (glyph, tone, weight) = style(&view);
let clock = clock(&view, request);
let right = clock.map_or_else(String::new, |clock| clock.elapsed(now));
Node {
clock,
id: super::NodeId::eval(eval.clone()),
glyph,
tone,
weight,
name: local(eval).to_owned(),
marks: String::new(),
text: text(states, &view, request),
compact: compact(&view, &right),
right,
changed: false,
upstream: states.upstream_states(eval),
held: states.held_for_run(eval),
children: Vec::new(),
kind: Kind::Eval(view),
}
}
fn names(nodes: &[&Node]) -> String {
let mut names = nodes
.iter()
.take(SHOWN)
.map(|node| node.name.as_str())
.collect::<Vec<_>>()
.join(", ");
if nodes.len() > SHOWN {
names = format!("{names} +{}", nodes.len() - SHOWN);
}
names
}
fn summary(states: &States, id: &ArtifactName, children: &[Node]) -> Vec<Segment> {
let view = |node: &Node| match &node.kind {
Kind::Eval(view) => Some(view.clone()),
Kind::Artifact { .. } => None,
};
let Some(top) = children
.iter()
.filter_map(view)
.map(|view| view.rank())
.min()
else {
return Vec::new();
};
let class = |rank: Rank| -> Vec<&Node> {
children
.iter()
.filter(|node| view(node).is_some_and(|view| view.rank() == rank))
.collect()
};
let x = |ids: Vec<crate::types::ArtifactName>| tokens(states, &ids, false);
let mut segments = match top {
Rank::Failed => {
let failed = class(Rank::Failed);
let errors: Vec<_> = failed
.iter()
.copied()
.filter(|node| node.kind == Kind::Eval(EvalView::Failed { verdict: false }))
.collect();
let (label, nodes) = if errors.is_empty() {
("RED", failed)
} else {
("ERROR", errors)
};
vec![Segment::plain(format!("{label}: {}", names(&nodes)))]
}
Rank::InProgress => vec![Segment::plain(format!(
"in progress: {}",
names(&class(Rank::InProgress))
))],
Rank::Blocked => {
let mut segments = vec![Segment::plain(if states.running {
"blocked by "
} else {
"not run: blocked by "
})];
segments.extend(x(states.blockers_of(id)));
segments
}
Rank::Waiting => {
let mut segments = vec![Segment::plain("waits for ")];
segments.extend(x(states.waits_of(id)));
segments
}
Rank::NotRun => {
let first = class(Rank::NotRun)[0];
match &first.kind {
Kind::Eval(EvalView::NotRun(NotRun::Dependency(_))) => {
let mut segments = vec![Segment::plain("not run: waited for ")];
segments.extend(x(states.waits_of(id)));
segments
}
_ => first.text.clone(),
}
}
Rank::Done => match states.completion(id) {
Completion::Waiting { blocked: true } => {
let mut segments = vec![Segment::plain("done, but blocked by ")];
segments.extend(x(states.blockers_of(id)));
segments
}
Completion::Waiting { blocked: false } => {
let mut segments = vec![Segment::plain("done, but waits for ")];
segments.extend(x(states.waits_of(id)));
segments
}
_ => Vec::new(),
},
};
if top == Rank::Failed {
let busy = class(Rank::InProgress);
if !busy.is_empty() {
segments.push(Segment::plain(format!(" · in progress: {}", names(&busy))));
}
}
segments
}
fn artifact_node<'a>(
states: &States<'a>,
saved: &Saved<'a>,
id: &ArtifactName,
now: OffsetDateTime,
) -> Node {
let artifact = saved.artifact(id);
let basis = artifact.and_then(|artifact| artifact.basis.value()) == Some(&true);
let children: Vec<_> = states
.evals(id)
.iter()
.map(|&eval| eval_node(states, eval, now))
.collect();
let mut marks = String::new();
if states.index.cyclic.contains(id) {
let peers = states.index.peers.get(id).map_or(&[][..], Vec::as_slice);
marks = match peers {
[] => " ↻".into(),
[peer] => format!(" ↻ {peer}"),
[peer, rest @ ..] => format!(" ↻ {peer} +{}", rest.len()),
};
}
if artifact.and_then(|artifact| artifact.kind.value())
== Some(&crate::config::ArtifactKind::File)
{
marks.push_str(" [file]");
}
let completion = states.completion(id);
let passed = states
.evals(id)
.iter()
.filter(|eval| states.effective(eval) == crate::graph::EvalStatus::Green)
.count();
let right = if children.is_empty() {
String::new()
} else {
format!("{passed}/{}", children.len())
};
let (glyph, tone, weight, text) = if basis {
("◇", Tone::Muted, Weight::Dim, vec![Segment::plain("basis")])
} else if children.is_empty() {
(
"○",
Tone::Muted,
Weight::Dim,
vec![Segment::plain("no evals")],
)
} else {
let top = children
.iter()
.min_by_key(|node| match &node.kind {
Kind::Eval(EvalView::Failed { verdict: false }) => (Rank::Failed, false),
Kind::Eval(view) => (view.rank(), true),
Kind::Artifact { .. } => (Rank::Done, true),
})
.expect("children");
let (glyph, tone, weight) = match (&top.kind, completion) {
(_, Completion::InProgress(busy)) => {
let (glyph, tone, _) = self::completion(Completion::InProgress(busy));
(glyph, tone, Weight::Bold)
}
(Kind::Eval(EvalView::Done(_)), Completion::Waiting { blocked }) => {
let (glyph, tone, _) = self::completion(Completion::Waiting { blocked });
let weight = if blocked { Weight::Normal } else { Weight::Dim };
(glyph, tone, weight)
}
_ => (top.glyph, top.tone, top.weight),
};
(glyph, tone, weight, summary(states, id, &children))
};
Node {
id: super::NodeId::artifact(id.clone()),
clock: None,
kind: Kind::Artifact { completion, basis },
glyph,
tone,
weight,
name: id.to_string(),
marks,
text,
compact: right.clone(),
right,
changed: false,
upstream: Vec::new(),
held: false,
children,
}
}
pub fn tree(run: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Vec<Node> {
let mut nodes = build(run, requests, now);
if let Some(ended) = at_end(run, requests) {
mark(&mut nodes, &build(run, &ended, now));
}
nodes
}
fn build(run: &RunView, requests: &[RequestView], now: OffsetDateTime) -> Vec<Node> {
let saved = Saved { run, requests };
let states = States::new(&saved);
states
.index
.order
.iter()
.map(|id| artifact_node(&states, &saved, id, now))
.collect()
}
fn at_end(run: &RunView, requests: &[RequestView]) -> Option<Vec<RequestView>> {
let end = run.run.completed_at()?;
let late = |time: Option<crate::types::Timestamp>| time.is_some_and(|time| time > end);
let changed = requests.iter().any(|view| {
late(view.request.completed_at()) || late(view.claim.as_ref().map(|claim| claim.claimed_at))
});
if !changed {
return None;
}
let mut ended = requests.to_vec();
for view in &mut ended {
if late(view.claim.as_ref().map(|claim| claim.claimed_at)) {
view.claim = None;
}
let request = &mut view.request;
if late(request.completed_at()) {
request.state = if request.profile.kind() == crate::config::ProfileKind::Human {
if run.run.modes.defer_human
&& request.profile.kind() == crate::config::ProfileKind::Human
&& run
.run
.definitions
.graph()
.and_then(|graph| {
graph.evals().iter().find(|eval| eval.id == request.eval_id)
})
.and_then(|eval| eval.declaration.value())
.is_some_and(|declaration| {
declaration.gate.value() != Some(&crate::config::Gate::Blocking)
})
{
crate::store::RequestState::Deferred
} else {
crate::store::RequestState::WaitingHuman
}
} else {
crate::store::RequestState::Running
};
}
}
Some(ended)
}
fn state(node: &Node) -> (Kind, &'static str, String) {
let kind = match &node.kind {
Kind::Eval(EvalView::WaitingOn(waits)) => Kind::Eval(EvalView::WaitingOn(Waits {
artifacts: waits.artifacts.clone(),
root: None,
})),
Kind::Eval(EvalView::NotRun(NotRun::Dependency(waits))) => {
Kind::Eval(EvalView::NotRun(NotRun::Dependency(Waits {
artifacts: waits.artifacts.clone(),
root: None,
})))
}
kind => kind.clone(),
};
let summary = match node.kind {
Kind::Artifact { .. } => format!("{} {}", node.status(), node.right),
Kind::Eval(_) => String::new(),
};
(kind, node.glyph, summary)
}
fn mark(nodes: &mut [Node], ended: &[Node]) {
let mut before = std::collections::HashMap::new();
for node in ended {
before.insert(&node.id, state(node));
for child in &node.children {
before.insert(&child.id, state(child));
}
}
for node in nodes {
node.changed = before.get(&node.id) != Some(&state(node));
for child in &mut node.children {
child.changed = before.get(&child.id) != Some(&state(child));
}
}
}
pub fn upstream_index(run: &RunView, requests: &[RequestView]) -> Vec<(EvalId, Vec<ArtifactName>)> {
let saved = Saved { run, requests };
let index = super::states::Index::new(&saved);
index
.upstream
.iter()
.map(|(eval, artifacts)| {
(
(*eval).clone(),
artifacts.iter().map(|id| (*id).clone()).collect(),
)
})
.collect()
}
pub(super) fn artifact_status(saved: &Saved, id: &ArtifactName, now: OffsetDateTime) -> String {
let states = States::new(saved);
let node = artifact_node(&states, saved, id, now);
let (_, _, word) = completion(states.completion(id));
let status = node.status();
let status = if status.is_empty() {
word.to_owned()
} else {
status
};
if node.right.is_empty() {
format!("{} {status}", node.glyph)
} else {
format!("{} {status} · {} Evals GREEN", node.glyph, node.right)
}
}
pub(super) fn waits_for(saved: &Saved, eval: &EvalId, now: OffsetDateTime) -> String {
let states = States::new(saved);
format_waits(
saved,
&states,
eval,
now,
&mut std::collections::HashSet::new(),
)
}
pub(super) fn artifact_waits(
saved: &Saved,
artifact: &ArtifactName,
now: OffsetDateTime,
) -> String {
let states = States::new(saved);
let mut seen = std::collections::HashSet::new();
let blocks = states
.evals(artifact)
.iter()
.map(|eval| format_waits(saved, &states, eval, now, &mut seen))
.filter(|block| !block.is_empty());
super::join(blocks, "\n")
}
fn format_waits(
saved: &Saved,
states: &States<'_>,
eval: &EvalId,
now: OffsetDateTime,
seen: &mut std::collections::HashSet<ArtifactName>,
) -> String {
let Some((&target, _)) = states
.index
.evals
.iter()
.find(|(_, evals)| evals.contains(&eval))
else {
return String::new();
};
let peers = |id: &ArtifactName| states.index.peers.get(id).cloned().unwrap_or_default();
let mut own = peers(target);
own.push(target);
let mut lines = Vec::new();
for upstream in states.upstream_states(eval) {
if !seen.insert(upstream.artifact.clone()) {
continue;
}
let (glyph, _, word) = completion(upstream.completion);
let id = &upstream.artifact;
let mut origins: Vec<String> = saved
.relations(id, false)
.into_iter()
.filter(|relation| own.contains(&&relation.target))
.map(|relation| {
let kind = super::relation_kind(relation);
if &relation.target == target {
kind
} else {
format!("{kind} → {}", relation.target)
}
})
.collect();
if origins.is_empty() && !peers(id).is_empty() {
origins.push(format!("↻ peer of {}", super::join(peers(id), ", ")));
}
let origins = if origins.is_empty() {
String::new()
} else {
format!(" · {}", origins.join(", "))
};
lines.push(format!("↑ {id} {glyph} {word}{origins}"));
if upstream.completion == Completion::Complete {
continue;
}
for &pending in states.evals(id) {
let node = eval_node(states, pending, now);
if !matches!(node.kind, Kind::Eval(EvalView::Done(_))) {
lines.push(format!(" {} {} {}", node.glyph, node.name, node.status()));
}
}
}
lines.join("\n")
}