use super::escape::esc;
use super::format::format_elapsed_clock;
use super::proc::proc_elapsed_secs;
use crate::daemon::model::{ProcRecord, Session, SessionLifecycle};
use crate::daemon::workflow::{
display_state, effective_workflow_meta, node_ranks, unmet_need_ids, validate_workflow_meta, WorkflowDisplayState,
WorkflowLoopPlan, WorkflowMeta, WorkflowNodeMeta,
};
const NODE_W: f64 = 200.0;
const NODE_H: f64 = 72.0;
const GAP_X: f64 = 56.0;
const GAP_Y: f64 = 28.0;
const PAD: f64 = 16.0;
const BOOKEND_W: f64 = 48.0;
const BOOKEND_H: f64 = 48.0;
const START_ID: &str = "__start";
const FINISH_ID: &str = "__finish";
#[derive(Clone)]
struct LaidOut {
id: String,
x: f64,
y: f64,
order: usize,
w: f64,
h: f64,
}
pub(crate) fn workflow_graph_html(session: &Session, now: u64) -> String {
let Some(meta) = effective_workflow_meta(session) else {
return String::new();
};
if validate_workflow_meta(&meta).is_err() {
return String::new();
}
let (layout, start, finish) = layout_with_bookends(session, &meta, now);
if layout.is_empty() {
return String::new();
}
let width = layout.iter().chain([&start, &finish]).map(|n| n.x + n.w).fold(0.0_f64, f64::max) + PAD;
let height = layout.iter().chain([&start, &finish]).map(|n| n.y + n.h).fold(0.0_f64, f64::max) + PAD;
let by_id: std::collections::BTreeMap<&str, &LaidOut> = layout.iter().map(|n| (n.id.as_str(), n)).collect();
let lifecycle = session.lifecycle_status(now);
let edges_svg = render_edges(&meta, &by_id, &start, &finish);
let loop_islands = loop_islands_html(&layout, &crate::daemon::workflow::workflow_loop_plans(session), lifecycle);
let mut nodes_html = String::new();
nodes_html.push_str(&bookend_html(&start, true));
let mut present = std::collections::BTreeSet::new();
let mut counts = StatusCounts::default();
let mut visual = layout.clone();
visual.sort_by(|a, b| {
a.y
.partial_cmp(&b.y)
.unwrap_or(std::cmp::Ordering::Equal)
.then(a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal))
});
let mut first_of: std::collections::BTreeMap<WorkflowDisplayState, String> = std::collections::BTreeMap::new();
for pos in &visual {
let Some(node) = meta.nodes.iter().find(|n| n.id == pos.id) else {
continue;
};
let state = display_state(session, &meta, node, now);
first_of.entry(state).or_insert_with(|| node.id.clone());
}
for node in &meta.nodes {
let Some(pos) = by_id.get(node.id.as_str()) else {
continue;
};
let state = display_state(session, &meta, node, now);
present.insert(state);
counts.tally(state);
nodes_html.push_str(&node_html(session, &meta, node, pos, now));
}
nodes_html.push_str(&bookend_html(&finish, false));
format!(
r#"<div class="chamfer card card--accent-left-orange workflow-card" id="workflow-graph" data-workflow-graph>
<div class="workflow-head">
<h2 class="workflow-title">Job graph</h2>
{outcome}
<p class="workflow-summary dim">{summary}</p>
<div class="workflow-zoom" aria-label="Graph view controls"><button type="button" class="chamfer" data-wf-zoom-out aria-label="Zoom out">−</button><button type="button" class="chamfer" data-wf-zoom-reset>100%</button><button type="button" class="chamfer" data-wf-zoom-in aria-label="Zoom in">+</button><button type="button" class="chamfer" data-wf-zoom-fit>Fit</button><button type="button" class="chamfer" data-wf-expand aria-label="Open graph in large view" aria-pressed="false">Full screen</button></div>
</div>
<div class="workflow-visual">
{legend}
<div class="chamfer workflow-scroll" role="region" aria-label="Job dependency graph" tabindex="0">
<div class="workflow-stage" style="width:{w:.0}px;height:{h:.0}px">
{loop_islands}
<svg class="workflow-edges" width="{w:.0}" height="{h:.0}" viewBox="0 0 {w:.1} {h:.1}" aria-hidden="true">
<defs>
<marker id="wf-arrow" viewBox="0 0 14 14" refX="12" refY="7" markerWidth="9" markerHeight="9" orient="auto" markerUnits="userSpaceOnUse">
<path class="wf-arrowhead" d="M3.5 2.5 L11 7 L3.5 11.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
</marker>
</defs>
{edges}
</svg>
<div class="workflow-nodes">{nodes}</div>
</div>
</div>
</div>
</div>
"#,
summary = counts.summary_html(meta.nodes.len(), &first_of),
outcome = job_outcome_html(session, lifecycle),
legend = legend_html(&present),
w = width,
h = height,
edges = edges_svg,
loop_islands = loop_islands,
nodes = nodes_html,
)
}
fn job_outcome_html(session: &Session, lifecycle: SessionLifecycle) -> String {
let finalizing = lifecycle == SessionLifecycle::Running && skill_procs_are_terminal(session);
let text = if finalizing {
"Finalizing recordings"
} else {
match lifecycle {
SessionLifecycle::Running => "Job running",
SessionLifecycle::Completed => "Job succeeded",
SessionLifecycle::Failed => "Job failed",
SessionLifecycle::Cancelled => "Job cancelled",
}
};
format!(
r#"<span class="chamfer workflow-outcome workflow-outcome--{class}" data-workflow-outcome>{text}</span>"#,
class = lifecycle.css_class(),
)
}
fn skill_procs_are_terminal(session: &Session) -> bool {
let mut skills = session.procs.iter().filter(|proc| proc.kind == crate::daemon::model::ProcKind::Skill).peekable();
skills.peek().is_some()
&& skills.all(|proc| {
matches!(
proc.status,
crate::daemon::model::ProcStatus::Ok
| crate::daemon::model::ProcStatus::Graceful
| crate::daemon::model::ProcStatus::Fail
| crate::daemon::model::ProcStatus::Skipped
)
})
}
fn loop_islands_html(layout: &[LaidOut], plans: &[WorkflowLoopPlan], lifecycle: SessionLifecycle) -> String {
let mut groups: std::collections::BTreeMap<(&str, &str), Vec<&LaidOut>> = std::collections::BTreeMap::new();
for pos in layout {
let Some((base, suffix, _)) = crate::daemon::workflow::parse_loop_iteration_id(&pos.id) else { continue };
let key = if suffix.starts_with("-while-") { suffix } else { base };
groups.entry((key, if suffix.starts_with("-while-") { "do-while" } else { "repeat" })).or_default().push(pos);
}
let mut html = String::new();
for ((group, kind), items) in groups {
let pad = 18.0;
let bottom_pad = 38.0;
let label_h = 22.0;
let left = items.iter().map(|p| p.x).fold(f64::INFINITY, f64::min) - pad;
let top = items.iter().map(|p| p.y).fold(f64::INFINITY, f64::min) - pad - label_h;
let right = items.iter().map(|p| p.x + p.w).fold(0.0_f64, f64::max) + pad;
let bottom = items.iter().map(|p| p.y + p.h).fold(0.0_f64, f64::max) + bottom_pad;
let end = group.trim_start_matches("-while-");
let first = items
.iter()
.min_by_key(|p| p.order)
.and_then(|p| crate::daemon::workflow::parse_loop_iteration_id(&p.id))
.map(|(base, _, _)| base)
.unwrap_or(end);
let name = if kind == "do-while" && first != end { format!("{first} → {end}") } else { end.to_string() };
let shown = items
.iter()
.filter_map(|p| crate::daemon::workflow::parse_loop_iteration_id(&p.id).map(|(_, _, iteration)| iteration))
.max()
.unwrap_or(1);
let plan = plans.iter().find(|plan| plan.id == end);
let progress = loop_progress_text(plan, shown, lifecycle);
html.push_str(&format!(
r#"<div class="chamfer wf-loop-island" data-loop-id="{loop_id}" data-loop-shown="{shown}" style="left:{left:.1}px;top:{top:.1}px;width:{width:.1}px;height:{height:.1}px"><span class="wf-loop-title">{kind} · {name}</span><span class="chamfer wf-loop-progress">{progress}</span></div>"#,
width = right - left,
height = bottom - top,
loop_id = esc(end),
name = esc(&name),
progress = esc(&progress),
));
}
html
}
fn loop_progress_text(plan: Option<&WorkflowLoopPlan>, shown: usize, lifecycle: SessionLifecycle) -> String {
let iterations = |n: usize| if n == 1 { "iteration" } else { "iterations" };
match plan {
Some(plan) if plan.exact => match plan.max_iterations {
Some(total) => {
let remaining = total.saturating_sub(shown);
if remaining == 0 {
format!("✓ all {total} {} shown", iterations(total))
} else {
format!("↻ {remaining} more {} planned", iterations(remaining))
}
}
None => "↻ more iterations planned".into(),
},
Some(plan) => match (plan.max_iterations, lifecycle) {
(Some(limit), _) if shown >= limit => format!("! safety limit reached after {shown} {}", iterations(shown)),
(Some(limit), SessionLifecycle::Completed) => {
let remaining = limit - shown;
format!("✓ stopped here · up to {remaining} more possible")
}
(Some(limit), SessionLifecycle::Running) => {
let remaining = limit - shown;
format!("↻ may continue · up to {remaining} more")
}
(Some(limit), _) => {
let remaining = limit - shown;
format!("↻ loop permits up to {remaining} more")
}
(None, SessionLifecycle::Running) => "↻ more iterations may follow".into(),
(None, _) => "↻ loop permits more iterations".into(),
},
None if lifecycle == SessionLifecycle::Completed => format!("✓ stopped after {shown} {}", iterations(shown)),
None if lifecycle == SessionLifecycle::Running => "↻ more iterations may follow".into(),
None => "↻ loop permits more iterations".into(),
}
}
#[cfg(test)]
mod loop_progress_tests {
use super::loop_progress_text;
use crate::daemon::model::SessionLifecycle;
use crate::daemon::workflow::WorkflowLoopPlan;
#[test]
fn continuation_copy_distinguishes_exact_and_agent_decided_loops() {
let fixed = WorkflowLoopPlan { id: "increment".into(), max_iterations: Some(3), exact: true };
assert_eq!(loop_progress_text(Some(&fixed), 1, SessionLifecycle::Running), "↻ 2 more iterations planned");
assert_eq!(loop_progress_text(Some(&fixed), 3, SessionLifecycle::Running), "✓ all 3 iterations shown");
let decided = WorkflowLoopPlan { id: "decide".into(), max_iterations: Some(25), exact: false };
assert_eq!(loop_progress_text(Some(&decided), 1, SessionLifecycle::Running), "↻ may continue · up to 24 more");
assert_eq!(
loop_progress_text(Some(&decided), 1, SessionLifecycle::Completed),
"✓ stopped here · up to 24 more possible"
);
assert_eq!(loop_progress_text(Some(&decided), 1, SessionLifecycle::Failed), "↻ loop permits up to 24 more");
}
}
struct EdgeGeom {
x1: f64,
y1: f64,
x2: f64,
y2: f64,
}
fn render_edges(
meta: &WorkflowMeta, by_id: &std::collections::BTreeMap<&str, &LaidOut>, start: &LaidOut, finish: &LaidOut,
) -> String {
let mut pairs: Vec<(&str, &str)> = Vec::new();
for node in &meta.nodes {
if !by_id.contains_key(node.id.as_str()) {
continue;
}
for need in &node.needs {
if by_id.contains_key(need.as_str()) {
pairs.push((need.as_str(), node.id.as_str()));
}
}
}
let roots = graph_roots(meta);
let sinks = graph_sinks(meta);
for id in &roots {
if by_id.contains_key(id.as_str()) {
pairs.push((START_ID, id.as_str()));
}
}
for id in &sinks {
if by_id.contains_key(id.as_str()) {
pairs.push((id.as_str(), FINISH_ID));
}
}
let mut all_by_id: std::collections::BTreeMap<&str, &LaidOut> = by_id.clone();
all_by_id.insert(START_ID, start);
all_by_id.insert(FINISH_ID, finish);
let mut out_n: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
let mut in_n: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for &(s, d) in &pairs {
*out_n.entry(s).or_default() += 1;
*in_n.entry(d).or_default() += 1;
}
let mut out_rank: std::collections::BTreeMap<&str, Vec<usize>> = std::collections::BTreeMap::new();
let mut in_rank: std::collections::BTreeMap<&str, Vec<usize>> = std::collections::BTreeMap::new();
for (i, &(s, d)) in pairs.iter().enumerate() {
out_rank.entry(s).or_default().push(i);
in_rank.entry(d).or_default().push(i);
}
for idxs in out_rank.values_mut() {
idxs.sort_by(|&a, &b| {
let ya = all_by_id.get(pairs[a].1).map(|n| n.y).unwrap_or(0.0);
let yb = all_by_id.get(pairs[b].1).map(|n| n.y).unwrap_or(0.0);
ya.partial_cmp(&yb).unwrap_or(std::cmp::Ordering::Equal).then_with(|| a.cmp(&b))
});
}
for idxs in in_rank.values_mut() {
idxs.sort_by(|&a, &b| {
let ya = all_by_id.get(pairs[a].0).map(|n| n.y).unwrap_or(0.0);
let yb = all_by_id.get(pairs[b].0).map(|n| n.y).unwrap_or(0.0);
ya.partial_cmp(&yb).unwrap_or(std::cmp::Ordering::Equal).then_with(|| a.cmp(&b))
});
}
let mut out_port: std::collections::BTreeMap<usize, usize> = std::collections::BTreeMap::new();
let mut in_port: std::collections::BTreeMap<usize, usize> = std::collections::BTreeMap::new();
for idxs in out_rank.values() {
for (port, &ei) in idxs.iter().enumerate() {
out_port.insert(ei, port);
}
}
for idxs in in_rank.values() {
for (port, &ei) in idxs.iter().enumerate() {
in_port.insert(ei, port);
}
}
let mut svg = String::new();
for (i, &(s, d)) in pairs.iter().enumerate() {
let src = all_by_id[s];
let dst = all_by_id[d];
let geom = EdgeGeom {
x1: src.x + src.w,
y1: port_y(src.y, src.h, out_port[&i], out_n[s]),
x2: dst.x,
y2: port_y(dst.y, dst.h, in_port[&i], in_n[d]),
};
svg.push_str(&edge_path(&geom));
}
svg
}
fn graph_roots(meta: &WorkflowMeta) -> Vec<String> {
let ids: std::collections::BTreeSet<&str> = meta.nodes.iter().map(|n| n.id.as_str()).collect();
meta.nodes.iter().filter(|n| n.needs.iter().all(|need| !ids.contains(need.as_str()))).map(|n| n.id.clone()).collect()
}
fn graph_sinks(meta: &WorkflowMeta) -> Vec<String> {
let ids: std::collections::BTreeSet<&str> = meta.nodes.iter().map(|n| n.id.as_str()).collect();
let depended_on: std::collections::BTreeSet<&str> =
meta.nodes.iter().flat_map(|n| n.needs.iter().map(|s| s.as_str())).filter(|id| ids.contains(id)).collect();
meta.nodes.iter().filter(|n| !depended_on.contains(n.id.as_str())).map(|n| n.id.clone()).collect()
}
fn port_y(node_y: f64, node_h: f64, index: usize, count: usize) -> f64 {
if count <= 1 {
return node_y + node_h / 2.0;
}
let margin = node_h * 0.22;
let usable = node_h - 2.0 * margin;
node_y + margin + usable * (index as f64) / ((count - 1) as f64)
}
fn edge_path(e: &EdgeGeom) -> String {
let x2 = e.x2 - 1.5;
if (e.y1 - e.y2).abs() < 0.5 {
return format!(
r#"<path class="wf-edge" d="M{x1:.1},{y1:.1} L{x2:.1},{y1:.1}" marker-end="url(#wf-arrow)" />"#,
x1 = e.x1,
y1 = e.y1,
x2 = x2,
);
}
let gap = (x2 - e.x1).max(1.0);
let runway = (gap * 0.24).clamp(12.0, 20.0);
let curve_start = e.x1 + runway;
let curve_end = x2 - runway;
let curve_dx = (curve_end - curve_start).max(1.0);
let c1x = curve_start + curve_dx * 0.42;
let c2x = curve_end - curve_dx * 0.42;
format!(
r#"<path class="wf-edge" d="M{x1:.1},{y1:.1} L{curve_start:.1},{y1:.1} C{c1x:.1},{y1:.1} {c2x:.1},{y2:.1} {curve_end:.1},{y2:.1} L{x2:.1},{y2:.1}" marker-end="url(#wf-arrow)" />"#,
x1 = e.x1,
y1 = e.y1,
y2 = e.y2,
)
}
#[derive(Default)]
struct StatusCounts {
done: usize,
graceful: usize,
running: usize,
terminating: usize,
waiting: usize,
ready: usize,
failed: usize,
stopped: usize,
stalled: usize,
skipped: usize,
}
impl StatusCounts {
fn tally(&mut self, state: WorkflowDisplayState) {
match state {
WorkflowDisplayState::Done => self.done += 1,
WorkflowDisplayState::Graceful => self.graceful += 1,
WorkflowDisplayState::Running => self.running += 1,
WorkflowDisplayState::Terminating => self.terminating += 1,
WorkflowDisplayState::Waiting => self.waiting += 1,
WorkflowDisplayState::Ready => self.ready += 1,
WorkflowDisplayState::Failed => self.failed += 1,
WorkflowDisplayState::ForceStopped => self.stopped += 1,
WorkflowDisplayState::Stalled => self.stalled += 1,
WorkflowDisplayState::Skipped => self.skipped += 1,
}
}
fn summary_html(&self, total: usize, first_of: &std::collections::BTreeMap<WorkflowDisplayState, String>) -> String {
let mut parts = vec![format!("{total} {}", if total == 1 { "task" } else { "tasks" })];
for (n, state) in [
(self.done, WorkflowDisplayState::Done),
(self.graceful, WorkflowDisplayState::Graceful),
(self.running, WorkflowDisplayState::Running),
(self.terminating, WorkflowDisplayState::Terminating),
(self.waiting, WorkflowDisplayState::Waiting),
(self.ready, WorkflowDisplayState::Ready),
(self.failed, WorkflowDisplayState::Failed),
(self.stopped, WorkflowDisplayState::ForceStopped),
(self.stalled, WorkflowDisplayState::Stalled),
(self.skipped, WorkflowDisplayState::Skipped),
] {
if n == 0 {
continue;
}
let label = state.as_str();
let shown = state.label().to_ascii_lowercase();
if let Some(id) = first_of.get(&state) {
parts.push(format!(
"<a class=\"wf-jump\" href=\"#task-{id}\" data-wf-status=\"{label}\" title=\"Jump to first {shown} task\">{n} {shown}</a>",
id = esc(id),
label = label,
shown = shown,
n = n,
));
} else {
parts.push(format!("{n} {shown}"));
}
}
parts.join(" · ")
}
}
fn legend_html(present: &std::collections::BTreeSet<WorkflowDisplayState>) -> String {
const ORDER: &[WorkflowDisplayState] = &[
WorkflowDisplayState::Running,
WorkflowDisplayState::Terminating,
WorkflowDisplayState::Done,
WorkflowDisplayState::Graceful,
WorkflowDisplayState::Failed,
WorkflowDisplayState::ForceStopped,
WorkflowDisplayState::Stalled,
WorkflowDisplayState::Waiting,
WorkflowDisplayState::Ready,
WorkflowDisplayState::Skipped,
];
let mut items = String::new();
for state in ORDER {
if !present.contains(state) {
continue;
}
items.push_str(&format!(
r#"<li class="wf-leg wf-leg-{key}"><span class="wf-ico" aria-hidden="true">{ico}</span> {label}</li>"#,
key = state.as_str(),
ico = state_icon(*state),
label = state.label(),
));
}
if items.is_empty() {
return String::new();
}
format!(r#"<ul class="chamfer workflow-legend" aria-label="Status legend">{items}</ul>"#)
}
fn layout_nodes(session: &Session, meta: &WorkflowMeta, now: u64) -> Vec<LaidOut> {
let ranks = node_ranks(meta);
let mut by_rank: std::collections::BTreeMap<usize, Vec<usize>> = std::collections::BTreeMap::new();
for (i, &r) in ranks.iter().enumerate() {
by_rank.entry(r).or_default().push(i);
}
for idxs in by_rank.values_mut() {
idxs.sort_by(|&a, &b| {
let sa = display_state(session, meta, &meta.nodes[a], now);
let sb = display_state(session, meta, &meta.nodes[b], now);
status_stack_rank(sa)
.cmp(&status_stack_rank(sb))
.then_with(|| meta.nodes[a].order.cmp(&meta.nodes[b].order))
.then_with(|| meta.nodes[a].id.cmp(&meta.nodes[b].id))
});
}
let max_in_rank = by_rank.values().map(|v| v.len()).max().unwrap_or(1);
let col_h = max_in_rank as f64 * NODE_H + (max_in_rank.saturating_sub(1) as f64) * GAP_Y;
let mut out = Vec::with_capacity(meta.nodes.len());
for (rank, idxs) in &by_rank {
let n = idxs.len();
let block_h = n as f64 * NODE_H + (n.saturating_sub(1) as f64) * GAP_Y;
let y0 = PAD + (col_h - block_h) / 2.0;
let x = PAD + (*rank as f64) * (NODE_W + GAP_X);
for (row, &i) in idxs.iter().enumerate() {
let node = &meta.nodes[i];
out.push(LaidOut {
id: node.id.clone(),
x,
y: y0 + row as f64 * (NODE_H + GAP_Y),
order: node.order,
w: NODE_W,
h: NODE_H,
});
}
}
out.sort_by_key(|a| a.order);
out
}
fn layout_with_bookends(session: &Session, meta: &WorkflowMeta, now: u64) -> (Vec<LaidOut>, LaidOut, LaidOut) {
let mut layout = layout_nodes(session, meta, now);
let shift = BOOKEND_W + GAP_X;
let loop_top = if meta.nodes.iter().any(|n| crate::daemon::workflow::parse_loop_iteration_id(&n.id).is_some()) {
36.0
} else {
0.0
};
for n in &mut layout {
n.x += shift;
n.y += loop_top;
}
let average_center = |ids: &[String]| {
let rows: Vec<&LaidOut> = layout.iter().filter(|n| ids.iter().any(|id| id == &n.id)).collect();
if rows.is_empty() {
PAD + NODE_H / 2.0
} else {
rows.iter().map(|n| n.y + n.h / 2.0).sum::<f64>() / rows.len() as f64
}
};
let start_y = (average_center(&graph_roots(meta)) - BOOKEND_H / 2.0).max(PAD);
let finish_y = (average_center(&graph_sinks(meta)) - BOOKEND_H / 2.0).max(PAD);
let start = LaidOut { id: START_ID.into(), x: PAD, y: start_y, order: 0, w: BOOKEND_W, h: BOOKEND_H };
let finish_x = layout.iter().map(|n| n.x + n.w).fold(PAD + BOOKEND_W, f64::max) + GAP_X;
let finish =
LaidOut { id: FINISH_ID.into(), x: finish_x, y: finish_y, order: usize::MAX, w: BOOKEND_W, h: BOOKEND_H };
(layout, start, finish)
}
fn bookend_html(pos: &LaidOut, is_start: bool) -> String {
if is_start {
format!(
r#"<div class="chamfer wf-bookend wf-start" id="wf-node-{id}" style="left:{x:.1}px;top:{y:.1}px;width:{w:.0}px;min-height:{h:.0}px" title="Start" aria-hidden="true">
<span class="wf-start-play" aria-hidden="true"></span>
</div>"#,
id = START_ID,
x = pos.x,
y = pos.y,
w = pos.w,
h = BOOKEND_H,
)
} else {
format!(
r#"<div class="chamfer wf-bookend wf-finish" id="wf-node-{id}" style="left:{x:.1}px;top:{y:.1}px;width:{w:.0}px;min-height:{h:.0}px" title="Finish" aria-hidden="true">
<span class="wf-finish-flag" aria-hidden="true"></span>
</div>"#,
id = FINISH_ID,
x = pos.x,
y = pos.y,
w = pos.w,
h = BOOKEND_H,
)
}
}
fn status_stack_rank(state: WorkflowDisplayState) -> u8 {
match state {
WorkflowDisplayState::Done => 0,
WorkflowDisplayState::Graceful => 1,
WorkflowDisplayState::Failed => 2,
WorkflowDisplayState::ForceStopped => 3,
WorkflowDisplayState::Skipped => 4,
WorkflowDisplayState::Terminating => 5,
WorkflowDisplayState::Running => 6,
WorkflowDisplayState::Stalled => 7,
WorkflowDisplayState::Ready => 8,
WorkflowDisplayState::Waiting => 9,
}
}
fn node_html(session: &Session, meta: &WorkflowMeta, node: &WorkflowNodeMeta, pos: &LaidOut, now: u64) -> String {
let state = display_state(session, meta, node, now);
let proc = node.proc_index.and_then(|i| session.procs.iter().find(|p| p.index == i));
let is_build = node.id == "build_base" || node.id.starts_with("build_");
let title = node_display_title(&node.id);
let harness = proc.and_then(|p| p.harness.as_deref()).unwrap_or("");
let model = proc.and_then(|p| p.model.as_deref()).unwrap_or("");
let elapsed = proc.and_then(|p| proc_elapsed_secs(p, now)).map(format_elapsed_clock);
let unmet_ids = unmet_need_ids(session, meta, node);
let unmet = unmet_ids.len();
let mut tip = node_tip(session, meta, node, &title, state, &unmet_ids, now);
let (attempt, attempts_total) = proc.map(|p| session.proc_attempt(p)).unwrap_or((1, 1));
let attempt_html = if attempts_total > 1 {
tip.push_str(&format!("\nAttempt {attempt} of {attempts_total} — an earlier attempt failed and was retried"));
format!(r#"<span class="wf-attempt"> · attempt {attempt}</span>"#)
} else {
String::new()
};
let aria = tip.replace('\n', ", ");
let mut meta_bits = Vec::new();
if is_build {
meta_bits.push("image build".into());
}
if !harness.is_empty() && !is_build {
meta_bits.push(esc(harness));
}
if !model.is_empty() {
meta_bits.push(esc(model));
}
let show_elapsed = matches!(
state,
WorkflowDisplayState::Running
| WorkflowDisplayState::Terminating
| WorkflowDisplayState::Done
| WorkflowDisplayState::Graceful
| WorkflowDisplayState::Failed
| WorkflowDisplayState::ForceStopped
| WorkflowDisplayState::Stalled
);
let state_elapsed = elapsed.filter(|_| show_elapsed).map(|e| format!(" · {}", esc(&e))).unwrap_or_default();
if state == WorkflowDisplayState::Waiting && unmet > 0 {
let names: Vec<String> = unmet_ids.iter().map(|id| node_display_title(id)).collect();
if names.len() == 1 {
meta_bits.push(format!("waiting on {}", esc(&names[0])));
} else if names.len() <= 3 {
meta_bits.push(format!("waiting on {}", esc(&names.join(", "))));
} else {
meta_bits.push(format!("waiting on {unmet} tasks"));
}
}
if state == WorkflowDisplayState::Ready {
meta_bits.push("ready".into());
}
let gate = if node.conditional {
let tip = "Runs only when its gate passes";
format!(r#"<span class="chamfer wf-gate" data-tip="{t}" aria-label="{t}">when</span>"#, t = esc(tip))
} else {
String::new()
};
let href = format!("#task-{}", esc(&node.id));
let proc_attr = match node.proc_index {
Some(i) => format!(r#" data-proc-index="{i}""#),
None => String::new(),
};
let build_class = if is_build { " wf-build" } else { "" };
let tip_running = match (state, proc.and_then(|p| p.started_at)) {
(WorkflowDisplayState::Running, Some(t)) => format!(r#" data-tip-running="{t}""#),
_ => String::new(),
};
format!(
r#"<a class="chamfer wf-node wf-{state}{build_class}" href="{href}" id="wf-node-{id}" data-workflow-step="{id}" data-wf-state="{state}"{proc_attr} style="left:{x:.1}px;top:{y:.1}px;width:{w:.0}px;min-height:{h:.0}px" data-tip="{tip}"{tip_running} aria-label="{aria}">
<span class="wf-state"><span class="wf-ico" aria-hidden="true">{ico}</span><span class="wf-state-label">{label}</span><span class="wf-state-elapsed">{state_elapsed}</span>{attempt_html}</span>
<span class="wf-id">{title_esc}{gate}</span>
<span class="wf-meta dim">{meta}</span>
</a>"#,
state = state.as_str(),
build_class = build_class,
href = href,
id = esc(&node.id),
title_esc = esc(&title),
proc_attr = proc_attr,
x = pos.x,
y = pos.y,
w = pos.w,
h = NODE_H,
tip = esc(&tip),
tip_running = tip_running,
aria = esc(&aria),
ico = state_icon(state),
label = state.label(),
state_elapsed = state_elapsed,
attempt_html = attempt_html,
gate = gate,
meta = meta_bits.join(" · "),
)
}
fn node_display_title(id: &str) -> String {
if id == "build_base" {
"base".into()
} else if let Some(h) = id.strip_prefix("build_") {
h.to_string()
} else if let Some((base, _, iteration)) = crate::daemon::workflow::parse_loop_iteration_id(id) {
format!("{base} · iteration {iteration}")
} else {
id.to_string()
}
}
fn node_tip(
session: &Session, meta: &WorkflowMeta, node: &WorkflowNodeMeta, title: &str, state: WorkflowDisplayState,
unmet_ids: &[&str], now: u64,
) -> String {
let mut lines = vec![title.to_string()];
match state {
WorkflowDisplayState::Waiting if !unmet_ids.is_empty() => {
lines.push("Waiting on:".into());
for id in unmet_ids {
lines.push(format!("• {}", unmet_blocker_line(session, meta, id, now)));
}
}
WorkflowDisplayState::Waiting => lines.push("Waiting to start".into()),
WorkflowDisplayState::Ready => {
lines.push("Queued — dependencies finished; waiting for the scheduler to start this task".into())
}
WorkflowDisplayState::Running => {
if node.id == "build_base" || node.id.starts_with("build_") {
lines.push("Image build running".into());
} else {
lines.push("Running".into());
}
}
WorkflowDisplayState::Terminating => {
let restarting = node
.proc_index
.and_then(|index| session.procs.iter().find(|proc| proc.index == index))
.is_some_and(|proc| proc.fail_reason.as_deref() == Some(crate::failure::reason::RESTART_REQUESTED));
lines.push(if restarting {
"Restarting — replacement attempt is starting".into()
} else {
"Terminating — stop requested".into()
});
}
WorkflowDisplayState::Done => lines.push("Succeeded".into()),
WorkflowDisplayState::Graceful => lines.push("Graceful shutdown — valid result survived a teardown issue".into()),
WorkflowDisplayState::Failed => lines.push("Failed".into()),
WorkflowDisplayState::ForceStopped => lines.push("Stopped from the session browser".into()),
WorkflowDisplayState::Skipped => lines.push("Skipped".into()),
WorkflowDisplayState::Stalled => lines.push("Abandoned — job stopped updating".into()),
}
if node.conditional && !matches!(state, WorkflowDisplayState::Skipped) {
lines.push("Runs only when its gate passes".into());
}
lines.join("\n")
}
fn unmet_blocker_line(session: &Session, meta: &WorkflowMeta, id: &str, now: u64) -> String {
let title = node_display_title(id);
let Some(dep) = meta.nodes.iter().find(|n| n.id == id) else {
return format!("{title} (missing)");
};
let is_build = id == "build_base" || id.starts_with("build_");
let kind = if is_build { "image build" } else { "task" };
match node_proc_for_tip(session, dep) {
None => format!("{title} ({kind}, not registered yet)"),
Some(p) => {
let st = display_state(session, meta, dep, now);
let status = match st {
WorkflowDisplayState::Running => "running",
WorkflowDisplayState::Terminating => "terminating",
WorkflowDisplayState::Waiting => "waiting",
WorkflowDisplayState::Ready => "queued",
WorkflowDisplayState::Stalled => "stalled",
WorkflowDisplayState::Done => "done",
WorkflowDisplayState::Graceful => "graceful",
WorkflowDisplayState::Failed => "failed",
WorkflowDisplayState::ForceStopped => "stopped",
WorkflowDisplayState::Skipped => "skipped",
};
let mut bits = vec![kind.to_string(), status.to_string()];
if let Some(h) = p.harness.as_deref().filter(|h| !is_build && !h.is_empty()) {
bits.insert(0, h.to_string());
}
format!("{title} ({})", bits.join(" · "))
}
}
}
fn node_proc_for_tip<'a>(
session: &'a Session, node: &WorkflowNodeMeta,
) -> Option<&'a crate::daemon::model::ProcRecord> {
node.proc_index.and_then(|i| session.procs.iter().find(|p| p.index == i))
}
fn state_icon(state: WorkflowDisplayState) -> &'static str {
match state {
WorkflowDisplayState::Waiting | WorkflowDisplayState::Ready => "◇",
WorkflowDisplayState::Running | WorkflowDisplayState::Terminating => "◆",
WorkflowDisplayState::Done => "✓",
WorkflowDisplayState::Graceful => "!",
WorkflowDisplayState::Failed => "✗",
WorkflowDisplayState::ForceStopped => "✕",
WorkflowDisplayState::Skipped => "⊘",
WorkflowDisplayState::Stalled => "!",
}
}
pub(crate) fn proc_task_id(session: &Session, proc: &ProcRecord) -> Option<String> {
let meta = effective_workflow_meta(session)?;
if let Some(node) = meta.nodes.iter().find(|n| n.proc_index == Some(proc.index)) {
return Some(node.id.clone());
}
if proc.kind == crate::daemon::model::ProcKind::Build {
let id = match proc.harness.as_deref() {
Some(h) => format!("build_{h}"),
None => "build_base".into(),
};
if meta.nodes.iter().any(|n| n.id == id && n.proc_index.is_none()) {
return Some(id);
}
}
let step = proc.skill_name.as_deref().or(proc.skill_source.as_deref());
let step = step?;
if !meta.nodes.iter().any(|n| n.id == step && n.proc_index.is_none()) {
return None;
}
Some(step.to_string())
}
pub(crate) fn proc_task_attrs(session: &Session, proc: &ProcRecord) -> String {
let Some(id) = proc_task_id(session, proc) else {
return String::new();
};
format!(r#" data-workflow-step="{id}""#, id = esc(&id))
}
pub(crate) fn proc_task_anchor_html(session: &Session, proc: &ProcRecord) -> String {
let Some(id) = proc_task_id(session, proc) else {
return String::new();
};
format!(r#"<span class="proc-task-anchor" id="task-{id}" aria-hidden="true"></span>"#, id = esc(&id))
}