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 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 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 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
459fn 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
503fn 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 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
543fn 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
572fn 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 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
601fn 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
613fn 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 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
656fn 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 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 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 fn today_at(offset_ms: i64) -> i64 {
1070 let today = crate::clock::local_time(super::now_ms()).expect("local time");
1071 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}