Skip to main content

sloop/
render.rs

1//! Human rendering of response envelopes. JSON envelopes remain the internal
2//! and `--json` representation; this module is a one-way translation applied
3//! at the final write, so the socket API and agent-facing output are
4//! unaffected by presentation changes here.
5
6use std::fmt::Write;
7
8use serde_json::Value;
9
10use crate::protocol::{ErrorBody, ResponseEnvelope};
11
12/// Renders a response envelope as human-readable text. `verb` selects a
13/// verb-specific layout; unknown or absent verbs fall back to pretty JSON so
14/// no response is ever silently dropped.
15pub fn render(verb: Option<&str>, response: &ResponseEnvelope) -> String {
16    if let Some(error) = &response.error {
17        return render_error(error);
18    }
19    let data = response.data.as_ref().unwrap_or(&Value::Null);
20    if data["implemented"] == Value::Bool(false) {
21        let verb = data["verb"].as_str().unwrap_or("this verb");
22        return format!("{verb} is not implemented by the daemon yet\n");
23    }
24    match verb {
25        Some("daemon") => render_daemon(data),
26        Some("restart") => render_restart(data),
27        Some("init") => render_init(data),
28        Some("post") => render_post(data),
29        Some("run") => render_run(data),
30        Some("retry" | "hold" | "ready") => render_ticket_transition(data),
31        Some("list") => render_list(data),
32        Some("status") => render_status(data),
33        Some("pause" | "resume") => render_scheduler_transition(data),
34        Some("stop") => render_stop(data),
35        Some("wait") => render_wait(data),
36        Some("cancel") => render_cancel(data),
37        Some("logs") => render_logs(data),
38        Some("reindex") => render_reindex(data),
39        Some("show") => render_show(data),
40        _ => fallback(data),
41    }
42}
43
44pub fn render_error(error: &ErrorBody) -> String {
45    let code = serde_json::to_value(error.code)
46        .ok()
47        .and_then(|value| value.as_str().map(str::to_owned))
48        .unwrap_or_else(|| "error".into());
49    let mut text = format!("error ({code}): {}\n", error.message);
50    if error.details.as_object().is_some_and(|map| !map.is_empty()) {
51        let _ = writeln!(text, "  details: {}", error.details);
52    }
53    text
54}
55
56fn render_daemon(data: &Value) -> String {
57    let pid = &data["pid"];
58    let state = if data["started"] == Value::Bool(true) {
59        "started"
60    } else {
61        "running"
62    };
63    let mut text = format!("daemon {state} (pid {pid})\n");
64    if let Some(socket) = data["socket"].as_str() {
65        let _ = writeln!(text, "socket: {socket}");
66    }
67    if let Some(log) = data["log"].as_str() {
68        let _ = writeln!(text, "log: {log}");
69    }
70    text
71}
72
73fn render_restart(data: &Value) -> String {
74    let active = data["active_runs"].as_u64().unwrap_or(0);
75    let noun = if active == 1 { "run" } else { "runs" };
76    format!("daemon draining for restart ({active} {noun} active)\n")
77}
78
79fn render_init(data: &Value) -> String {
80    let mut text = format!(
81        "initialized sloop in {}\n",
82        data["repository_root"].as_str().unwrap_or("?")
83    );
84    for (label, key) in [("created", "created"), ("existing", "existing")] {
85        for path in string_items(&data[key]) {
86            let _ = writeln!(text, "  {label}: {path}");
87        }
88    }
89    // The scaffold shows the shape but not the grammar; point at the verb
90    // that documents every field, since nothing else in an installed binary
91    // does.
92    text.push_str(
93        "\nwrite a ticket with `sloop template ticket > .agents/sloop/tickets/<name>.md`\n\
94         see also `sloop template flow|project|config`\n",
95    );
96    text
97}
98
99fn render_post(data: &Value) -> String {
100    let ticket = &data["ticket"];
101    let id = ticket["id"].as_str().unwrap_or("?");
102    let verb = if data["created"] == Value::Bool(false) {
103        "updated"
104    } else {
105        "registered"
106    };
107    let mut text = format!(
108        "ticket {id} {verb} from {} (project {}, {})\n",
109        ticket["file"].as_str().unwrap_or("?"),
110        ticket["project"].as_str().unwrap_or("?"),
111        ticket["state"].as_str().unwrap_or("?"),
112    );
113    // Silence would read as "nothing was asked for". A settled ticket did ask
114    // and was refused, so name the state that refused it — and the one verb
115    // that undoes it, which exists only for `failed`.
116    if let Some(state) = data["trigger_suppressed"]["state"].as_str() {
117        let _ = write!(text, "no trigger queued: {id} is {state}");
118        if state == "failed" {
119            let _ = write!(text, "; `sloop retry {id}` returns it to ready");
120        }
121        text.push('\n');
122    } else {
123        text.push_str(&render_trigger(&data["trigger"]));
124    }
125    text
126}
127
128fn render_run(data: &Value) -> String {
129    render_trigger(&data["trigger"])
130}
131
132fn render_trigger(trigger: &Value) -> String {
133    let Some(fields) = trigger.as_object() else {
134        return String::new();
135    };
136    let mut text = format!(
137        "trigger {} {} ({}",
138        fields.get("id").and_then(Value::as_str).unwrap_or("?"),
139        fields
140            .get("state")
141            .and_then(Value::as_str)
142            .unwrap_or("queued"),
143        fields.get("kind").and_then(Value::as_str).unwrap_or("?"),
144    );
145    for key in ["ticket", "project", "time"] {
146        if let Some(value) = fields.get(key).and_then(Value::as_str) {
147            let _ = write!(text, ", {key} {value}");
148        }
149    }
150    text.push_str(")\n");
151    text
152}
153
154fn render_status(data: &Value) -> String {
155    let daemon = &data["daemon"];
156    let state = if daemon["draining"] == Value::Bool(true) {
157        let active = data["gate"]["active_agents"].as_u64().unwrap_or(0);
158        let noun = if active == 1 { "run" } else { "runs" };
159        format!(", draining for restart ({active} {noun} active)")
160    } else if daemon["paused"] == Value::Bool(true) {
161        ", paused".into()
162    } else {
163        String::new()
164    };
165    let mut text = format!("daemon: pid {}{state}\n", daemon["pid"]);
166    let _ = writeln!(
167        text,
168        "agents: {} active of {} max",
169        data["gate"]["active_agents"], data["gate"]["max_agents"]
170    );
171    if data["gate"]["storage"]["writable"] == Value::Bool(false) {
172        text.push_str("storage: database full (dispatch blocked until space is available)\n");
173    }
174    if let Some(hours) = data["gate"]["running_hours"].as_object() {
175        let state = if hours.get("open") == Some(&Value::Bool(true)) {
176            "open"
177        } else {
178            "closed"
179        };
180        let _ = writeln!(
181            text,
182            "running hours: {}-{} ({state})",
183            hours.get("start").and_then(Value::as_str).unwrap_or("?"),
184            hours.get("end").and_then(Value::as_str).unwrap_or("?"),
185        );
186    }
187    if let Some(next_wake) = data["next_wake"].as_str() {
188        let _ = writeln!(text, "next wake: {next_wake}");
189    }
190
191    let tickets = &data["tickets"];
192    let counts: Vec<String> = [
193        "ready",
194        "held",
195        "blocked",
196        "claimed",
197        "merged",
198        "failed",
199        "needs_review",
200    ]
201    .iter()
202    .map(|state| format!("{} {state}", tickets[*state]))
203    .collect();
204    let _ = writeln!(text, "tickets: {}", counts.join(", "));
205
206    // Run lines lead with the alias and the ticket's name, so the line answers
207    // "what is this working on" without a second command. Queued triggers
208    // are not runs and keep their own shape.
209    let runs = data["runs"]
210        .as_array()
211        .map(Vec::as_slice)
212        .unwrap_or_default();
213    if runs.is_empty() {
214        text.push_str("runs: none\n");
215    } else {
216        text.push_str("runs:\n");
217        for run in runs {
218            let _ = writeln!(
219                text,
220                "  {} {} — {} (project {})",
221                run["alias"].as_str().unwrap_or("?"),
222                run["state"].as_str().unwrap_or("?"),
223                run["ticket_name"].as_str().unwrap_or("?"),
224                run["project"].as_str().unwrap_or("-"),
225            );
226        }
227    }
228
229    let queued = data["queued_triggers"]
230        .as_array()
231        .map(Vec::as_slice)
232        .unwrap_or_default();
233    if queued.is_empty() {
234        text.push_str("queued: none\n");
235    } else {
236        text.push_str("queued:\n");
237        for item in queued {
238            let _ = writeln!(
239                text,
240                "  {} {} (ticket {}, project {})",
241                item["id"].as_str().unwrap_or("?"),
242                item["state"].as_str().unwrap_or("?"),
243                item["ticket"].as_str().unwrap_or("-"),
244                item["project"].as_str().unwrap_or("-"),
245            );
246        }
247    }
248    text
249}
250
251fn render_list(data: &Value) -> String {
252    let tickets = data["tickets"]
253        .as_array()
254        .map(Vec::as_slice)
255        .unwrap_or_default();
256    if tickets.is_empty() {
257        return "no tickets\n".into();
258    }
259
260    let id_width = tickets
261        .iter()
262        .filter_map(|ticket| ticket["id"].as_str())
263        .map(str::len)
264        .max()
265        .unwrap_or(1);
266    let state_width = tickets
267        .iter()
268        .filter_map(|ticket| ticket["state"].as_str())
269        .map(str::len)
270        .max()
271        .unwrap_or(1);
272    let mut text = String::new();
273    for ticket in tickets {
274        let id = ticket["id"].as_str().unwrap_or("?");
275        let state = ticket["state"].as_str().unwrap_or("?");
276        let project = ticket["project"].as_str().unwrap_or("?");
277        let name = ticket["name"].as_str().unwrap_or("?");
278        let _ = write!(
279            text,
280            "{id:id_width$}  {state:state_width$}  ({project})  {name}"
281        );
282        let terminal = matches!(state, "merged" | "needs_review");
283        if ticket["run"].is_null()
284            && !terminal
285            && let Some(reason) = ticket["reason"].as_str()
286        {
287            let _ = write!(text, "  — {reason}");
288        }
289        text.push('\n');
290    }
291    text
292}
293
294fn render_ticket_transition(data: &Value) -> String {
295    format!(
296        "ticket {}: {} -> {}\n",
297        data["ticket"].as_str().unwrap_or("?"),
298        data["previous_state"].as_str().unwrap_or("?"),
299        data["state"].as_str().unwrap_or("?"),
300    )
301}
302
303fn render_scheduler_transition(data: &Value) -> String {
304    if data["paused"] == Value::Bool(true) {
305        "scheduler paused\n".into()
306    } else {
307        "scheduler resumed\n".into()
308    }
309}
310
311fn render_stop(data: &Value) -> String {
312    if data["running"] == Value::Bool(false) {
313        return "daemon is not running\n".into();
314    }
315    let mut text = format!("daemon stopping (pid {})\n", data["pid"]);
316    for run in string_items(&data["cancelled_runs"]) {
317        let _ = writeln!(text, "  cancelled: {run}");
318    }
319    text
320}
321
322fn render_wait(data: &Value) -> String {
323    let mut rendered = String::new();
324    if let Some(note) = data["note"].as_str() {
325        let _ = writeln!(rendered, "{note}");
326    }
327    let _ = writeln!(
328        rendered,
329        "run {} {}",
330        data["alias"].as_str().unwrap_or("?"),
331        data["state"].as_str().unwrap_or("?"),
332    );
333    if let Some(reason) = data["reason"].as_str() {
334        let _ = writeln!(rendered, "reason: {reason}");
335    }
336    rendered
337}
338
339fn render_cancel(data: &Value) -> String {
340    let mut text = format!("run {} cancelling\n", data["alias"].as_str().unwrap_or("?"));
341    if let Some(worktree) = data["worktree"].as_str() {
342        let _ = writeln!(text, "worktree preserved at {worktree}");
343    }
344    text
345}
346
347fn render_logs(data: &Value) -> String {
348    let entries = data["entries"]
349        .as_array()
350        .map(Vec::as_slice)
351        .unwrap_or_default();
352    let mut text = String::new();
353    if let Some(note) = data["note"].as_str() {
354        let _ = writeln!(text, "{note}");
355    }
356    if entries.is_empty() {
357        let _ = writeln!(
358            text,
359            "no output captured for run {}",
360            data["alias"].as_str().unwrap_or("?")
361        );
362        return text;
363    }
364    for entry in entries {
365        let timestamp = entry["timestamp"].as_str().unwrap_or("?");
366        let mut origin = entry["source"].as_str().unwrap_or("?").to_owned();
367        if let Some(stage) = entry["stage"].as_str() {
368            let _ = write!(origin, ":{stage}");
369            // The same `#N` suffix the stage table prints, so an unfiltered
370            // page of a looped run still says which execution wrote each line
371            // — and the label read here is the `--stage` selector to type next.
372            if let Some(attempt) = entry["attempt"].as_u64().filter(|attempt| *attempt > 1) {
373                let _ = write!(origin, "#{attempt}");
374            }
375        }
376        // Bytes that failed UTF-8 decoding are stored as base64; a human
377        // view labels them rather than printing garbage.
378        let line = entry["text"].as_str().unwrap_or("<binary output>");
379        let _ = writeln!(text, "[{timestamp}] [{origin}] {line}");
380    }
381    if let Some(notice) = logs_window_notice(entries.len(), data["elided"].as_u64().unwrap_or(0)) {
382        let _ = writeln!(text, "{notice}");
383    }
384    text
385}
386
387/// States what a trailing window hid, or `None` when the page showed
388/// everything: silence here claims the operator saw the whole log.
389///
390/// `complete` is deliberately not rendered. Every CLI read a human sees is
391/// tail-anchored and so reaches EOF, and `--follow` drains incomplete pages
392/// in its own loop. JSON consumers still get both fields.
393fn logs_window_notice(shown: usize, elided: u64) -> Option<String> {
394    if elided == 0 {
395        return None;
396    }
397    let total = shown as u64 + elided;
398    Some(format!(
399        "showing the last {shown} of {total} entries; --tail N or --follow for more"
400    ))
401}
402
403fn render_reindex(data: &Value) -> String {
404    format!(
405        "reindexed {} projects and {} tickets; {} ticket states changed; {} rows dropped\n",
406        data["projects_indexed"],
407        data["tickets_indexed"],
408        data["tickets_state_changed"],
409        data["rows_dropped"]
410    )
411}
412
413fn render_show(data: &Value) -> String {
414    match data["kind"].as_str() {
415        Some("dashboard") => render_dashboard(data),
416        Some("matches") => render_list(data),
417        Some("ticket") => render_ticket_show(data),
418        Some("run") => render_run_show(data),
419        Some("project") => render_project_show(data),
420        _ => fallback(data),
421    }
422}
423
424fn render_dashboard(data: &Value) -> String {
425    let daemon = &data["daemon"];
426    let gate = &data["gate"];
427    let state = if daemon["draining"] == Value::Bool(true) {
428        "draining"
429    } else if daemon["paused"] == Value::Bool(true) {
430        "paused"
431    } else {
432        "running"
433    };
434    let mut text = format!(
435        "daemon: pid {} {state} - {}/{} agents active",
436        daemon["pid"], gate["active_agents"], gate["max_agents"]
437    );
438    if let Some(next_wake) = data["next_wake"].as_str() {
439        let _ = write!(text, " - next wake {next_wake}");
440    }
441    text.push('\n');
442
443    let tickets = &data["tickets"];
444    let counts = [
445        "ready",
446        "held",
447        "blocked",
448        "claimed",
449        "merged",
450        "failed",
451        "needs_review",
452    ]
453    .iter()
454    .map(|state| format!("{} {state}", tickets[*state]))
455    .collect::<Vec<_>>();
456    let _ = writeln!(text, "tickets: {}", counts.join(", "));
457
458    let runs = data["runs"]
459        .as_array()
460        .map(Vec::as_slice)
461        .unwrap_or_default();
462    if !runs.is_empty() {
463        text.push_str("\nruns:\n");
464        let alias_width = column_width(runs, "alias");
465        let state_width = column_width(runs, "state");
466        for run in runs {
467            let _ = writeln!(
468                text,
469                "  {:alias_width$}  {:state_width$}  {}  {}  {}",
470                run["alias"].as_str().unwrap_or("?"),
471                run["state"].as_str().unwrap_or("?"),
472                span(
473                    run["started_at_ms"].as_i64(),
474                    run["finished_at_ms"].as_i64()
475                ),
476                stage_strip(&run["stages"]),
477                run["ticket_name"].as_str().unwrap_or("?"),
478            );
479        }
480    }
481
482    text.push_str("\nrecent:\n");
483    text.push_str(&render_list(
484        &serde_json::json!({"tickets": data["recent"]}),
485    ));
486    let shown = data["recent"].as_array().map_or(0, Vec::len);
487    let total = data["recent_total"].as_u64().unwrap_or(shown as u64);
488    let more = total.saturating_sub(shown as u64);
489    if total == 0 {
490        text.push_str("\n`sloop show <ref>` for detail\n");
491    } else {
492        let _ = writeln!(
493            text,
494            "\n{more} more - `sloop show -{total}` for all - `sloop show <ref>` for detail"
495        );
496    }
497    text
498}
499
500/// A scannable frontmatter summary followed by the ticket body. The worker's
501/// own `show` carries no `body`, so the same layout renders just the summary
502/// for it — no worker-specific branch and no behavior change.
503fn render_ticket_show(data: &Value) -> String {
504    let value = &data["value"];
505    let mut text = format!(
506        "{}  {}  ({})\n",
507        value["id"].as_str().unwrap_or("?"),
508        value["name"].as_str().unwrap_or("?"),
509        value["state"].as_str().unwrap_or("?"),
510    );
511    if let Some(project) = value["project"].as_str() {
512        let _ = writeln!(text, "project: {project}");
513    }
514    if let Some(worktree) = value["worktree"].as_str() {
515        let _ = writeln!(text, "worktree: {worktree}");
516    }
517    let blocked_by = string_items(&value["blocked_by"]).collect::<Vec<_>>();
518    if !blocked_by.is_empty() {
519        let _ = writeln!(text, "blocked_by: {}", blocked_by.join(", "));
520    }
521    for (label, key) in [
522        ("target", "target"),
523        ("model", "model"),
524        ("effort", "effort"),
525    ] {
526        if let Some(field) = value[key].as_str() {
527            let _ = writeln!(text, "{label}: {field}");
528        }
529    }
530    if let Some(reason) = value["reason"].as_str() {
531        let _ = writeln!(text, "reason: {reason}");
532    }
533    text.push_str(&ticket_runs(&value["runs"]));
534    if let Some(body) = value["body"]
535        .as_str()
536        .map(str::trim)
537        .filter(|body| !body.is_empty())
538    {
539        let _ = write!(text, "\n{body}\n");
540    }
541    text
542}
543
544/// The ticket's runs, newest first, one line each: alias, outcome, wall-clock
545/// span, and a strip of stage markers. A ticket that has never run prints
546/// `runs: none` rather than nothing, so "no runs yet" is distinguishable from
547/// an older `sloop` that did not report runs at all.
548fn ticket_runs(runs: &Value) -> String {
549    let Some(runs) = runs.as_array() else {
550        return String::new();
551    };
552    if runs.is_empty() {
553        return "runs: none\n".to_owned();
554    }
555    // The alias and state columns are padded to the widest entry so the spans
556    // and stage strips line up down the section and can be read as columns.
557    let alias_width = column_width(runs, "alias");
558    let state_width = column_width(runs, "state");
559    let mut text = String::from("runs:\n");
560    for run in runs {
561        let _ = writeln!(
562            text,
563            "  {:alias_width$}  {:state_width$}  {}  {}",
564            run["alias"].as_str().unwrap_or("?"),
565            run["state"].as_str().unwrap_or("?"),
566            span(
567                run["started_at_ms"].as_i64(),
568                run["finished_at_ms"].as_i64()
569            ),
570            stage_strip(&run["stages"]),
571        );
572    }
573    text
574}
575
576fn column_width(runs: &[Value], key: &str) -> usize {
577    runs.iter()
578        .filter_map(|run| run[key].as_str())
579        .map(str::len)
580        .max()
581        .unwrap_or(1)
582}
583
584/// The per-stage markers on a run's summary line. Deliberately ASCII: the rest
585/// of this renderer is, and a stage strip is exactly the output most likely to
586/// be piped through something that mangles glyphs.
587fn stage_strip(stages: &Value) -> String {
588    stages
589        .as_array()
590        .map(Vec::as_slice)
591        .unwrap_or_default()
592        .iter()
593        .map(|stage| {
594            if let Some(silent_for_ms) = stage["silent_for_ms"].as_i64() {
595                return format!(
596                    "{}:running (no output {})",
597                    stage_label(stage),
598                    duration(silent_for_ms),
599                );
600            }
601            format!("{}:{}", stage_label(stage), stage_marker(stage))
602        })
603        .collect::<Vec<_>>()
604        .join("  ")
605}
606
607/// How a stage execution is named in output. A backward edge re-enters a
608/// stage, so its name alone stops identifying a row — `build#2` says which
609/// pass it was. The suffix appears only past the first attempt: every stage in
610/// a flow without loops runs once, and saying so would be noise on every line.
611fn stage_label(stage: &Value) -> String {
612    let name = stage["stage"].as_str().unwrap_or("?");
613    match stage["attempt"].as_u64() {
614        Some(attempt) if attempt > 1 => format!("{name}#{attempt}"),
615        _ => name.to_owned(),
616    }
617}
618
619/// One stage's marker in the scan strip. An advisory stage's failure gets its
620/// own word rather than a decorated `FAIL`: the strip is read at a glance, and
621/// the only question it has to answer about a failure is whether the walk
622/// stopped there.
623fn stage_marker(stage: &Value) -> &'static str {
624    match stage["state"].as_str().unwrap_or("") {
625        "passed" => "ok",
626        "failed" if advisory(stage) => "warn",
627        "failed" => "FAIL",
628        "running" => "..",
629        _ => "-",
630    }
631}
632
633/// True for a stage the flow declares `fail_action: continue`, whose failure
634/// was recorded and stepped over.
635fn advisory(stage: &Value) -> bool {
636    stage["advisory"] == Value::Bool(true)
637}
638
639/// A run or stage's wall-clock span. An unfinished one is open-ended rather
640/// than closed at the current instant — a running stage has no end yet, and
641/// printing one would be an invention.
642fn span(start_ms: Option<i64>, end_ms: Option<i64>) -> String {
643    let Some(start) = start_ms.and_then(crate::clock::local_time) else {
644        return "-".to_owned();
645    };
646    let end = end_ms.and_then(crate::clock::local_time);
647    // `HH:MM` alone is ambiguous for anything that did not happen today or
648    // that ran across midnight, so those two cases widen to include the date.
649    let today = crate::clock::local_time(now_ms());
650    let dated = today.is_some_and(|today| !start.same_day(&today))
651        || end.is_some_and(|end| !start.same_day(&end));
652    let opening = if dated { start.dated() } else { start.clock() };
653    match end {
654        None => format!("{opening}-..."),
655        Some(end) if start.same_day(&end) => format!("{opening}-{}", end.clock()),
656        Some(end) => format!("{opening}-{}", end.dated()),
657    }
658}
659
660fn now_ms() -> i64 {
661    use std::time::{SystemTime, UNIX_EPOCH};
662    SystemTime::now()
663        .duration_since(UNIX_EPOCH)
664        .map(|elapsed| elapsed.as_millis() as i64)
665        .unwrap_or(0)
666}
667
668/// A duration in the coarsest unit that still says something useful. Stage
669/// durations range from milliseconds to hours, and `4m12s` is easier to
670/// compare at a glance than `252000ms`.
671fn duration(milliseconds: i64) -> String {
672    let seconds = milliseconds / 1_000;
673    match (seconds / 3_600, (seconds % 3_600) / 60, seconds % 60) {
674        (0, 0, seconds) => format!("{seconds}s"),
675        (0, minutes, seconds) => format!("{minutes}m{seconds}s"),
676        (hours, minutes, _) => format!("{hours}h{minutes}m"),
677    }
678}
679
680fn run_state(value: &Value) -> String {
681    let state = value["state"].as_str().unwrap_or("?");
682    if state == "failed"
683        && let Some(threshold_ms) = value["stall"]["threshold_ms"].as_i64()
684    {
685        return format!("failed (stalled: no output for {})", duration(threshold_ms));
686    }
687    state.to_owned()
688}
689
690/// The run's stage table: how far the flow got, and on what evidence. This is
691/// the view that answers "how did this run fail" without opening the database.
692fn run_stages(stages: &Value) -> String {
693    let Some(stages) = stages.as_array().filter(|stages| !stages.is_empty()) else {
694        return String::new();
695    };
696    let names: Vec<String> = stages.iter().map(stage_label).collect();
697    let name_width = names.iter().map(String::len).max().unwrap_or(5).max(5);
698    let mut text = String::from("stages:\n");
699    for (stage, name) in stages.iter().zip(&names) {
700        let state = stage["state"].as_str().unwrap_or("?");
701        let mut line = format!(
702            "  {name:name_width$}  {state:7}  {}",
703            span(
704                stage["started_at_ms"].as_i64(),
705                stage["finished_at_ms"].as_i64()
706            ),
707        );
708        if let Some(elapsed) = stage["duration_ms"].as_i64() {
709            let _ = write!(line, "  {}", duration(elapsed));
710        }
711        if let Some(exit_code) = stage["exit_code"].as_i64() {
712            let _ = write!(line, "  exit {exit_code}");
713        }
714        // Only worth saying on the row it changes the meaning of. On a passing
715        // or unreached advisory stage the word answers a question nobody asked;
716        // on a failing one it is the difference between "this ended the run"
717        // and "this was noted and stepped over".
718        if state == "failed" && advisory(stage) {
719            line.push_str("  advisory");
720        }
721        if let Some(source) = stage["verdict_source"].as_str() {
722            let _ = write!(line, "  verdict from {source}");
723        }
724        // A worker's own confidence sits beside the verdict it qualifies, the
725        // same way a panel seat's does one level down.
726        if let Some(confidence) = stage["confidence"].as_str() {
727            let _ = write!(line, "  confidence {confidence}");
728        }
729        if let Some(silent_for_ms) = stage["silent_for_ms"].as_i64() {
730            let _ = write!(line, "  (no output {})", duration(silent_for_ms));
731        }
732        let _ = writeln!(text, "{}", line.trim_end());
733        text.push_str(&panel_reviewers(&stage["reviewers"]));
734    }
735    text
736}
737
738/// The seats under a panel-judged stage: who reviewed, what they said, how
739/// sure they were, and why.
740///
741/// A panel's verdict is a count, and a count on its own tells an operator
742/// nothing about *which* reviewer objected — which is the only part worth
743/// reading when the stage failed. Every seat prints, silent ones included,
744/// because a seat that never reported is a `Fail` the count used.
745fn panel_reviewers(reviewers: &Value) -> String {
746    let Some(reviewers) = reviewers.as_array().filter(|seats| !seats.is_empty()) else {
747        return String::new();
748    };
749    let targets: Vec<&str> = reviewers
750        .iter()
751        .map(|seat| seat["target"].as_str().unwrap_or("?"))
752        .collect();
753    let width = targets.iter().map(|target| target.len()).max().unwrap_or(0);
754    let mut text = String::new();
755    for (seat, target) in reviewers.iter().zip(&targets) {
756        let mut line = format!(
757            "    {target:width$}  {:4}",
758            seat["verdict"].as_str().unwrap_or("?"),
759        );
760        // A reviewer that never reported has no confidence to state, and
761        // inventing one would dress an absence up as an opinion.
762        if let Some(confidence) = seat["confidence"].as_str() {
763            let _ = write!(line, "  confidence {confidence}");
764        }
765        if let Some(reason) = seat["reason"].as_str().filter(|text| !text.is_empty()) {
766            let _ = write!(line, "  {reason}");
767        }
768        let _ = writeln!(text, "{}", line.trim_end());
769    }
770    text
771}
772
773/// A run's identity and settled evidence, one fact per line.
774fn render_run_show(data: &Value) -> String {
775    let value = &data["value"];
776    let mut text = String::new();
777    if let Some(note) = value["note"].as_str() {
778        let _ = writeln!(text, "{note}");
779    }
780    let _ = writeln!(
781        text,
782        "{}  ({})",
783        value["alias"].as_str().unwrap_or("?"),
784        run_state(value),
785    );
786    let ticket = value["ticket"].as_str().unwrap_or("?");
787    match value["ticket_name"].as_str() {
788        Some(name) => {
789            let _ = writeln!(text, "ticket: {ticket}  {name}");
790        }
791        None => {
792            let _ = writeln!(text, "ticket: {ticket}");
793        }
794    }
795    if let Some(branch) = value["branch"].as_str() {
796        let _ = writeln!(text, "branch: {branch}");
797    }
798    if let Some(worktree) = value["worktree"].as_str() {
799        let _ = writeln!(text, "worktree: {worktree}");
800    }
801    // Claim, start, and finish bound the run; a run still in flight simply
802    // lacks the later fields rather than showing a guessed end.
803    let timeline = [
804        ("claimed", value["claimed_at_ms"].as_i64()),
805        ("started", value["started_at_ms"].as_i64()),
806        ("finished", value["finished_at_ms"].as_i64()),
807    ]
808    .into_iter()
809    .filter_map(|(label, at_ms)| {
810        let at = crate::clock::local_time(at_ms?)?;
811        Some(format!("{label} {}", at.clock()))
812    })
813    .collect::<Vec<_>>();
814    if !timeline.is_empty() {
815        let _ = writeln!(text, "timeline: {}", timeline.join("  "));
816    }
817    // `exit: 0` read as "the run passed" even when a later stage had failed,
818    // which is exactly how one smoke-test failure got misdiagnosed. The label
819    // now says whose exit it is.
820    if let Some(exit_code) = value["exit_code"].as_i64() {
821        let _ = writeln!(text, "agent exit: {exit_code}");
822    }
823    if let Some(reason) = value["reason"].as_str() {
824        let _ = writeln!(text, "reason: {reason}");
825    }
826    text.push_str(&run_stages(&value["stages"]));
827    text
828}
829
830fn render_project_show(data: &Value) -> String {
831    let project = &data["value"];
832    let mut text = format!(
833        "project {} ({})\n",
834        project["title"].as_str().unwrap_or("?"),
835        project["id"].as_str().unwrap_or("?"),
836    );
837    let tickets = project["tickets"]
838        .as_array()
839        .map(Vec::as_slice)
840        .unwrap_or_default();
841    if tickets.is_empty() {
842        text.push_str("no tickets\n");
843        return text;
844    }
845    for ticket in tickets {
846        let _ = writeln!(
847            text,
848            "\n{}  {}  ({})",
849            ticket["id"].as_str().unwrap_or("?"),
850            ticket["name"].as_str().unwrap_or("?"),
851            ticket["state"].as_str().unwrap_or("?"),
852        );
853        for note in ticket["notes"]
854            .as_array()
855            .map(Vec::as_slice)
856            .unwrap_or_default()
857        {
858            let _ = writeln!(
859                text,
860                "  note {} [{}]: {}",
861                note["id"].as_str().unwrap_or("?"),
862                note["run"].as_str().unwrap_or("?"),
863                note["text"].as_str().unwrap_or("?"),
864            );
865        }
866        for commit in ticket["commits"]
867            .as_array()
868            .map(Vec::as_slice)
869            .unwrap_or_default()
870        {
871            let _ = writeln!(
872                text,
873                "  commit {} [{}]: {}",
874                commit["hash"].as_str().unwrap_or("?"),
875                commit["run"].as_str().unwrap_or("?"),
876                commit["message"].as_str().unwrap_or("?"),
877            );
878        }
879    }
880    text
881}
882
883fn fallback(data: &Value) -> String {
884    let mut text = serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string());
885    text.push('\n');
886    text
887}
888
889fn string_items(value: &Value) -> impl Iterator<Item = &str> {
890    value
891        .as_array()
892        .map(Vec::as_slice)
893        .unwrap_or_default()
894        .iter()
895        .filter_map(Value::as_str)
896}
897
898#[cfg(test)]
899mod tests {
900    use serde_json::{Value, json};
901
902    use super::render;
903    use crate::protocol::{ErrorBody, ErrorCode, ResponseEnvelope};
904
905    #[test]
906    fn errors_render_the_code_and_message() {
907        let response = ResponseEnvelope::failure(
908            None,
909            ErrorBody {
910                code: ErrorCode::Conflict,
911                message: "run `R1` is `merged` and cannot be cancelled".into(),
912                details: json!({}),
913            },
914        );
915
916        let text = render(Some("cancel"), &response);
917        assert_eq!(
918            text,
919            "error (conflict): run `R1` is `merged` and cannot be cancelled\n"
920        );
921    }
922
923    #[test]
924    fn status_renders_counts_and_active_runs() {
925        let response = ResponseEnvelope::success(
926            None,
927            json!({
928                "daemon": {"pid": 42, "paused": false},
929                "gate": {
930                    "active_agents": 1,
931                    "max_agents": 2,
932                    "running_hours": {"start": "22:00", "end": "06:00", "open": false}
933                },
934                "next_wake": "2026-07-15T22:00:00Z",
935                "runs": [{
936                    "id": "3f2a9c1b7d4e5061a2b3c4d5e6f70819", "alias": "T1-r1",
937                    "state": "running", "ticket": "T1", "ticket_name": "Generalized stages",
938                    "project": "default"
939                }],
940                "queued_triggers": [],
941                "tickets": {
942                    "ready": 1, "held": 2, "blocked": 0, "claimed": 1,
943                    "merged": 3, "failed": 0, "needs_review": 0
944                }
945            }),
946        );
947
948        let text = render(Some("status"), &response);
949        assert!(text.contains("daemon: pid 42\n"), "{text}");
950        assert!(text.contains("agents: 1 active of 2 max"), "{text}");
951        assert!(
952            text.contains("running hours: 22:00-06:00 (closed)"),
953            "{text}"
954        );
955        assert!(text.contains("next wake: 2026-07-15T22:00:00Z"), "{text}");
956        assert!(
957            text.contains(
958                "tickets: 1 ready, 2 held, 0 blocked, 1 claimed, 3 merged, 0 failed, 0 needs_review"
959            ),
960            "{text}"
961        );
962        assert!(
963            text.contains("T1-r1 running — Generalized stages (project default)"),
964            "{text}"
965        );
966        assert!(text.contains("queued: none"), "{text}");
967    }
968
969    #[test]
970    fn status_renders_a_full_database_as_a_dispatch_gate() {
971        let response = ResponseEnvelope::success(
972            None,
973            json!({
974                "daemon": {"pid": 42, "paused": false},
975                "gate": {
976                    "active_agents": 0,
977                    "max_agents": 1,
978                    "storage": {"writable": false, "reason": "database_full"}
979                },
980                "runs": [],
981                "queued_triggers": [],
982                "tickets": {}
983            }),
984        );
985
986        let text = render(Some("status"), &response);
987        assert!(
988            text.contains("storage: database full (dispatch blocked"),
989            "{text}"
990        );
991    }
992
993    #[test]
994    fn list_renders_reasons_but_not_running_or_terminal_reasons() {
995        let response = ResponseEnvelope::success(
996            None,
997            json!({
998                "tickets": [
999                    {"id": "TICKET-1", "name": "Fix dispatch", "project": "default", "state": "ready", "run": null,
1000                     "reason": "scheduler is paused; resume with `sloop resume`"},
1001                    {"id": "T2", "name": "Add retries", "project": "default", "state": "claimed", "run": "R1",
1002                     "reason": "claimed by run R1"},
1003                    {"id": "T3", "name": "Polish the UI", "project": "web", "state": "merged", "run": null,
1004                     "reason": null}
1005                ]
1006            }),
1007        );
1008
1009        assert_eq!(
1010            render(Some("list"), &response),
1011            "TICKET-1  ready    (default)  Fix dispatch  — scheduler is paused; resume with `sloop resume`\n\
1012             T2        claimed  (default)  Add retries\n\
1013             T3        merged   (web)  Polish the UI\n"
1014        );
1015    }
1016
1017    #[test]
1018    fn empty_list_says_there_are_no_tickets() {
1019        let response = ResponseEnvelope::success(None, json!({"tickets": []}));
1020        assert_eq!(render(Some("list"), &response), "no tickets\n");
1021    }
1022
1023    #[test]
1024    fn hold_and_ready_render_the_transition() {
1025        let response = ResponseEnvelope::success(
1026            None,
1027            json!({
1028                "ticket": "T1",
1029                "previous_state": "held",
1030                "state": "ready",
1031                "overridden": true,
1032            }),
1033        );
1034
1035        for verb in ["hold", "ready"] {
1036            assert_eq!(render(Some(verb), &response), "ticket T1: held -> ready\n");
1037        }
1038    }
1039
1040    #[test]
1041    fn retry_renders_the_transition() {
1042        let response = ResponseEnvelope::success(
1043            None,
1044            json!({
1045                "ticket": "T1",
1046                "previous_state": "failed",
1047                "state": "ready",
1048            }),
1049        );
1050
1051        assert_eq!(
1052            render(Some("retry"), &response),
1053            "ticket T1: failed -> ready\n"
1054        );
1055    }
1056
1057    fn post_response(created: bool, trigger: Value, suppressed: Value) -> ResponseEnvelope {
1058        ResponseEnvelope::success(
1059            None,
1060            json!({
1061                "ticket": {
1062                    "id": "TICK-43",
1063                    "project": "default",
1064                    "file": ".agents/sloop/tickets/cooldown.md",
1065                    "state": suppressed["state"].as_str().unwrap_or("ready"),
1066                },
1067                "created": created,
1068                "trigger": trigger,
1069                "trigger_suppressed": suppressed,
1070            }),
1071        )
1072    }
1073
1074    #[test]
1075    fn a_fresh_post_reports_the_registration_and_its_trigger() {
1076        let response = post_response(
1077            true,
1078            json!({"id": "TR95", "kind": "auto", "state": "queued", "ticket": "TICK-43"}),
1079            Value::Null,
1080        );
1081
1082        assert_eq!(
1083            render(Some("post"), &response),
1084            "ticket TICK-43 registered from .agents/sloop/tickets/cooldown.md \
1085             (project default, ready)\n\
1086             trigger TR95 queued (auto, ticket TICK-43)\n"
1087        );
1088    }
1089
1090    #[test]
1091    fn a_repost_of_a_terminal_ticket_names_the_state_that_suppressed_the_trigger() {
1092        let merged = post_response(
1093            false,
1094            Value::Null,
1095            json!({"reason": "terminal_ticket", "state": "merged"}),
1096        );
1097
1098        assert_eq!(
1099            render(Some("post"), &merged),
1100            "ticket TICK-43 updated from .agents/sloop/tickets/cooldown.md \
1101             (project default, merged)\n\
1102             no trigger queued: TICK-43 is merged\n"
1103        );
1104
1105        // Only `failed` has a verb that undoes it, so only `failed` gets one.
1106        let failed = post_response(
1107            false,
1108            Value::Null,
1109            json!({"reason": "terminal_ticket", "state": "failed"}),
1110        );
1111
1112        assert_eq!(
1113            render(Some("post"), &failed),
1114            "ticket TICK-43 updated from .agents/sloop/tickets/cooldown.md \
1115             (project default, failed)\n\
1116             no trigger queued: TICK-43 is failed; \
1117             `sloop retry TICK-43` returns it to ready\n"
1118        );
1119    }
1120
1121    #[test]
1122    fn a_post_that_requested_no_trigger_stays_silent_about_one() {
1123        let response = post_response(false, Value::Null, Value::Null);
1124
1125        assert_eq!(
1126            render(Some("post"), &response),
1127            "ticket TICK-43 updated from .agents/sloop/tickets/cooldown.md \
1128             (project default, ready)\n"
1129        );
1130    }
1131
1132    #[test]
1133    fn unknown_shapes_fall_back_to_pretty_json() {
1134        let response = ResponseEnvelope::success(None, json!({"surprise": true}));
1135        let text = render(Some("mystery"), &response);
1136        assert!(text.contains("\"surprise\": true"), "{text}");
1137    }
1138
1139    #[test]
1140    fn project_show_groups_activity_by_ticket() {
1141        let response = ResponseEnvelope::success(
1142            None,
1143            json!({
1144                "ref": "backend",
1145                "kind": "project",
1146                "value": {
1147                    "id": "backend",
1148                    "title": "Backend",
1149                    "tickets": [{
1150                        "id": "T1",
1151                        "name": "Persist cooldowns",
1152                        "state": "merged",
1153                        "notes": [{"id": "N1", "run": "R1", "text": "halfway"}],
1154                        "commits": [{"hash": "abc1234", "run": "R1", "message": "persist cooldowns"}]
1155                    }]
1156                }
1157            }),
1158        );
1159
1160        assert_eq!(
1161            render(Some("show"), &response),
1162            concat!(
1163                "project Backend (backend)\n",
1164                "\n",
1165                "T1  Persist cooldowns  (merged)\n",
1166                "  note N1 [R1]: halfway\n",
1167                "  commit abc1234 [R1]: persist cooldowns\n",
1168            )
1169        );
1170    }
1171
1172    #[test]
1173    fn ticket_show_renders_a_summary_then_the_body() {
1174        let response = ResponseEnvelope::success(
1175            None,
1176            json!({
1177                "ref": "TICK-1",
1178                "kind": "ticket",
1179                "value": {
1180                    "id": "TICK-1",
1181                    "name": "cooldown",
1182                    "state": "ready",
1183                    "project": "default",
1184                    "worktree": "sloop/TICK-1",
1185                    "blocked_by": ["TICK-0"],
1186                    "target": "claude",
1187                    "model": "opus",
1188                    "effort": "high",
1189                    "body": "# Persist cooldowns\n\nSurvive restarts.",
1190                }
1191            }),
1192        );
1193
1194        assert_eq!(
1195            render(Some("show"), &response),
1196            concat!(
1197                "TICK-1  cooldown  (ready)\n",
1198                "project: default\n",
1199                "worktree: sloop/TICK-1\n",
1200                "blocked_by: TICK-0\n",
1201                "target: claude\n",
1202                "model: opus\n",
1203                "effort: high\n",
1204                "\n",
1205                "# Persist cooldowns\n\nSurvive restarts.\n",
1206            )
1207        );
1208    }
1209
1210    #[test]
1211    fn ticket_show_without_a_body_renders_only_the_summary() {
1212        let response = ResponseEnvelope::success(
1213            None,
1214            json!({
1215                "ref": "T1",
1216                "kind": "ticket",
1217                "value": {"id": "T1", "name": "work", "state": "ready", "blocked_by": []}
1218            }),
1219        );
1220
1221        assert_eq!(render(Some("show"), &response), "T1  work  (ready)\n");
1222    }
1223
1224    #[test]
1225    fn run_show_renders_the_run_evidence_summary() {
1226        let response = ResponseEnvelope::success(
1227            None,
1228            json!({
1229                "ref": "R14",
1230                "kind": "run",
1231                "value": {
1232                    "id": "R14",
1233                    "alias": "TICK-1-r2",
1234                    "ticket": "TICK-1",
1235                    "ticket_name": "cooldown",
1236                    "state": "merged",
1237                    "terminal": true,
1238                    "branch": "sloop/R14-TICK-1",
1239                    "worktree": "/repo/.worktrees/R14",
1240                    "exit_code": 0,
1241                    "reason": null,
1242                    "classification": null,
1243                }
1244            }),
1245        );
1246
1247        assert_eq!(
1248            render(Some("show"), &response),
1249            concat!(
1250                "TICK-1-r2  (merged)\n",
1251                "ticket: TICK-1  cooldown\n",
1252                "branch: sloop/R14-TICK-1\n",
1253                "worktree: /repo/.worktrees/R14\n",
1254                "agent exit: 0\n",
1255            )
1256        );
1257    }
1258
1259    /// An instant today, so the span renders as bare `HH:MM` and the assertion
1260    /// does not depend on which day the suite runs.
1261    fn today_at(offset_ms: i64) -> i64 {
1262        let today = crate::clock::local_time(super::now_ms()).expect("local time");
1263        // Noon plus the offset: far enough from either midnight that a few
1264        // minutes either way cannot spill into another day.
1265        super::now_ms() - i64::from(today.hour) * 3_600_000 - i64::from(today.minute) * 60_000
1266            + 12 * 3_600_000
1267            + offset_ms
1268    }
1269
1270    fn clock_at(offset_ms: i64) -> String {
1271        crate::clock::local_time(today_at(offset_ms))
1272            .expect("local time")
1273            .clock()
1274    }
1275
1276    #[test]
1277    fn ticket_show_lists_runs_newest_first_with_a_stage_strip() {
1278        let response = ResponseEnvelope::success(
1279            None,
1280            json!({
1281                "ref": "TICK-1",
1282                "kind": "ticket",
1283                "value": {
1284                    "id": "TICK-1", "name": "cooldown", "state": "merged", "blocked_by": [],
1285                    "runs": [
1286                        {
1287                            "alias": "TICK-1-r2", "state": "merged",
1288                            "started_at_ms": today_at(0),
1289                            "finished_at_ms": today_at(360_000),
1290                            "stages": [
1291                                {"stage": "build", "state": "passed"},
1292                                {"stage": "test", "state": "passed"},
1293                                {"stage": "merge", "state": "passed"},
1294                            ],
1295                        },
1296                        {
1297                            "alias": "TICK-1-r1", "state": "needs_review",
1298                            "started_at_ms": today_at(-3_600_000),
1299                            "finished_at_ms": today_at(-3_240_000),
1300                            "stages": [
1301                                {"stage": "build", "state": "passed"},
1302                                {"stage": "test", "state": "failed"},
1303                                {"stage": "merge", "state": "pending"},
1304                            ],
1305                        },
1306                    ],
1307                }
1308            }),
1309        );
1310
1311        assert_eq!(
1312            render(Some("show"), &response),
1313            format!(
1314                concat!(
1315                    "TICK-1  cooldown  (merged)\n",
1316                    "runs:\n",
1317                    "  TICK-1-r2  merged        {}-{}  build:ok  test:ok  merge:ok\n",
1318                    "  TICK-1-r1  needs_review  {}-{}  build:ok  test:FAIL  merge:-\n",
1319                ),
1320                clock_at(0),
1321                clock_at(360_000),
1322                clock_at(-3_600_000),
1323                clock_at(-3_240_000),
1324            )
1325        );
1326    }
1327
1328    #[test]
1329    fn ticket_show_says_so_when_a_ticket_has_never_run() {
1330        let response = ResponseEnvelope::success(
1331            None,
1332            json!({
1333                "ref": "T1",
1334                "kind": "ticket",
1335                "value": {"id": "T1", "name": "work", "state": "ready", "blocked_by": [],
1336                          "runs": []}
1337            }),
1338        );
1339
1340        assert_eq!(
1341            render(Some("show"), &response),
1342            "T1  work  (ready)\nruns: none\n"
1343        );
1344    }
1345
1346    #[test]
1347    fn run_show_renders_stages_and_the_derived_reason() {
1348        let response = ResponseEnvelope::success(
1349            None,
1350            json!({
1351                "ref": "TICK-1-r1",
1352                "kind": "run",
1353                "value": {
1354                    "id": "R14", "alias": "TICK-1-r1", "ticket": "TICK-1",
1355                    "state": "needs_review", "terminal": true, "exit_code": 0,
1356                    "claimed_at_ms": today_at(0),
1357                    "started_at_ms": today_at(1_000),
1358                    "finished_at_ms": today_at(252_000),
1359                    "reason": "stage `test` failed (exit 1) after agent completed with commits",
1360                    "stages": [
1361                        {
1362                            "stage": "build", "state": "passed",
1363                            "started_at_ms": today_at(1_000),
1364                            "finished_at_ms": today_at(61_000),
1365                            "duration_ms": 60_000, "exit_code": 0,
1366                            "verdict_source": "exit_code",
1367                        },
1368                        {
1369                            "stage": "test", "state": "failed",
1370                            "started_at_ms": today_at(61_000),
1371                            "finished_at_ms": today_at(252_000),
1372                            "duration_ms": 191_000, "exit_code": 1,
1373                            "verdict_source": "exit_code",
1374                        },
1375                        {"stage": "merge", "state": "pending"},
1376                    ],
1377                }
1378            }),
1379        );
1380
1381        assert_eq!(
1382            render(Some("show"), &response),
1383            format!(
1384                concat!(
1385                    "TICK-1-r1  (needs_review)\n",
1386                    "ticket: TICK-1\n",
1387                    "timeline: claimed {}  started {}  finished {}\n",
1388                    "agent exit: 0\n",
1389                    "reason: stage `test` failed (exit 1) after agent completed with commits\n",
1390                    "stages:\n",
1391                    "  build  passed   {}-{}  1m0s  exit 0  verdict from exit_code\n",
1392                    "  test   failed   {}-{}  3m11s  exit 1  verdict from exit_code\n",
1393                    "  merge  pending  -\n",
1394                ),
1395                clock_at(0),
1396                clock_at(1_000),
1397                clock_at(252_000),
1398                clock_at(1_000),
1399                clock_at(61_000),
1400                clock_at(61_000),
1401                clock_at(252_000),
1402            )
1403        );
1404    }
1405
1406    /// A stage a backward edge re-entered appears once per execution, and the
1407    /// re-runs are told apart by the attempt suffix. Without it the table
1408    /// would show two identically-named `test` rows disagreeing about the
1409    /// verdict.
1410    #[test]
1411    fn run_show_tells_re_runs_apart_by_attempt() {
1412        let response = ResponseEnvelope::success(
1413            None,
1414            json!({
1415                "ref": "TICK-1-r1",
1416                "kind": "run",
1417                "value": {
1418                    "id": "R14", "alias": "TICK-1-r1", "ticket": "TICK-1",
1419                    "state": "merged", "terminal": true,
1420                    "stages": [
1421                        {"stage": "build", "state": "passed", "attempt": 1},
1422                        {"stage": "build", "state": "passed", "attempt": 2},
1423                        {"stage": "test", "state": "failed", "attempt": 1,
1424                         "exit_code": 1},
1425                        {"stage": "test", "state": "passed", "attempt": 2,
1426                         "exit_code": 0},
1427                        {"stage": "merge", "state": "passed", "attempt": 1},
1428                    ],
1429                }
1430            }),
1431        );
1432
1433        assert_eq!(
1434            render(Some("show"), &response),
1435            concat!(
1436                "TICK-1-r1  (merged)\n",
1437                "ticket: TICK-1\n",
1438                "stages:\n",
1439                "  build    passed   -\n",
1440                "  build#2  passed   -\n",
1441                "  test     failed   -  exit 1\n",
1442                "  test#2   passed   -  exit 0\n",
1443                "  merge    passed   -\n",
1444            )
1445        );
1446    }
1447
1448    /// A panel's verdict is a tally, and a tally alone does not say which
1449    /// reviewer objected — which is the only part worth reading when the stage
1450    /// failed. Every seat prints, including the one that never reported.
1451    #[test]
1452    fn run_show_lists_every_panel_seat_under_its_stage() {
1453        let response = ResponseEnvelope::success(
1454            None,
1455            json!({
1456                "ref": "TICK-1-r1",
1457                "kind": "run",
1458                "value": {
1459                    "id": "R14", "alias": "TICK-1-r1", "ticket": "TICK-1",
1460                    "state": "needs_review", "terminal": true,
1461                    "stages": [
1462                        {"stage": "build", "state": "passed", "attempt": 1},
1463                        {
1464                            "stage": "review", "state": "failed", "attempt": 1,
1465                            "verdict_source": "panel",
1466                            "reason": "panel: 1 of 3 reviewers passed, quorum 2",
1467                            "reviewers": [
1468                                {"reviewer": 0, "target": "claude", "verdict": "pass",
1469                                 "confidence": "high", "reason": "reads correct"},
1470                                {"reviewer": 1, "target": "codex", "verdict": "fail",
1471                                 "confidence": "medium", "reason": "missing a test"},
1472                                {"reviewer": 2, "target": "gemini", "verdict": "fail",
1473                                 "confidence": null, "reason": "no verdict reported"},
1474                            ],
1475                        },
1476                    ],
1477                }
1478            }),
1479        );
1480
1481        assert_eq!(
1482            render(Some("show"), &response),
1483            concat!(
1484                "TICK-1-r1  (needs_review)\n",
1485                "ticket: TICK-1\n",
1486                "stages:\n",
1487                "  build   passed   -\n",
1488                "  review  failed   -  verdict from panel\n",
1489                "    claude  pass  confidence high  reads correct\n",
1490                "    codex   fail  confidence medium  missing a test\n",
1491                "    gemini  fail  no verdict reported\n",
1492            )
1493        );
1494    }
1495
1496    /// An advisory failure and the failure that ended the run must not read
1497    /// alike. The table says `advisory` in words next to the state it
1498    /// qualifies, and the run's own reason names the halting stage — never the
1499    /// advisory one, which the walk provably stepped over.
1500    #[test]
1501    fn run_show_separates_an_advisory_failure_from_the_halting_one() {
1502        let response = ResponseEnvelope::success(
1503            None,
1504            json!({
1505                "ref": "TICK-1-r1",
1506                "kind": "run",
1507                "value": {
1508                    "id": "R14", "alias": "TICK-1-r1", "ticket": "TICK-1",
1509                    "state": "needs_review", "terminal": true,
1510                    "halt": "fail_action",
1511                    "reason": "stage `test` failed (exit 1) after agent completed with commits",
1512                    "stages": [
1513                        {"stage": "build", "state": "passed", "attempt": 1,
1514                         "advisory": false},
1515                        {"stage": "lint", "state": "failed", "attempt": 1,
1516                         "exit_code": 2, "advisory": true},
1517                        {"stage": "test", "state": "failed", "attempt": 1,
1518                         "exit_code": 1, "advisory": false},
1519                        {"stage": "merge", "state": "pending",
1520                         "advisory": false},
1521                    ],
1522                }
1523            }),
1524        );
1525
1526        assert_eq!(
1527            render(Some("show"), &response),
1528            concat!(
1529                "TICK-1-r1  (needs_review)\n",
1530                "ticket: TICK-1\n",
1531                "reason: stage `test` failed (exit 1) after agent completed with commits\n",
1532                "stages:\n",
1533                "  build  passed   -\n",
1534                "  lint   failed   -  exit 2  advisory\n",
1535                "  test   failed   -  exit 1\n",
1536                "  merge  pending  -\n",
1537            )
1538        );
1539    }
1540
1541    /// A worker's own confidence sits on the stage row, the way a panel seat's
1542    /// sits on the seat row — one flag, one meaning, wherever it is reported.
1543    #[test]
1544    fn run_show_carries_a_reported_stages_confidence() {
1545        let response = ResponseEnvelope::success(
1546            None,
1547            json!({
1548                "ref": "TICK-1-r1",
1549                "kind": "run",
1550                "value": {
1551                    "id": "R14", "alias": "TICK-1-r1", "ticket": "TICK-1",
1552                    "state": "merged", "terminal": true,
1553                    "stages": [{
1554                        "stage": "review", "state": "passed", "attempt": 1,
1555                        "verdict_source": "reported", "confidence": "low",
1556                    }],
1557                }
1558            }),
1559        );
1560
1561        assert!(
1562            render(Some("show"), &response)
1563                .contains("review  passed   -  verdict from reported  confidence low"),
1564            "{}",
1565            render(Some("show"), &response)
1566        );
1567    }
1568
1569    /// The scan strip has to carry the distinction too, in one token: an
1570    /// operator reading a ticket's runs decides from it whether to open the
1571    /// run at all.
1572    #[test]
1573    fn the_ticket_strip_marks_an_advisory_failure_as_a_warning() {
1574        let response = ResponseEnvelope::success(
1575            None,
1576            json!({
1577                "ref": "TICK-1",
1578                "kind": "ticket",
1579                "value": {
1580                    "id": "TICK-1", "name": "advisory", "state": "merged", "blocked_by": [],
1581                    "runs": [{
1582                        "alias": "TICK-1-r1", "state": "merged",
1583                        "stages": [
1584                            {"stage": "build", "state": "passed", "attempt": 1},
1585                            {"stage": "lint", "state": "failed", "attempt": 1,
1586                             "advisory": true},
1587                            {"stage": "merge", "state": "passed", "attempt": 1},
1588                        ],
1589                    }],
1590                }
1591            }),
1592        );
1593
1594        assert!(
1595            render(Some("show"), &response).contains("build:ok  lint:warn  merge:ok"),
1596            "{}",
1597            render(Some("show"), &response)
1598        );
1599    }
1600
1601    /// The ticket view's strip carries the same distinction, so a loop is
1602    /// visible without opening the run.
1603    #[test]
1604    fn the_ticket_strip_marks_re_runs_too() {
1605        let response = ResponseEnvelope::success(
1606            None,
1607            json!({
1608                "ref": "TICK-1",
1609                "kind": "ticket",
1610                "value": {
1611                    "id": "TICK-1", "name": "loop", "state": "merged", "blocked_by": [],
1612                    "runs": [{
1613                        "alias": "TICK-1-r1", "state": "merged",
1614                        "stages": [
1615                            {"stage": "build", "state": "passed", "attempt": 1},
1616                            {"stage": "build", "state": "passed", "attempt": 2},
1617                            {"stage": "test", "state": "failed", "attempt": 1},
1618                            {"stage": "test", "state": "passed", "attempt": 2},
1619                        ],
1620                    }],
1621                }
1622            }),
1623        );
1624
1625        assert!(
1626            render(Some("show"), &response).contains("build:ok  build#2:ok  test:FAIL  test#2:ok"),
1627            "{}",
1628            render(Some("show"), &response)
1629        );
1630    }
1631
1632    #[test]
1633    fn a_running_stage_renders_an_open_ended_span() {
1634        assert_eq!(
1635            super::span(Some(today_at(0)), None),
1636            format!("{}-...", clock_at(0))
1637        );
1638        assert_eq!(super::span(None, None), "-");
1639    }
1640
1641    #[test]
1642    fn durations_use_the_coarsest_useful_unit() {
1643        assert_eq!(super::duration(9_400), "9s");
1644        assert_eq!(super::duration(191_000), "3m11s");
1645        assert_eq!(super::duration(3_900_000), "1h5m");
1646    }
1647
1648    #[test]
1649    fn pause_and_resume_render_the_scheduler_state() {
1650        for (verb, paused, expected) in [
1651            ("pause", true, "scheduler paused\n"),
1652            ("resume", false, "scheduler resumed\n"),
1653        ] {
1654            let response = ResponseEnvelope::success(None, json!({"paused": paused}));
1655            assert_eq!(render(Some(verb), &response), expected);
1656        }
1657    }
1658
1659    #[test]
1660    fn restart_renders_the_active_drain_count() {
1661        assert_eq!(
1662            render(
1663                Some("restart"),
1664                &ResponseEnvelope::success(None, json!({"active_runs": 1}))
1665            ),
1666            "daemon draining for restart (1 run active)\n"
1667        );
1668    }
1669
1670    #[test]
1671    fn reindex_summarizes_the_rebuilt_state() {
1672        let response = ResponseEnvelope::success(
1673            None,
1674            json!({
1675                "projects_indexed": 2,
1676                "tickets_indexed": 5,
1677                "tickets_state_changed": 1,
1678                "state_changes": [
1679                    {"ticket": "T1", "previous_state": "ready", "state": "merged"}
1680                ],
1681                "rows_dropped": 7,
1682            }),
1683        );
1684        let text = render(Some("reindex"), &response);
1685        assert_eq!(
1686            text,
1687            "reindexed 2 projects and 5 tickets; 1 ticket states changed; 7 rows dropped\n"
1688        );
1689    }
1690
1691    #[test]
1692    fn log_entries_render_timestamp_origin_and_text() {
1693        let response = ResponseEnvelope::success(
1694            None,
1695            json!({
1696                "run": "R1",
1697                "entries": [
1698                    {"timestamp": "2026-07-17T12:34:56Z", "source": "agent", "stage": null, "text": "hello"},
1699                    {"timestamp": "2026-07-17T12:34:57Z", "source": "stage", "stage": "test", "bytes_b64": "AAE="}
1700                ],
1701                "next_cursor": 2,
1702                "complete": true
1703            }),
1704        );
1705
1706        let text = render(Some("logs"), &response);
1707        assert_eq!(
1708            text,
1709            "[2026-07-17T12:34:56Z] [agent] hello\n\
1710             [2026-07-17T12:34:57Z] [stage:test] <binary output>\n"
1711        );
1712    }
1713}