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 mut text = format!(
102        "ticket {} registered from {} (project {}, {})\n",
103        ticket["id"].as_str().unwrap_or("?"),
104        ticket["file"].as_str().unwrap_or("?"),
105        ticket["project"].as_str().unwrap_or("?"),
106        ticket["state"].as_str().unwrap_or("?"),
107    );
108    text.push_str(&render_activation(&data["activation"]));
109    text
110}
111
112fn render_run(data: &Value) -> String {
113    render_activation(&data["activation"])
114}
115
116fn render_activation(activation: &Value) -> String {
117    let Some(fields) = activation.as_object() else {
118        return String::new();
119    };
120    let mut text = format!(
121        "activation {} {} ({}",
122        fields.get("id").and_then(Value::as_str).unwrap_or("?"),
123        fields
124            .get("state")
125            .and_then(Value::as_str)
126            .unwrap_or("queued"),
127        fields.get("kind").and_then(Value::as_str).unwrap_or("?"),
128    );
129    for key in ["ticket", "project", "time"] {
130        if let Some(value) = fields.get(key).and_then(Value::as_str) {
131            let _ = write!(text, ", {key} {value}");
132        }
133    }
134    text.push_str(")\n");
135    text
136}
137
138fn render_status(data: &Value) -> String {
139    let daemon = &data["daemon"];
140    let state = if daemon["draining"] == Value::Bool(true) {
141        let active = data["gate"]["active_agents"].as_u64().unwrap_or(0);
142        let noun = if active == 1 { "run" } else { "runs" };
143        format!(", draining for restart ({active} {noun} active)")
144    } else if daemon["paused"] == Value::Bool(true) {
145        ", paused".into()
146    } else {
147        String::new()
148    };
149    let mut text = format!("daemon: pid {}{state}\n", daemon["pid"]);
150    let _ = writeln!(
151        text,
152        "agents: {} active of {} max",
153        data["gate"]["active_agents"], data["gate"]["max_agents"]
154    );
155    if data["gate"]["storage"]["writable"] == Value::Bool(false) {
156        text.push_str("storage: database full (dispatch blocked until space is available)\n");
157    }
158    if let Some(hours) = data["gate"]["running_hours"].as_object() {
159        let state = if hours.get("open") == Some(&Value::Bool(true)) {
160            "open"
161        } else {
162            "closed"
163        };
164        let _ = writeln!(
165            text,
166            "running hours: {}-{} ({state})",
167            hours.get("start").and_then(Value::as_str).unwrap_or("?"),
168            hours.get("end").and_then(Value::as_str).unwrap_or("?"),
169        );
170    }
171    if let Some(next_wake) = data["next_wake"].as_str() {
172        let _ = writeln!(text, "next wake: {next_wake}");
173    }
174
175    let tickets = &data["tickets"];
176    let counts: Vec<String> = [
177        "ready",
178        "held",
179        "blocked",
180        "claimed",
181        "merged",
182        "failed",
183        "needs_review",
184    ]
185    .iter()
186    .map(|state| format!("{} {state}", tickets[*state]))
187    .collect();
188    let _ = writeln!(text, "tickets: {}", counts.join(", "));
189
190    // Run lines lead with the alias and the ticket's name, so the line answers
191    // "what is this working on" without a second command. Queued activations
192    // are not runs and keep their own shape.
193    let runs = data["runs"]
194        .as_array()
195        .map(Vec::as_slice)
196        .unwrap_or_default();
197    if runs.is_empty() {
198        text.push_str("runs: none\n");
199    } else {
200        text.push_str("runs:\n");
201        for run in runs {
202            let _ = writeln!(
203                text,
204                "  {} {} — {} (project {})",
205                run["alias"].as_str().unwrap_or("?"),
206                run["state"].as_str().unwrap_or("?"),
207                run["ticket_name"].as_str().unwrap_or("?"),
208                run["project"].as_str().unwrap_or("-"),
209            );
210        }
211    }
212
213    let queued = data["queued_activations"]
214        .as_array()
215        .map(Vec::as_slice)
216        .unwrap_or_default();
217    if queued.is_empty() {
218        text.push_str("queued: none\n");
219    } else {
220        text.push_str("queued:\n");
221        for item in queued {
222            let _ = writeln!(
223                text,
224                "  {} {} (ticket {}, project {})",
225                item["id"].as_str().unwrap_or("?"),
226                item["state"].as_str().unwrap_or("?"),
227                item["ticket"].as_str().unwrap_or("-"),
228                item["project"].as_str().unwrap_or("-"),
229            );
230        }
231    }
232    text
233}
234
235fn render_list(data: &Value) -> String {
236    let tickets = data["tickets"]
237        .as_array()
238        .map(Vec::as_slice)
239        .unwrap_or_default();
240    if tickets.is_empty() {
241        return "no tickets\n".into();
242    }
243
244    let id_width = tickets
245        .iter()
246        .filter_map(|ticket| ticket["id"].as_str())
247        .map(str::len)
248        .max()
249        .unwrap_or(1);
250    let state_width = tickets
251        .iter()
252        .filter_map(|ticket| ticket["state"].as_str())
253        .map(str::len)
254        .max()
255        .unwrap_or(1);
256    let mut text = String::new();
257    for ticket in tickets {
258        let id = ticket["id"].as_str().unwrap_or("?");
259        let state = ticket["state"].as_str().unwrap_or("?");
260        let project = ticket["project"].as_str().unwrap_or("?");
261        let name = ticket["name"].as_str().unwrap_or("?");
262        let _ = write!(
263            text,
264            "{id:id_width$}  {state:state_width$}  ({project})  {name}"
265        );
266        let terminal = matches!(state, "merged" | "needs_review");
267        if ticket["run"].is_null()
268            && !terminal
269            && let Some(reason) = ticket["reason"].as_str()
270        {
271            let _ = write!(text, "  — {reason}");
272        }
273        text.push('\n');
274    }
275    text
276}
277
278fn render_ticket_transition(data: &Value) -> String {
279    format!(
280        "ticket {}: {} -> {}\n",
281        data["ticket"].as_str().unwrap_or("?"),
282        data["previous_state"].as_str().unwrap_or("?"),
283        data["state"].as_str().unwrap_or("?"),
284    )
285}
286
287fn render_scheduler_transition(data: &Value) -> String {
288    if data["paused"] == Value::Bool(true) {
289        "scheduler paused\n".into()
290    } else {
291        "scheduler resumed\n".into()
292    }
293}
294
295fn render_stop(data: &Value) -> String {
296    if data["running"] == Value::Bool(false) {
297        return "daemon is not running\n".into();
298    }
299    let mut text = format!("daemon stopping (pid {})\n", data["pid"]);
300    for run in string_items(&data["cancelled_runs"]) {
301        let _ = writeln!(text, "  cancelled: {run}");
302    }
303    text
304}
305
306fn render_wait(data: &Value) -> String {
307    let mut rendered = String::new();
308    if let Some(note) = data["note"].as_str() {
309        let _ = writeln!(rendered, "{note}");
310    }
311    let _ = writeln!(
312        rendered,
313        "run {} {}",
314        data["alias"].as_str().unwrap_or("?"),
315        data["state"].as_str().unwrap_or("?"),
316    );
317    if let Some(reason) = data["reason"].as_str() {
318        let _ = writeln!(rendered, "reason: {reason}");
319    }
320    rendered
321}
322
323fn render_cancel(data: &Value) -> String {
324    let mut text = format!("run {} cancelling\n", data["alias"].as_str().unwrap_or("?"));
325    if let Some(worktree) = data["worktree"].as_str() {
326        let _ = writeln!(text, "worktree preserved at {worktree}");
327    }
328    text
329}
330
331fn render_logs(data: &Value) -> String {
332    let entries = data["entries"]
333        .as_array()
334        .map(Vec::as_slice)
335        .unwrap_or_default();
336    let mut text = String::new();
337    if let Some(note) = data["note"].as_str() {
338        let _ = writeln!(text, "{note}");
339    }
340    if entries.is_empty() {
341        let _ = writeln!(
342            text,
343            "no output captured for run {}",
344            data["alias"].as_str().unwrap_or("?")
345        );
346        return text;
347    }
348    for entry in entries {
349        let timestamp = entry["timestamp"].as_str().unwrap_or("?");
350        let mut origin = entry["source"].as_str().unwrap_or("?").to_owned();
351        if let Some(stage) = entry["stage"].as_str() {
352            let _ = write!(origin, ":{stage}");
353        }
354        // Bytes that failed UTF-8 decoding are stored as base64; a human
355        // view labels them rather than printing garbage.
356        let line = entry["text"].as_str().unwrap_or("<binary output>");
357        let _ = writeln!(text, "[{timestamp}] [{origin}] {line}");
358    }
359    text
360}
361
362fn render_reindex(data: &Value) -> String {
363    format!(
364        "reindexed {} projects and {} tickets; {} ticket states changed; {} rows dropped\n",
365        data["projects_indexed"],
366        data["tickets_indexed"],
367        data["tickets_state_changed"],
368        data["rows_dropped"]
369    )
370}
371
372fn render_show(data: &Value) -> String {
373    match data["kind"].as_str() {
374        Some("dashboard") => render_dashboard(data),
375        Some("matches") => render_list(data),
376        Some("ticket") => render_ticket_show(data),
377        Some("run") => render_run_show(data),
378        Some("project") => render_project_show(data),
379        _ => fallback(data),
380    }
381}
382
383fn render_dashboard(data: &Value) -> String {
384    let daemon = &data["daemon"];
385    let gate = &data["gate"];
386    let state = if daemon["draining"] == Value::Bool(true) {
387        "draining"
388    } else if daemon["paused"] == Value::Bool(true) {
389        "paused"
390    } else {
391        "running"
392    };
393    let mut text = format!(
394        "daemon: pid {} {state} - {}/{} agents active",
395        daemon["pid"], gate["active_agents"], gate["max_agents"]
396    );
397    if let Some(next_wake) = data["next_wake"].as_str() {
398        let _ = write!(text, " - next wake {next_wake}");
399    }
400    text.push('\n');
401
402    let tickets = &data["tickets"];
403    let counts = [
404        "ready",
405        "held",
406        "blocked",
407        "claimed",
408        "merged",
409        "failed",
410        "needs_review",
411    ]
412    .iter()
413    .map(|state| format!("{} {state}", tickets[*state]))
414    .collect::<Vec<_>>();
415    let _ = writeln!(text, "tickets: {}", counts.join(", "));
416
417    let runs = data["runs"]
418        .as_array()
419        .map(Vec::as_slice)
420        .unwrap_or_default();
421    if !runs.is_empty() {
422        text.push_str("\nruns:\n");
423        let alias_width = column_width(runs, "alias");
424        let state_width = column_width(runs, "state");
425        for run in runs {
426            let _ = writeln!(
427                text,
428                "  {:alias_width$}  {:state_width$}  {}  {}  {}",
429                run["alias"].as_str().unwrap_or("?"),
430                run["state"].as_str().unwrap_or("?"),
431                span(
432                    run["started_at_ms"].as_i64(),
433                    run["finished_at_ms"].as_i64()
434                ),
435                stage_strip(&run["stages"]),
436                run["ticket_name"].as_str().unwrap_or("?"),
437            );
438        }
439    }
440
441    text.push_str("\nrecent:\n");
442    text.push_str(&render_list(
443        &serde_json::json!({"tickets": data["recent"]}),
444    ));
445    let shown = data["recent"].as_array().map_or(0, Vec::len);
446    let total = data["recent_total"].as_u64().unwrap_or(shown as u64);
447    let more = total.saturating_sub(shown as u64);
448    if total == 0 {
449        text.push_str("\n`sloop show <ref>` for detail\n");
450    } else {
451        let _ = writeln!(
452            text,
453            "\n{more} more - `sloop show -{total}` for all - `sloop show <ref>` for detail"
454        );
455    }
456    text
457}
458
459/// A scannable frontmatter summary followed by the ticket body. The worker's
460/// own `show` carries no `body`, so the same layout renders just the summary
461/// for it — no worker-specific branch and no behavior change.
462fn render_ticket_show(data: &Value) -> String {
463    let value = &data["value"];
464    let mut text = format!(
465        "{}  {}  ({})\n",
466        value["id"].as_str().unwrap_or("?"),
467        value["name"].as_str().unwrap_or("?"),
468        value["state"].as_str().unwrap_or("?"),
469    );
470    if let Some(project) = value["project"].as_str() {
471        let _ = writeln!(text, "project: {project}");
472    }
473    if let Some(worktree) = value["worktree"].as_str() {
474        let _ = writeln!(text, "worktree: {worktree}");
475    }
476    let blocked_by = string_items(&value["blocked_by"]).collect::<Vec<_>>();
477    if !blocked_by.is_empty() {
478        let _ = writeln!(text, "blocked_by: {}", blocked_by.join(", "));
479    }
480    for (label, key) in [
481        ("target", "target"),
482        ("model", "model"),
483        ("effort", "effort"),
484    ] {
485        if let Some(field) = value[key].as_str() {
486            let _ = writeln!(text, "{label}: {field}");
487        }
488    }
489    if let Some(reason) = value["reason"].as_str() {
490        let _ = writeln!(text, "reason: {reason}");
491    }
492    text.push_str(&ticket_runs(&value["runs"]));
493    if let Some(body) = value["body"]
494        .as_str()
495        .map(str::trim)
496        .filter(|body| !body.is_empty())
497    {
498        let _ = write!(text, "\n{body}\n");
499    }
500    text
501}
502
503/// The ticket's runs, newest first, one line each: alias, outcome, wall-clock
504/// span, and a strip of stage markers. A ticket that has never run prints
505/// `runs: none` rather than nothing, so "no runs yet" is distinguishable from
506/// an older `sloop` that did not report runs at all.
507fn ticket_runs(runs: &Value) -> String {
508    let Some(runs) = runs.as_array() else {
509        return String::new();
510    };
511    if runs.is_empty() {
512        return "runs: none\n".to_owned();
513    }
514    // The alias and state columns are padded to the widest entry so the spans
515    // and stage strips line up down the section and can be read as columns.
516    let alias_width = column_width(runs, "alias");
517    let state_width = column_width(runs, "state");
518    let mut text = String::from("runs:\n");
519    for run in runs {
520        let _ = writeln!(
521            text,
522            "  {:alias_width$}  {:state_width$}  {}  {}",
523            run["alias"].as_str().unwrap_or("?"),
524            run["state"].as_str().unwrap_or("?"),
525            span(
526                run["started_at_ms"].as_i64(),
527                run["finished_at_ms"].as_i64()
528            ),
529            stage_strip(&run["stages"]),
530        );
531    }
532    text
533}
534
535fn column_width(runs: &[Value], key: &str) -> usize {
536    runs.iter()
537        .filter_map(|run| run[key].as_str())
538        .map(str::len)
539        .max()
540        .unwrap_or(1)
541}
542
543/// The per-stage markers on a run's summary line. Deliberately ASCII: the rest
544/// of this renderer is, and a stage strip is exactly the output most likely to
545/// be piped through something that mangles glyphs.
546fn stage_strip(stages: &Value) -> String {
547    stages
548        .as_array()
549        .map(Vec::as_slice)
550        .unwrap_or_default()
551        .iter()
552        .map(|stage| {
553            format!(
554                "{}:{}",
555                stage["stage"].as_str().unwrap_or("?"),
556                stage_marker(stage["state"].as_str().unwrap_or("")),
557            )
558        })
559        .collect::<Vec<_>>()
560        .join("  ")
561}
562
563fn stage_marker(state: &str) -> &'static str {
564    match state {
565        "passed" => "ok",
566        "failed" => "FAIL",
567        "running" => "..",
568        _ => "-",
569    }
570}
571
572/// A run or stage's wall-clock span. An unfinished one is open-ended rather
573/// than closed at the current instant — a running stage has no end yet, and
574/// printing one would be an invention.
575fn span(start_ms: Option<i64>, end_ms: Option<i64>) -> String {
576    let Some(start) = start_ms.and_then(crate::clock::local_time) else {
577        return "-".to_owned();
578    };
579    let end = end_ms.and_then(crate::clock::local_time);
580    // `HH:MM` alone is ambiguous for anything that did not happen today or
581    // that ran across midnight, so those two cases widen to include the date.
582    let today = crate::clock::local_time(now_ms());
583    let dated = today.is_some_and(|today| !start.same_day(&today))
584        || end.is_some_and(|end| !start.same_day(&end));
585    let opening = if dated { start.dated() } else { start.clock() };
586    match end {
587        None => format!("{opening}-..."),
588        Some(end) if start.same_day(&end) => format!("{opening}-{}", end.clock()),
589        Some(end) => format!("{opening}-{}", end.dated()),
590    }
591}
592
593fn now_ms() -> i64 {
594    use std::time::{SystemTime, UNIX_EPOCH};
595    SystemTime::now()
596        .duration_since(UNIX_EPOCH)
597        .map(|elapsed| elapsed.as_millis() as i64)
598        .unwrap_or(0)
599}
600
601/// A duration in the coarsest unit that still says something useful. Stage
602/// durations range from milliseconds to hours, and `4m12s` is easier to
603/// compare at a glance than `252000ms`.
604fn duration(milliseconds: i64) -> String {
605    let seconds = milliseconds / 1_000;
606    match (seconds / 3_600, (seconds % 3_600) / 60, seconds % 60) {
607        (0, 0, seconds) => format!("{seconds}s"),
608        (0, minutes, seconds) => format!("{minutes}m{seconds}s"),
609        (hours, minutes, _) => format!("{hours}h{minutes}m"),
610    }
611}
612
613/// The run's stage table: how far the flow got, and on what evidence. This is
614/// the view that answers "how did this run fail" without opening the database.
615fn run_stages(stages: &Value) -> String {
616    let Some(stages) = stages.as_array().filter(|stages| !stages.is_empty()) else {
617        return String::new();
618    };
619    let name_width = stages
620        .iter()
621        .filter_map(|stage| stage["stage"].as_str())
622        .map(str::len)
623        .max()
624        .unwrap_or(5)
625        .max(5);
626    let mut text = String::from("stages:\n");
627    for stage in stages {
628        let state = stage["state"].as_str().unwrap_or("?");
629        let mut line = format!(
630            "  {:name_width$}  {state:7}  {}",
631            stage["stage"].as_str().unwrap_or("?"),
632            span(
633                stage["started_at_ms"].as_i64(),
634                stage["finished_at_ms"].as_i64()
635            ),
636        );
637        if let Some(elapsed) = stage["duration_ms"].as_i64() {
638            let _ = write!(line, "  {}", duration(elapsed));
639        }
640        if let Some(exit_code) = stage["exit_code"].as_i64() {
641            let _ = write!(line, "  exit {exit_code}");
642        }
643        // Attempts are only worth a column when a repair actually retried the
644        // stage; every other stage ran exactly once and saying so is noise.
645        if let Some(attempts) = stage["attempts"].as_u64().filter(|attempts| *attempts > 1) {
646            let _ = write!(line, "  {attempts} attempts");
647        }
648        if let Some(source) = stage["verdict_source"].as_str() {
649            let _ = write!(line, "  verdict from {source}");
650        }
651        let _ = writeln!(text, "{}", line.trim_end());
652    }
653    text
654}
655
656/// A run's identity and settled evidence, one fact per line.
657fn render_run_show(data: &Value) -> String {
658    let value = &data["value"];
659    let mut text = String::new();
660    if let Some(note) = value["note"].as_str() {
661        let _ = writeln!(text, "{note}");
662    }
663    let _ = writeln!(
664        text,
665        "{}  ({})",
666        value["alias"].as_str().unwrap_or("?"),
667        value["state"].as_str().unwrap_or("?"),
668    );
669    let ticket = value["ticket"].as_str().unwrap_or("?");
670    match value["ticket_name"].as_str() {
671        Some(name) => {
672            let _ = writeln!(text, "ticket: {ticket}  {name}");
673        }
674        None => {
675            let _ = writeln!(text, "ticket: {ticket}");
676        }
677    }
678    if let Some(branch) = value["branch"].as_str() {
679        let _ = writeln!(text, "branch: {branch}");
680    }
681    if let Some(worktree) = value["worktree"].as_str() {
682        let _ = writeln!(text, "worktree: {worktree}");
683    }
684    // Claim, start, and finish bound the run; a run still in flight simply
685    // lacks the later fields rather than showing a guessed end.
686    let timeline = [
687        ("claimed", value["claimed_at_ms"].as_i64()),
688        ("started", value["started_at_ms"].as_i64()),
689        ("finished", value["finished_at_ms"].as_i64()),
690    ]
691    .into_iter()
692    .filter_map(|(label, at_ms)| {
693        let at = crate::clock::local_time(at_ms?)?;
694        Some(format!("{label} {}", at.clock()))
695    })
696    .collect::<Vec<_>>();
697    if !timeline.is_empty() {
698        let _ = writeln!(text, "timeline: {}", timeline.join("  "));
699    }
700    // `exit: 0` read as "the run passed" even when a later stage had failed,
701    // which is exactly how one smoke-test failure got misdiagnosed. The label
702    // now says whose exit it is.
703    if let Some(exit_code) = value["exit_code"].as_i64() {
704        let _ = writeln!(text, "agent exit: {exit_code}");
705    }
706    if let Some(reason) = value["reason"].as_str() {
707        let _ = writeln!(text, "reason: {reason}");
708    }
709    text.push_str(&run_stages(&value["stages"]));
710    text
711}
712
713fn render_project_show(data: &Value) -> String {
714    let project = &data["value"];
715    let mut text = format!(
716        "project {} ({})\n",
717        project["title"].as_str().unwrap_or("?"),
718        project["id"].as_str().unwrap_or("?"),
719    );
720    let tickets = project["tickets"]
721        .as_array()
722        .map(Vec::as_slice)
723        .unwrap_or_default();
724    if tickets.is_empty() {
725        text.push_str("no tickets\n");
726        return text;
727    }
728    for ticket in tickets {
729        let _ = writeln!(
730            text,
731            "\n{}  {}  ({})",
732            ticket["id"].as_str().unwrap_or("?"),
733            ticket["name"].as_str().unwrap_or("?"),
734            ticket["state"].as_str().unwrap_or("?"),
735        );
736        for note in ticket["notes"]
737            .as_array()
738            .map(Vec::as_slice)
739            .unwrap_or_default()
740        {
741            let _ = writeln!(
742                text,
743                "  note {} [{}]: {}",
744                note["id"].as_str().unwrap_or("?"),
745                note["run"].as_str().unwrap_or("?"),
746                note["text"].as_str().unwrap_or("?"),
747            );
748        }
749        for commit in ticket["commits"]
750            .as_array()
751            .map(Vec::as_slice)
752            .unwrap_or_default()
753        {
754            let _ = writeln!(
755                text,
756                "  commit {} [{}]: {}",
757                commit["hash"].as_str().unwrap_or("?"),
758                commit["run"].as_str().unwrap_or("?"),
759                commit["message"].as_str().unwrap_or("?"),
760            );
761        }
762    }
763    text
764}
765
766fn fallback(data: &Value) -> String {
767    let mut text = serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string());
768    text.push('\n');
769    text
770}
771
772fn string_items(value: &Value) -> impl Iterator<Item = &str> {
773    value
774        .as_array()
775        .map(Vec::as_slice)
776        .unwrap_or_default()
777        .iter()
778        .filter_map(Value::as_str)
779}
780
781#[cfg(test)]
782mod tests {
783    use serde_json::json;
784
785    use super::render;
786    use crate::protocol::{ErrorBody, ErrorCode, ResponseEnvelope};
787
788    #[test]
789    fn errors_render_the_code_and_message() {
790        let response = ResponseEnvelope::failure(
791            None,
792            ErrorBody {
793                code: ErrorCode::Conflict,
794                message: "run `R1` is `merged` and cannot be cancelled".into(),
795                details: json!({}),
796            },
797        );
798
799        let text = render(Some("cancel"), &response);
800        assert_eq!(
801            text,
802            "error (conflict): run `R1` is `merged` and cannot be cancelled\n"
803        );
804    }
805
806    #[test]
807    fn status_renders_counts_and_active_runs() {
808        let response = ResponseEnvelope::success(
809            None,
810            json!({
811                "daemon": {"pid": 42, "paused": false},
812                "gate": {
813                    "active_agents": 1,
814                    "max_agents": 2,
815                    "running_hours": {"start": "22:00", "end": "06:00", "open": false}
816                },
817                "next_wake": "2026-07-15T22:00:00Z",
818                "runs": [{
819                    "id": "3f2a9c1b7d4e5061a2b3c4d5e6f70819", "alias": "T1-r1",
820                    "state": "running", "ticket": "T1", "ticket_name": "Generalized stages",
821                    "project": "default"
822                }],
823                "queued_activations": [],
824                "tickets": {
825                    "ready": 1, "held": 2, "blocked": 0, "claimed": 1,
826                    "merged": 3, "failed": 0, "needs_review": 0
827                }
828            }),
829        );
830
831        let text = render(Some("status"), &response);
832        assert!(text.contains("daemon: pid 42\n"), "{text}");
833        assert!(text.contains("agents: 1 active of 2 max"), "{text}");
834        assert!(
835            text.contains("running hours: 22:00-06:00 (closed)"),
836            "{text}"
837        );
838        assert!(text.contains("next wake: 2026-07-15T22:00:00Z"), "{text}");
839        assert!(
840            text.contains(
841                "tickets: 1 ready, 2 held, 0 blocked, 1 claimed, 3 merged, 0 failed, 0 needs_review"
842            ),
843            "{text}"
844        );
845        assert!(
846            text.contains("T1-r1 running — Generalized stages (project default)"),
847            "{text}"
848        );
849        assert!(text.contains("queued: none"), "{text}");
850    }
851
852    #[test]
853    fn status_renders_a_full_database_as_a_dispatch_gate() {
854        let response = ResponseEnvelope::success(
855            None,
856            json!({
857                "daemon": {"pid": 42, "paused": false},
858                "gate": {
859                    "active_agents": 0,
860                    "max_agents": 1,
861                    "storage": {"writable": false, "reason": "database_full"}
862                },
863                "runs": [],
864                "queued_activations": [],
865                "tickets": {}
866            }),
867        );
868
869        let text = render(Some("status"), &response);
870        assert!(
871            text.contains("storage: database full (dispatch blocked"),
872            "{text}"
873        );
874    }
875
876    #[test]
877    fn list_renders_reasons_but_not_running_or_terminal_reasons() {
878        let response = ResponseEnvelope::success(
879            None,
880            json!({
881                "tickets": [
882                    {"id": "TICKET-1", "name": "Fix dispatch", "project": "default", "state": "ready", "run": null,
883                     "reason": "scheduler is paused; resume with `sloop resume`"},
884                    {"id": "T2", "name": "Add retries", "project": "default", "state": "claimed", "run": "R1",
885                     "reason": "claimed by run R1"},
886                    {"id": "T3", "name": "Polish the UI", "project": "web", "state": "merged", "run": null,
887                     "reason": null}
888                ]
889            }),
890        );
891
892        assert_eq!(
893            render(Some("list"), &response),
894            "TICKET-1  ready    (default)  Fix dispatch  — scheduler is paused; resume with `sloop resume`\n\
895             T2        claimed  (default)  Add retries\n\
896             T3        merged   (web)  Polish the UI\n"
897        );
898    }
899
900    #[test]
901    fn empty_list_says_there_are_no_tickets() {
902        let response = ResponseEnvelope::success(None, json!({"tickets": []}));
903        assert_eq!(render(Some("list"), &response), "no tickets\n");
904    }
905
906    #[test]
907    fn hold_and_ready_render_the_transition() {
908        let response = ResponseEnvelope::success(
909            None,
910            json!({
911                "ticket": "T1",
912                "previous_state": "held",
913                "state": "ready",
914                "overridden": true,
915            }),
916        );
917
918        for verb in ["hold", "ready"] {
919            assert_eq!(render(Some(verb), &response), "ticket T1: held -> ready\n");
920        }
921    }
922
923    #[test]
924    fn retry_renders_the_transition() {
925        let response = ResponseEnvelope::success(
926            None,
927            json!({
928                "ticket": "T1",
929                "previous_state": "failed",
930                "state": "ready",
931            }),
932        );
933
934        assert_eq!(
935            render(Some("retry"), &response),
936            "ticket T1: failed -> ready\n"
937        );
938    }
939
940    #[test]
941    fn unknown_shapes_fall_back_to_pretty_json() {
942        let response = ResponseEnvelope::success(None, json!({"surprise": true}));
943        let text = render(Some("mystery"), &response);
944        assert!(text.contains("\"surprise\": true"), "{text}");
945    }
946
947    #[test]
948    fn project_show_groups_activity_by_ticket() {
949        let response = ResponseEnvelope::success(
950            None,
951            json!({
952                "ref": "backend",
953                "kind": "project",
954                "value": {
955                    "id": "backend",
956                    "title": "Backend",
957                    "tickets": [{
958                        "id": "T1",
959                        "name": "Persist cooldowns",
960                        "state": "merged",
961                        "notes": [{"id": "N1", "run": "R1", "text": "halfway"}],
962                        "commits": [{"hash": "abc1234", "run": "R1", "message": "persist cooldowns"}]
963                    }]
964                }
965            }),
966        );
967
968        assert_eq!(
969            render(Some("show"), &response),
970            concat!(
971                "project Backend (backend)\n",
972                "\n",
973                "T1  Persist cooldowns  (merged)\n",
974                "  note N1 [R1]: halfway\n",
975                "  commit abc1234 [R1]: persist cooldowns\n",
976            )
977        );
978    }
979
980    #[test]
981    fn ticket_show_renders_a_summary_then_the_body() {
982        let response = ResponseEnvelope::success(
983            None,
984            json!({
985                "ref": "TICK-1",
986                "kind": "ticket",
987                "value": {
988                    "id": "TICK-1",
989                    "name": "cooldown",
990                    "state": "ready",
991                    "project": "default",
992                    "worktree": "sloop/TICK-1",
993                    "blocked_by": ["TICK-0"],
994                    "target": "claude",
995                    "model": "opus",
996                    "effort": "high",
997                    "body": "# Persist cooldowns\n\nSurvive restarts.",
998                }
999            }),
1000        );
1001
1002        assert_eq!(
1003            render(Some("show"), &response),
1004            concat!(
1005                "TICK-1  cooldown  (ready)\n",
1006                "project: default\n",
1007                "worktree: sloop/TICK-1\n",
1008                "blocked_by: TICK-0\n",
1009                "target: claude\n",
1010                "model: opus\n",
1011                "effort: high\n",
1012                "\n",
1013                "# Persist cooldowns\n\nSurvive restarts.\n",
1014            )
1015        );
1016    }
1017
1018    #[test]
1019    fn ticket_show_without_a_body_renders_only_the_summary() {
1020        let response = ResponseEnvelope::success(
1021            None,
1022            json!({
1023                "ref": "T1",
1024                "kind": "ticket",
1025                "value": {"id": "T1", "name": "work", "state": "ready", "blocked_by": []}
1026            }),
1027        );
1028
1029        assert_eq!(render(Some("show"), &response), "T1  work  (ready)\n");
1030    }
1031
1032    #[test]
1033    fn run_show_renders_the_run_evidence_summary() {
1034        let response = ResponseEnvelope::success(
1035            None,
1036            json!({
1037                "ref": "R14",
1038                "kind": "run",
1039                "value": {
1040                    "id": "R14",
1041                    "alias": "TICK-1-r2",
1042                    "ticket": "TICK-1",
1043                    "ticket_name": "cooldown",
1044                    "state": "merged",
1045                    "terminal": true,
1046                    "branch": "sloop/R14-TICK-1",
1047                    "worktree": "/repo/.worktrees/R14",
1048                    "exit_code": 0,
1049                    "reason": null,
1050                    "classification": null,
1051                }
1052            }),
1053        );
1054
1055        assert_eq!(
1056            render(Some("show"), &response),
1057            concat!(
1058                "TICK-1-r2  (merged)\n",
1059                "ticket: TICK-1  cooldown\n",
1060                "branch: sloop/R14-TICK-1\n",
1061                "worktree: /repo/.worktrees/R14\n",
1062                "agent exit: 0\n",
1063            )
1064        );
1065    }
1066
1067    /// An instant today, so the span renders as bare `HH:MM` and the assertion
1068    /// does not depend on which day the suite runs.
1069    fn today_at(offset_ms: i64) -> i64 {
1070        let today = crate::clock::local_time(super::now_ms()).expect("local time");
1071        // Noon plus the offset: far enough from either midnight that a few
1072        // minutes either way cannot spill into another day.
1073        super::now_ms() - i64::from(today.hour) * 3_600_000 - i64::from(today.minute) * 60_000
1074            + 12 * 3_600_000
1075            + offset_ms
1076    }
1077
1078    fn clock_at(offset_ms: i64) -> String {
1079        crate::clock::local_time(today_at(offset_ms))
1080            .expect("local time")
1081            .clock()
1082    }
1083
1084    #[test]
1085    fn ticket_show_lists_runs_newest_first_with_a_stage_strip() {
1086        let response = ResponseEnvelope::success(
1087            None,
1088            json!({
1089                "ref": "TICK-1",
1090                "kind": "ticket",
1091                "value": {
1092                    "id": "TICK-1", "name": "cooldown", "state": "merged", "blocked_by": [],
1093                    "runs": [
1094                        {
1095                            "alias": "TICK-1-r2", "state": "merged",
1096                            "started_at_ms": today_at(0),
1097                            "finished_at_ms": today_at(360_000),
1098                            "stages": [
1099                                {"stage": "build", "state": "passed"},
1100                                {"stage": "test", "state": "passed"},
1101                                {"stage": "merge", "state": "passed"},
1102                            ],
1103                        },
1104                        {
1105                            "alias": "TICK-1-r1", "state": "needs_review",
1106                            "started_at_ms": today_at(-3_600_000),
1107                            "finished_at_ms": today_at(-3_240_000),
1108                            "stages": [
1109                                {"stage": "build", "state": "passed"},
1110                                {"stage": "test", "state": "failed"},
1111                                {"stage": "merge", "state": "pending"},
1112                            ],
1113                        },
1114                    ],
1115                }
1116            }),
1117        );
1118
1119        assert_eq!(
1120            render(Some("show"), &response),
1121            format!(
1122                concat!(
1123                    "TICK-1  cooldown  (merged)\n",
1124                    "runs:\n",
1125                    "  TICK-1-r2  merged        {}-{}  build:ok  test:ok  merge:ok\n",
1126                    "  TICK-1-r1  needs_review  {}-{}  build:ok  test:FAIL  merge:-\n",
1127                ),
1128                clock_at(0),
1129                clock_at(360_000),
1130                clock_at(-3_600_000),
1131                clock_at(-3_240_000),
1132            )
1133        );
1134    }
1135
1136    #[test]
1137    fn ticket_show_says_so_when_a_ticket_has_never_run() {
1138        let response = ResponseEnvelope::success(
1139            None,
1140            json!({
1141                "ref": "T1",
1142                "kind": "ticket",
1143                "value": {"id": "T1", "name": "work", "state": "ready", "blocked_by": [],
1144                          "runs": []}
1145            }),
1146        );
1147
1148        assert_eq!(
1149            render(Some("show"), &response),
1150            "T1  work  (ready)\nruns: none\n"
1151        );
1152    }
1153
1154    #[test]
1155    fn run_show_renders_stages_and_the_derived_reason() {
1156        let response = ResponseEnvelope::success(
1157            None,
1158            json!({
1159                "ref": "TICK-1-r1",
1160                "kind": "run",
1161                "value": {
1162                    "id": "R14", "alias": "TICK-1-r1", "ticket": "TICK-1",
1163                    "state": "needs_review", "terminal": true, "exit_code": 0,
1164                    "claimed_at_ms": today_at(0),
1165                    "started_at_ms": today_at(1_000),
1166                    "finished_at_ms": today_at(252_000),
1167                    "reason": "stage `test` failed (exit 1) after agent completed with commits",
1168                    "stages": [
1169                        {
1170                            "stage": "build", "state": "passed", "attempts": 1,
1171                            "started_at_ms": today_at(1_000),
1172                            "finished_at_ms": today_at(61_000),
1173                            "duration_ms": 60_000, "exit_code": 0,
1174                            "verdict_source": "exit_code",
1175                        },
1176                        {
1177                            "stage": "test", "state": "failed", "attempts": 2,
1178                            "started_at_ms": today_at(61_000),
1179                            "finished_at_ms": today_at(252_000),
1180                            "duration_ms": 191_000, "exit_code": 1,
1181                            "verdict_source": "exit_code",
1182                        },
1183                        {"stage": "merge", "state": "pending", "attempts": 0},
1184                    ],
1185                }
1186            }),
1187        );
1188
1189        assert_eq!(
1190            render(Some("show"), &response),
1191            format!(
1192                concat!(
1193                    "TICK-1-r1  (needs_review)\n",
1194                    "ticket: TICK-1\n",
1195                    "timeline: claimed {}  started {}  finished {}\n",
1196                    "agent exit: 0\n",
1197                    "reason: stage `test` failed (exit 1) after agent completed with commits\n",
1198                    "stages:\n",
1199                    "  build  passed   {}-{}  1m0s  exit 0  verdict from exit_code\n",
1200                    "  test   failed   {}-{}  3m11s  exit 1  2 attempts  verdict from exit_code\n",
1201                    "  merge  pending  -\n",
1202                ),
1203                clock_at(0),
1204                clock_at(1_000),
1205                clock_at(252_000),
1206                clock_at(1_000),
1207                clock_at(61_000),
1208                clock_at(61_000),
1209                clock_at(252_000),
1210            )
1211        );
1212    }
1213
1214    #[test]
1215    fn a_running_stage_renders_an_open_ended_span() {
1216        assert_eq!(
1217            super::span(Some(today_at(0)), None),
1218            format!("{}-...", clock_at(0))
1219        );
1220        assert_eq!(super::span(None, None), "-");
1221    }
1222
1223    #[test]
1224    fn durations_use_the_coarsest_useful_unit() {
1225        assert_eq!(super::duration(9_400), "9s");
1226        assert_eq!(super::duration(191_000), "3m11s");
1227        assert_eq!(super::duration(3_900_000), "1h5m");
1228    }
1229
1230    #[test]
1231    fn pause_and_resume_render_the_scheduler_state() {
1232        for (verb, paused, expected) in [
1233            ("pause", true, "scheduler paused\n"),
1234            ("resume", false, "scheduler resumed\n"),
1235        ] {
1236            let response = ResponseEnvelope::success(None, json!({"paused": paused}));
1237            assert_eq!(render(Some(verb), &response), expected);
1238        }
1239    }
1240
1241    #[test]
1242    fn restart_renders_the_active_drain_count() {
1243        assert_eq!(
1244            render(
1245                Some("restart"),
1246                &ResponseEnvelope::success(None, json!({"active_runs": 1}))
1247            ),
1248            "daemon draining for restart (1 run active)\n"
1249        );
1250    }
1251
1252    #[test]
1253    fn reindex_summarizes_the_rebuilt_state() {
1254        let response = ResponseEnvelope::success(
1255            None,
1256            json!({
1257                "projects_indexed": 2,
1258                "tickets_indexed": 5,
1259                "tickets_state_changed": 1,
1260                "state_changes": [
1261                    {"ticket": "T1", "previous_state": "ready", "state": "merged"}
1262                ],
1263                "rows_dropped": 7,
1264            }),
1265        );
1266        let text = render(Some("reindex"), &response);
1267        assert_eq!(
1268            text,
1269            "reindexed 2 projects and 5 tickets; 1 ticket states changed; 7 rows dropped\n"
1270        );
1271    }
1272
1273    #[test]
1274    fn log_entries_render_timestamp_origin_and_text() {
1275        let response = ResponseEnvelope::success(
1276            None,
1277            json!({
1278                "run": "R1",
1279                "entries": [
1280                    {"timestamp": "2026-07-17T12:34:56Z", "source": "agent", "stage": null, "text": "hello"},
1281                    {"timestamp": "2026-07-17T12:34:57Z", "source": "aftercare", "stage": "test", "bytes_b64": "AAE="}
1282                ],
1283                "next_cursor": 2,
1284                "complete": true
1285            }),
1286        );
1287
1288        let text = render(Some("logs"), &response);
1289        assert_eq!(
1290            text,
1291            "[2026-07-17T12:34:56Z] [agent] hello\n\
1292             [2026-07-17T12:34:57Z] [aftercare:test] <binary output>\n"
1293        );
1294    }
1295}