1use std::fmt::Write;
7
8use serde_json::Value;
9
10use crate::protocol::{ErrorBody, ResponseEnvelope};
11
12pub 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 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 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 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 if let Some(attempt) = entry["attempt"].as_u64().filter(|attempt| *attempt > 1) {
373 let _ = write!(origin, "#{attempt}");
374 }
375 }
376 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
387fn 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
500fn 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
544fn 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 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
584fn 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
607fn 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
619fn 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
633fn advisory(stage: &Value) -> bool {
636 stage["advisory"] == Value::Bool(true)
637}
638
639fn 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 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
668fn 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
690fn 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 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 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
738fn 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 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
773fn 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 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 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 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 fn today_at(offset_ms: i64) -> i64 {
1262 let today = crate::clock::local_time(super::now_ms()).expect("local time");
1263 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 #[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 #[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 #[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 #[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 #[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 #[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}