1use std::ffi::OsString;
2use std::fmt;
3use std::io::{BufRead, BufReader, Write};
4use std::os::unix::net::UnixStream;
5use std::path::PathBuf;
6use std::process::ExitCode;
7use std::str::FromStr;
8
9use clap::error::{ContextKind, ContextValue, ErrorKind};
10use clap::{
11 ArgGroup, Args, ColorChoice, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum,
12};
13use serde_json::json;
14
15mod init;
16mod render;
17mod style;
18mod templates;
19
20use self::style::Style;
21use self::templates::TemplateKind;
22use crate::protocol::{
23 ConfidenceValue, EmptyArgs, ErrorBody, ErrorCode, EventsArgs, ListArgs, LogsArgs, NoteArgs,
24 PostArgs, PostTrigger, Request, RequestEnvelope, RequestId, ResponseEnvelope, RunArgs,
25 RunReferenceArgs, RunTrigger, ShowArgs, StopArgs, TicketReferenceArgs, VerdictArgs,
26 VerdictValue,
27};
28
29const TICKET_STATES_HELP: &str = "Ticket states:
33 ready Nothing is stopping it - but it runs only once a trigger is
34 queued for it and the gates are open. `sloop run` queues one.
35 held Prevented from running by an operator; release with `sloop ready`.
36 blocked Waiting for every ticket in `blocked_by` to be merged.
37 claimed Owned by an active run, including recovery.
38 merged Terminal: completed work was integrated into the default branch.
39 failed Terminal: the run did not succeed; `sloop retry` returns it to
40 ready, and `sloop run` is what starts it again.
41 needs_review Terminal: the run could not be merged; inspect manually.";
42
43const SHOW_LONG_ABOUT: &str =
44 "Show the daemon, tickets, runs, and projects - sloop's one read verb.
45
46REF_OR_PATTERN is resolved in this order; first match wins:
47
48 1. nothing -> the dashboard: daemon state plus recent tickets
49 2. an exact ref -> the full detail view for that thing
50 3. anything else -> a filter: matching tickets, newest first
51
52A ref is an exact ticket id (TICK-12), run alias (TICK-12-r1), run-id prefix
53(749048f4), ticket name, or project id. An exact match always wins over pattern
54interpretation.
55
56A pattern matches ticket ids and names case-insensitively. Plain text is a
57substring; regex metacharacters make it an unanchored regex, like grep. Quote
58regexes in your shell: 'log.*'. A pattern always renders the list view, even
59when it matches one ticket.
60
61A run's detail is its stage table: one row per stage execution, suffixed `#N`
62past the first attempt, with advisory failures marked `advisory` and any
63panel's seats listed under their stage. `sloop logs <run> --stage <name>#<N>`
64reads the output behind one of those rows.
65
66--follow streams events for the shown scope. Ticket and run scopes exit when
67they settle; dashboard, pattern, and project scopes run until interrupted.
68--follow --quiet suppresses the stream and only returns the outcome.
69
70EXIT CODES:
71 0 shown successfully, or followed scope merged
72 1 followed scope reached any other terminal outcome, or daemon error
73 2 usage error, invalid pattern, or deprecated wait-alias timeout";
74
75const SHOW_EXAMPLES: &str = "Examples:
76 sloop show dashboard: daemon plus recent tickets
77 sloop show -5 dashboard with the 5 newest tickets
78 sloop show TICK-12 everything about one ticket, including runs
79 sloop show verdict tickets mentioning \"verdict\"
80 sloop show 'flow|merge' -5 5 newest tickets matching a regex
81 sloop show TICK-12 --follow watch a ticket until it settles
82 sloop show TICK-12 -f -q block silently; exit code is the outcome";
83
84#[derive(Debug, Parser)]
85#[command(
86 name = "sloop",
87 version,
88 about = "Schedule coding agents",
89 color = ColorChoice::Never
90)]
91pub struct Cli {
92 #[command(subcommand)]
93 pub command: Option<Command>,
94 #[arg(long, global = true)]
96 pub json: bool,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104enum OutputMode {
105 Json,
106 Human(Style),
107}
108
109#[derive(Debug, Subcommand)]
110pub enum Command {
111 Init,
113 Template {
119 #[arg(value_name = "KIND")]
121 kind: TemplateKind,
122 },
123 Daemon(DaemonCliArgs),
125 Post(PostCliArgs),
127 #[command(hide = true)]
129 Run(RunCliArgs),
130 #[command(hide = true)]
132 Retry { ticket: String },
133 #[command(hide = true)]
135 Hold { ticket: String },
136 #[command(hide = true)]
138 Ready { ticket: String },
139 #[command(hide = true)]
145 List(ListCliArgs),
146 #[command(hide = true)]
148 Status,
149 #[command(hide = true)]
151 Pause,
152 #[command(hide = true)]
154 Resume,
155 #[command(hide = true)]
157 Stop {
158 #[arg(long)]
160 force: bool,
161 },
162 #[command(hide = true)]
164 Cancel {
165 run: String,
167 },
168 #[command(
170 about = "Show daemon state, details, or filtered tickets; optionally follow live",
171 long_about = SHOW_LONG_ABOUT,
172 after_help = SHOW_EXAMPLES
173 )]
174 Show(ShowCliArgs),
175 #[command(
177 long_about = LOGS_LONG_ABOUT,
178 after_help = "See `sloop show --help` for derived state and live activity."
179 )]
180 Logs {
181 run: String,
183 #[arg(long, value_name = "STAGE[#ATTEMPT]")]
186 stage: Option<String>,
187 #[arg(long, value_name = "N")]
189 tail: Option<u32>,
190 #[arg(long)]
192 follow: bool,
193 },
194 #[command(hide = true)]
203 Watch {
204 r#ref: Option<String>,
206 #[arg(long, default_value_t = 20)]
208 tail: u32,
209 },
210 #[command(hide = true)]
212 Wait {
213 run: String,
215 #[arg(long, default_value_t = 3600)]
217 timeout: u64,
218 },
219 #[command(hide = true)]
221 Reindex,
222 #[command(hide = true)]
224 Brief,
225 #[command(hide = true)]
227 Note {
228 #[arg(required = true, trailing_var_arg = true, value_name = "TEXT")]
231 text: Vec<String>,
232 },
233 #[command(hide = true, long_about = VERDICT_LONG_ABOUT)]
235 Verdict {
236 verdict: VerdictCliValue,
238 #[arg(long, value_name = "TEXT")]
241 reason: Option<String>,
242 #[arg(long)]
245 confidence: Option<ConfidenceCliValue>,
246 },
247}
248
249const VERDICT_LONG_ABOUT: &str = "\
253Report the current stage's verdict.
254
255Two callers may use it, each exactly once per stage execution:
256
257 - the worker on a stage whose flow declares `result_check: reported`. It
258 is the only thing that can pass such a stage; one that exits without
259 reporting fails with `no verdict reported`.
260 - a panel reviewer, when the stage's check is `result_check: { panel: ... }`.
261 Its credential names the seat the report lands on, so no argument chooses
262 one, and `--reason` is required.
263
264The first report for an execution is final; a second is refused. A `return_to`
265edge that re-enters the stage starts a fresh execution, which is owed its own
266report.
267
268`--confidence` takes `low`, `medium`, or `high` and defaults to `medium`. It is
269recorded as evidence and shown by `sloop show`, and is never weighted into a
270panel's quorum: a `fail` at low confidence counts exactly as much as one at
271high.";
272
273const LOGS_LONG_ABOUT: &str = "\
276Show output from a run — stdout and stderr, in capture order.
277
278A bare read shows the last 64 entries, the way `tail` does: on a live run that
279is the part worth reading. `--tail N` widens or narrows that window, and
280`--follow` streams the run from its first entry instead. Whenever a window
281hides output, the last line of the page says so — a page that says nothing
282showed everything.
283
284`--stage` narrows to one stage, named exactly as its flow names it. Add
285`#<attempt>` to narrow further to a single execution: `--stage build#2` is the
286second pass a `return_to` edge sent the walk through, and `--stage build` is
287every pass together. The suffix is the same label `sloop show` prints in its
288stage table, so a row read there is a selector that can be pasted back.
289
290A stage the run's flow does not define is an error listing the ones it does,
291rather than an empty page.";
292
293#[derive(Debug, Clone, Copy, ValueEnum)]
294pub enum VerdictCliValue {
295 Pass,
296 Fail,
297}
298
299#[derive(Debug, Clone, Copy, ValueEnum)]
302pub enum ConfidenceCliValue {
303 Low,
304 Medium,
305 High,
306}
307
308#[derive(Debug, Args)]
309pub struct DaemonCliArgs {
310 #[command(subcommand)]
311 action: Option<DaemonAction>,
312 #[arg(long, hide = true)]
313 foreground: bool,
314}
315
316#[derive(Debug, Subcommand)]
317enum DaemonAction {
318 Restart,
320}
321
322#[derive(Debug, Args)]
323#[command(
324 after_help = "Ticket files need `name`, `blocked_by`, and a non-empty body. Run \
325`sloop template ticket` for a commented example of every frontmatter field, or \
326`sloop template flow` for the flow grammar that `--flow` selects.",
327 group(
328 ArgGroup::new("trigger")
329 .args(["auto", "at", "manual", "hold"])
330 .multiple(false)
331 )
332)]
333pub struct PostCliArgs {
334 file: PathBuf,
336 #[arg(long, value_name = "PROJECT")]
338 project: Option<String>,
339 #[arg(long, value_name = "FLOW")]
341 flow: Option<String>,
342 #[arg(long)]
344 auto: bool,
345 #[arg(long, value_name = "TIME")]
347 at: Option<LocalTime>,
348 #[arg(long)]
350 manual: bool,
351 #[arg(long)]
353 hold: bool,
354}
355
356#[derive(Debug, Args)]
357pub struct ListCliArgs {
358 #[arg(long, short = 'n', value_name = "N", value_parser = clap::value_parser!(u32).range(1..))]
360 limit: Option<u32>,
361}
362
363#[derive(Debug, Default, Args)]
364pub struct ShowCliArgs {
365 #[arg(value_name = "REF_OR_PATTERN")]
367 reference: Option<String>,
368 #[arg(long, short = 'f')]
370 follow: bool,
371 #[arg(long, short = 'q', requires = "follow")]
373 quiet: bool,
374 #[arg(long, short = 'n', value_name = "N", value_parser = clap::value_parser!(u32).range(1..))]
376 limit: Option<u32>,
377}
378
379#[derive(Debug, Args)]
380#[command(group(
381 ArgGroup::new("trigger")
382 .args(["at", "every", "overnight"])
383 .multiple(false)
384))]
385pub struct RunCliArgs {
386 ticket: Option<String>,
388 #[arg(long, value_name = "PROJECT", conflicts_with = "ticket")]
390 project: Option<String>,
391 #[arg(long, value_name = "TIME")]
393 at: Option<LocalTime>,
394 #[arg(long, value_name = "DURATION")]
396 every: Option<DurationMs>,
397 #[arg(long)]
399 overnight: bool,
400 #[arg(long, value_delimiter = ',', value_name = "TICKETS")]
402 only: Option<Vec<String>>,
403}
404
405impl Cli {
406 pub fn into_request(self) -> Result<Request, RequestConstructionError> {
407 self.command
408 .ok_or_else(|| {
409 RequestConstructionError(
410 "bare `sloop` prints help and has no daemon request".into(),
411 )
412 })?
413 .try_into()
414 }
415}
416
417fn logs_args(run: String, stage: Option<String>, tail: Option<u32>, follow: bool) -> LogsArgs {
424 LogsArgs {
425 run,
426 stage,
427 tail: tail.or((!follow).then_some(crate::run_log::PAGE_LIMIT as u32)),
428 after: None,
429 }
430}
431
432impl TryFrom<Command> for Request {
433 type Error = RequestConstructionError;
434
435 fn try_from(command: Command) -> Result<Self, Self::Error> {
436 let empty = EmptyArgs::default;
437 Ok(match command {
438 Command::Init => Self::Init(empty()),
439 Command::Template { .. } => {
440 return Err(RequestConstructionError(
441 "template is printed locally and has no daemon request".into(),
442 ));
443 }
444 Command::Daemon(args) => match args.action {
445 Some(DaemonAction::Restart) => Self::Restart(empty()),
446 None => Self::Daemon(empty()),
447 },
448 Command::Post(args) => Self::Post(args.try_into()?),
449 Command::Run(args) => Self::Run(args.into()),
450 Command::Retry { ticket } => Self::Retry(TicketReferenceArgs { ticket }),
451 Command::Hold { ticket } => Self::Hold(TicketReferenceArgs { ticket }),
452 Command::Ready { ticket } => Self::Ready(TicketReferenceArgs { ticket }),
453 Command::List(args) => Self::List(ListArgs { limit: args.limit }),
454 Command::Status => Self::Status(empty()),
455 Command::Pause => Self::Pause(empty()),
456 Command::Resume => Self::Resume(empty()),
457 Command::Stop { force } => Self::Stop(StopArgs { force }),
458 Command::Cancel { run } => Self::Cancel(RunReferenceArgs { run }),
459 Command::Logs {
460 run,
461 stage,
462 tail,
463 follow,
464 } => Self::Logs(logs_args(run, stage, tail, follow)),
465 Command::Watch { r#ref, tail } => Self::Events(EventsArgs {
466 after: None,
467 tail: Some(tail),
468 limit: None,
469 scope: r#ref,
470 }),
471 Command::Wait { run, .. } => Self::Wait(RunReferenceArgs { run }),
472 Command::Reindex => Self::Reindex(empty()),
473 Command::Brief => Self::Brief(empty()),
474 Command::Show(args) => Self::Show(ShowArgs {
475 reference: args.reference,
476 limit: args.limit,
477 }),
478 Command::Note { text } => Self::Note(NoteArgs {
479 text: text.join(" "),
480 }),
481 Command::Verdict {
482 verdict,
483 reason,
484 confidence,
485 } => Self::Verdict(VerdictArgs {
486 verdict: match verdict {
487 VerdictCliValue::Pass => VerdictValue::Pass,
488 VerdictCliValue::Fail => VerdictValue::Fail,
489 },
490 reason,
491 confidence: confidence.map(|confidence| match confidence {
492 ConfidenceCliValue::Low => ConfidenceValue::Low,
493 ConfidenceCliValue::Medium => ConfidenceValue::Medium,
494 ConfidenceCliValue::High => ConfidenceValue::High,
495 }),
496 }),
497 })
498 }
499}
500
501impl TryFrom<PostCliArgs> for PostArgs {
502 type Error = RequestConstructionError;
503
504 fn try_from(args: PostCliArgs) -> Result<Self, Self::Error> {
505 let file = args
506 .file
507 .into_os_string()
508 .into_string()
509 .map_err(|_| RequestConstructionError("ticket path must be valid UTF-8".into()))?;
510 let trigger = if let Some(time) = args.at {
511 PostTrigger::At { time: time.0 }
512 } else if args.manual {
513 PostTrigger::Manual
514 } else if args.hold {
515 PostTrigger::Hold
516 } else {
517 PostTrigger::Auto
518 };
519
520 Ok(Self {
521 file,
522 project: args.project,
523 flow: args.flow,
524 trigger,
525 })
526 }
527}
528
529impl From<RunCliArgs> for RunArgs {
530 fn from(args: RunCliArgs) -> Self {
531 let trigger = if let Some(time) = args.at {
532 RunTrigger::At { local_time: time.0 }
533 } else if let Some(interval) = args.every {
534 RunTrigger::Every {
535 interval_ms: interval.0,
536 }
537 } else if args.overnight {
538 RunTrigger::Overnight
539 } else {
540 RunTrigger::Now
541 };
542
543 Self {
544 ticket: args.ticket,
545 project: args.project,
546 trigger,
547 only: args.only.unwrap_or_default(),
548 }
549 }
550}
551
552#[derive(Debug, Clone, PartialEq, Eq)]
553pub struct RequestConstructionError(String);
554
555impl fmt::Display for RequestConstructionError {
556 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
557 formatter.write_str(&self.0)
558 }
559}
560
561impl std::error::Error for RequestConstructionError {}
562
563#[derive(Debug, Clone)]
564struct LocalTime(String);
565
566impl FromStr for LocalTime {
567 type Err = String;
568
569 fn from_str(value: &str) -> Result<Self, Self::Err> {
570 let (hour, minute) = value
571 .split_once(':')
572 .ok_or_else(|| "time must use HH:MM".to_owned())?;
573 if hour.len() != 2 || minute.len() != 2 {
574 return Err("time must use HH:MM".into());
575 }
576 let hour: u8 = hour.parse().map_err(|_| "hour must be numeric")?;
577 let minute: u8 = minute.parse().map_err(|_| "minute must be numeric")?;
578 if hour > 23 || minute > 59 {
579 return Err("time must be between 00:00 and 23:59".into());
580 }
581 Ok(Self(value.to_owned()))
582 }
583}
584
585#[derive(Debug, Clone, Copy)]
586struct DurationMs(u64);
587
588impl FromStr for DurationMs {
589 type Err = String;
590
591 fn from_str(value: &str) -> Result<Self, Self::Err> {
592 let digits = value.chars().take_while(char::is_ascii_digit).count();
593 let (amount, unit) = value.split_at(digits);
594 if amount.is_empty() || unit.is_empty() {
595 return Err("duration must include a positive number and unit (ms, s, m, or h)".into());
596 }
597 let amount: u64 = amount
598 .parse()
599 .map_err(|_| "duration amount is too large".to_owned())?;
600 if amount == 0 {
601 return Err("duration must be greater than zero".into());
602 }
603 let multiplier = match unit {
604 "ms" => 1,
605 "s" => 1_000,
606 "m" => 60_000,
607 "h" => 3_600_000,
608 _ => return Err("duration unit must be ms, s, m, or h".into()),
609 };
610 amount
611 .checked_mul(multiplier)
612 .map(Self)
613 .ok_or_else(|| "duration is too large".into())
614 }
615}
616
617pub fn run<I, T, O, E>(args: I, stdout: &mut O, stderr: &mut E) -> ExitCode
618where
619 I: IntoIterator<Item = T>,
620 T: Into<OsString> + Clone,
621 O: Write,
622 E: Write,
623{
624 let mut args: Vec<OsString> = args.into_iter().map(Into::into).collect();
625 let expanded_help = args.iter().any(|arg| arg == "--all")
626 && args
627 .iter()
628 .any(|arg| arg == "--help" || arg == "-h" || arg == "help");
629 if expanded_help {
630 args.retain(|arg| arg != "--all");
631 }
632 expand_limit_shorthand(&mut args);
633
634 let mut command = Cli::command();
635 if expanded_help {
636 let subcommands: Vec<String> = command
637 .get_subcommands()
638 .map(|subcommand| subcommand.get_name().to_owned())
639 .collect();
640 for subcommand in subcommands {
641 command = command.mut_subcommand(subcommand, |subcommand| subcommand.hide(false));
642 }
643 command = command.after_help(TICKET_STATES_HELP);
644 } else {
645 command = command.after_help(
646 "Read state with `sloop show` and raw output with `sloop logs`.\nRun `sloop show --help` for the complete read model or `sloop --help --all` for every command.",
647 );
648 }
649
650 match command
651 .clone()
652 .try_get_matches_from(&args)
653 .and_then(|matches| Cli::from_arg_matches(&matches))
654 {
655 Ok(cli) => {
656 let mode = if cli.json {
657 OutputMode::Json
658 } else {
659 OutputMode::Human(Style::detect())
660 };
661 match cli.command {
662 Some(subcommand) => run_command(subcommand, mode, stdout, stderr),
663 None => {
664 let help = command.render_help().to_string();
665 let help = help.trim_end();
666 write_plain_or(
667 mode,
668 stdout,
669 help,
670 &ResponseEnvelope::success(None, json!({"kind": "help", "text": help})),
671 )
672 }
673 }
674 }
675 Err(error) => {
676 let mode = if args.iter().any(|arg| arg == "--json") {
677 OutputMode::Json
678 } else {
679 OutputMode::Human(Style::detect())
680 };
681 match error.kind() {
682 ErrorKind::DisplayHelp => write_plain_or(
683 mode,
684 stdout,
685 error.to_string().trim_end(),
686 &ResponseEnvelope::success(
687 None,
688 json!({"kind": "help", "text": error.to_string().trim_end()}),
689 ),
690 ),
691 ErrorKind::DisplayVersion => write_plain_or(
692 mode,
693 stdout,
694 concat!("sloop ", env!("CARGO_PKG_VERSION")),
695 &ResponseEnvelope::success(
696 None,
697 json!({"kind": "version", "version": env!("CARGO_PKG_VERSION")}),
698 ),
699 ),
700 _ => write_cli_error(
701 mode,
702 stderr,
703 augment_unknown_subcommand(&error, error.to_string().trim_end().to_owned()),
704 ),
705 }
706 }
707 }
708}
709
710fn expand_limit_shorthand(args: &mut [OsString]) {
717 let verb = args
718 .iter()
719 .skip(1)
720 .position(|arg| arg != "--json")
721 .map(|offset| offset + 1);
722 let Some(verb) = verb.filter(|index| args[*index] == "show" || args[*index] == "list") else {
723 return;
724 };
725 for argument in &mut args[verb + 1..] {
726 let Some(digits) = argument.to_str().and_then(|arg| arg.strip_prefix('-')) else {
727 continue;
728 };
729 if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) {
730 *argument = OsString::from(format!("--limit={digits}"));
731 }
732 }
733}
734
735fn subcommand_synonym(attempted: &str) -> Option<&'static str> {
740 match attempted {
741 "tickets" | "ls" | "queue" => Some("list"),
742 "ps" => Some("status"),
743 "start" => Some("run"),
744 "kill" | "abort" => Some("cancel"),
745 _ => None,
746 }
747}
748
749fn augment_unknown_subcommand(error: &clap::Error, rendered: String) -> String {
755 if error.kind() != ErrorKind::InvalidSubcommand
756 || error.get(ContextKind::SuggestedSubcommand).is_some()
757 {
758 return rendered;
759 }
760 let Some(attempted) = invalid_subcommand(error) else {
761 return rendered;
762 };
763 let Some(verb) = subcommand_synonym(&attempted) else {
764 return rendered;
765 };
766 let tip = format!(
767 "\n\n tip: `{attempted}` is not a verb; did you mean `{verb}`? run `sloop {verb}`"
768 );
769 match rendered.find("\n\nUsage:") {
770 Some(index) => {
771 let mut augmented = rendered;
772 augmented.insert_str(index, &tip);
773 augmented
774 }
775 None => rendered + &tip,
776 }
777}
778
779fn invalid_subcommand(error: &clap::Error) -> Option<String> {
780 match error.get(ContextKind::InvalidSubcommand) {
781 Some(ContextValue::String(value)) => Some(value.clone()),
782 _ => None,
783 }
784}
785
786fn run_command(
787 command: Command,
788 mode: OutputMode,
789 stdout: &mut impl Write,
790 stderr: &mut impl Write,
791) -> ExitCode {
792 match command {
793 Command::Init => run_init(mode, stdout, stderr),
794 Command::Template { kind } => run_template(kind, mode, stdout),
795 Command::Daemon(args) if args.foreground && args.action.is_none() => {
796 match crate::daemon::serve_current_repository() {
797 Ok(()) | Err(crate::daemon::DaemonError::AlreadyRunning) => ExitCode::SUCCESS,
798 Err(_) => ExitCode::FAILURE,
799 }
800 }
801 Command::Daemon(args) => {
802 let (request, report_started) = match args.action {
803 Some(DaemonAction::Restart) => (Request::Restart(EmptyArgs::default()), false),
804 None => (Request::Daemon(EmptyArgs::default()), true),
805 };
806 run_daemon_request(request, report_started, mode, stdout, stderr)
807 }
808 Command::Stop { force } => run_stop_request(force, mode, stdout, stderr),
809 Command::Wait { run, timeout } => {
810 if !write_deprecation(stderr, "wait", "show --follow --quiet") {
811 return ExitCode::FAILURE;
812 }
813 run_wait(run, timeout, mode, stdout, stderr)
814 }
815 Command::Watch { r#ref, tail } => {
816 if !write_deprecation(stderr, "watch", "show --follow") {
817 return ExitCode::FAILURE;
818 }
819 run_watch(r#ref, tail, mode, stdout, stderr)
820 }
821 Command::Logs {
822 run,
823 stage,
824 tail,
825 follow,
826 } => run_logs(
827 logs_args(run, stage, tail, follow),
828 follow,
829 mode,
830 stdout,
831 stderr,
832 ),
833 Command::List(args) => {
834 if !write_deprecation(stderr, "list", "show") {
835 return ExitCode::FAILURE;
836 }
837 run_daemon_request(
838 Request::List(ListArgs { limit: args.limit }),
839 false,
840 mode,
841 stdout,
842 stderr,
843 )
844 }
845 Command::Status => {
846 if !write_deprecation(stderr, "status", "show") {
847 return ExitCode::FAILURE;
848 }
849 run_show(ShowCliArgs::default(), mode, stdout, stderr)
850 }
851 Command::Show(args) => run_show(args, mode, stdout, stderr),
852 command @ (Command::Post(_)
853 | Command::Run(_)
854 | Command::Retry { .. }
855 | Command::Hold { .. }
856 | Command::Ready { .. }
857 | Command::Pause
858 | Command::Resume
859 | Command::Cancel { .. }
860 | Command::Reindex) => match Request::try_from(command) {
861 Ok(request) => run_daemon_request(request, false, mode, stdout, stderr),
862 Err(error) => write_cli_error(mode, stderr, error.to_string()),
863 },
864 command @ (Command::Brief | Command::Note { .. } | Command::Verdict { .. }) => {
865 match Request::try_from(command) {
866 Ok(request) => run_worker_request(request, mode, stdout, stderr),
867 Err(error) => write_cli_error(mode, stderr, error.to_string()),
868 }
869 }
870 }
871}
872
873fn write_plain_or(
876 mode: OutputMode,
877 output: &mut impl Write,
878 text: &str,
879 envelope: &ResponseEnvelope,
880) -> ExitCode {
881 match mode {
882 OutputMode::Json => write_response(mode, None, output, envelope, ExitCode::SUCCESS),
883 OutputMode::Human(_) => {
884 if writeln!(output, "{text}").is_err() {
885 return ExitCode::FAILURE;
886 }
887 ExitCode::SUCCESS
888 }
889 }
890}
891
892fn run_template(kind: TemplateKind, mode: OutputMode, stdout: &mut impl Write) -> ExitCode {
897 let text = kind.text();
898 match mode {
899 OutputMode::Json => write_response(
900 mode,
901 Some("template"),
902 stdout,
903 &ResponseEnvelope::success(None, json!({"kind": kind.as_str(), "template": text})),
904 ExitCode::SUCCESS,
905 ),
906 OutputMode::Human(_) => {
907 if stdout.write_all(text.as_bytes()).is_err() {
908 return ExitCode::FAILURE;
909 }
910 ExitCode::SUCCESS
911 }
912 }
913}
914
915fn run_init(mode: OutputMode, stdout: &mut impl Write, stderr: &mut impl Write) -> ExitCode {
916 let cwd = match std::env::current_dir() {
917 Ok(cwd) => cwd,
918 Err(error) => {
919 return write_response(
920 mode,
921 Some("init"),
922 stderr,
923 &ResponseEnvelope::failure(
924 None,
925 ErrorBody {
926 code: ErrorCode::Internal,
927 message: format!("cannot read current directory: {error}"),
928 details: json!({}),
929 },
930 ),
931 ExitCode::FAILURE,
932 );
933 }
934 };
935 match self::init::init(&cwd) {
936 Ok(outcome) => write_response(
937 mode,
938 Some("init"),
939 stdout,
940 &ResponseEnvelope::success(
941 None,
942 json!({
943 "repository_root": outcome.repository_root.to_string_lossy(),
944 "created": outcome.created,
945 "existing": outcome.existing,
946 }),
947 ),
948 ExitCode::SUCCESS,
949 ),
950 Err(error) => {
951 let code = match error {
952 self::init::InitError::Conflict { .. } => ErrorCode::Conflict,
953 self::init::InitError::Io { .. } => ErrorCode::Internal,
954 };
955 write_response(
956 mode,
957 Some("init"),
958 stderr,
959 &ResponseEnvelope::failure(
960 None,
961 ErrorBody {
962 code,
963 message: error.to_string(),
964 details: json!({}),
965 },
966 ),
967 ExitCode::FAILURE,
968 )
969 }
970 }
971}
972
973fn write_deprecation(stderr: &mut impl Write, old: &str, new: &str) -> bool {
974 writeln!(
975 stderr,
976 "note: 'sloop {old}' is now 'sloop {new}'; this alias will be removed in a future release"
977 )
978 .is_ok()
979}
980
981fn run_show(
982 args: ShowCliArgs,
983 mode: OutputMode,
984 stdout: &mut impl Write,
985 stderr: &mut impl Write,
986) -> ExitCode {
987 let request_args = ShowArgs {
988 reference: args.reference,
989 limit: args.limit,
990 };
991 let worker_environment =
992 std::env::var_os("SLOOP_SOCKET").is_some() || std::env::var_os("SLOOP_TOKEN").is_some();
993 if worker_environment {
994 if args.follow {
995 return write_cli_error(
996 mode,
997 stderr,
998 "workers cannot follow operator activity; worker `show` only reads its own ticket"
999 .into(),
1000 );
1001 }
1002 return run_worker_request(Request::Show(request_args), mode, stdout, stderr);
1003 }
1004 if args.follow {
1005 return follow_show(request_args, 20, None, args.quiet, mode, stdout, stderr);
1006 }
1007 match crate::daemon::request(Request::Show(request_args)) {
1008 Ok(result) if result.response.ok => write_response(
1009 mode,
1010 Some("show"),
1011 stdout,
1012 &result.response,
1013 ExitCode::SUCCESS,
1014 ),
1015 Ok(result) => {
1016 let exit = if result.response.error.as_ref().map(|error| error.code)
1017 == Some(ErrorCode::InvalidArguments)
1018 {
1019 ExitCode::from(2)
1020 } else {
1021 ExitCode::FAILURE
1022 };
1023 write_response(mode, Some("show"), stderr, &result.response, exit)
1024 }
1025 Err(error) => write_response(
1026 mode,
1027 Some("show"),
1028 stderr,
1029 &ResponseEnvelope::failure(None, error.error_body()),
1030 ExitCode::FAILURE,
1031 ),
1032 }
1033}
1034
1035fn run_wait(
1039 run: String,
1040 timeout_secs: u64,
1041 mode: OutputMode,
1042 stdout: &mut impl Write,
1043 stderr: &mut impl Write,
1044) -> ExitCode {
1045 match crate::daemon::request(Request::Wait(RunReferenceArgs { run: run.clone() })) {
1046 Ok(result) if result.response.ok => {}
1047 Ok(result) => {
1048 return write_response(
1049 mode,
1050 Some("wait"),
1051 stderr,
1052 &result.response,
1053 ExitCode::FAILURE,
1054 );
1055 }
1056 Err(error) => {
1057 return write_response(
1058 mode,
1059 Some("wait"),
1060 stderr,
1061 &ResponseEnvelope::failure(None, error.error_body()),
1062 ExitCode::FAILURE,
1063 );
1064 }
1065 }
1066 follow_show(
1067 ShowArgs {
1068 reference: Some(run),
1069 limit: None,
1070 },
1071 0,
1072 Some(timeout_secs),
1073 true,
1074 mode,
1075 stdout,
1076 stderr,
1077 )
1078}
1079
1080fn run_watch(
1091 scope: Option<String>,
1092 tail: u32,
1093 mode: OutputMode,
1094 stdout: &mut impl Write,
1095 stderr: &mut impl Write,
1096) -> ExitCode {
1097 follow_show(
1098 ShowArgs {
1099 reference: scope,
1100 limit: None,
1101 },
1102 tail,
1103 None,
1104 false,
1105 mode,
1106 stdout,
1107 stderr,
1108 )
1109}
1110
1111fn follow_show(
1112 show: ShowArgs,
1113 tail: u32,
1114 timeout_secs: Option<u64>,
1115 quiet: bool,
1116 mode: OutputMode,
1117 stdout: &mut impl Write,
1118 stderr: &mut impl Write,
1119) -> ExitCode {
1120 let initial = match crate::daemon::request(Request::Show(show.clone())) {
1121 Ok(result) if result.response.ok => result.response.data.unwrap_or_default(),
1122 Ok(result) => {
1123 let exit = if result.response.error.as_ref().map(|error| error.code)
1124 == Some(ErrorCode::InvalidArguments)
1125 {
1126 ExitCode::from(2)
1127 } else {
1128 ExitCode::FAILURE
1129 };
1130 return write_response(mode, Some("show"), stderr, &result.response, exit);
1131 }
1132 Err(error) => {
1133 return write_response(
1134 mode,
1135 Some("show"),
1136 stderr,
1137 &ResponseEnvelope::failure(None, error.error_body()),
1138 ExitCode::FAILURE,
1139 );
1140 }
1141 };
1142 let settling_kind = matches!(initial["kind"].as_str(), Some("ticket" | "run"));
1143 let deadline = timeout_secs
1144 .map(|seconds| std::time::Instant::now() + std::time::Duration::from_secs(seconds));
1145 let mut cursor: Option<i64> = None;
1146 let mut settled_outcome: Option<bool> = None;
1147 loop {
1148 let args = match cursor {
1149 Some(after) => EventsArgs {
1150 after: Some(after),
1151 tail: None,
1152 limit: None,
1153 scope: show.reference.clone(),
1154 },
1155 None => EventsArgs {
1156 after: None,
1157 tail: Some(tail),
1158 limit: None,
1159 scope: show.reference.clone(),
1160 },
1161 };
1162 match crate::daemon::request(Request::Events(args)) {
1163 Ok(result) if result.response.ok => {
1164 let data = result.response.data.unwrap_or_default();
1165 let events = data["events"].as_array().cloned().unwrap_or_default();
1166 for event in &events {
1167 if quiet {
1168 continue;
1169 }
1170 let written = match mode {
1171 OutputMode::Json => serde_json::to_writer(&mut *stdout, event)
1172 .map_err(|_| ())
1173 .and_then(|()| stdout.write_all(b"\n").map_err(|_| ())),
1174 OutputMode::Human(_) => {
1175 writeln!(stdout, "{}", format_event(event)).map_err(|_| ())
1176 }
1177 };
1178 if written.is_err() {
1179 return ExitCode::FAILURE;
1180 }
1181 }
1182 if !quiet && stdout.flush().is_err() {
1183 return ExitCode::FAILURE;
1184 }
1185 let next = data["next_cursor"].as_i64();
1186 let advanced = next.is_some() && next != cursor;
1187 if let Some(next) = next {
1188 cursor = Some(next);
1189 }
1190 let caught_up = next == data["latest"].as_i64();
1191 if advanced && !caught_up {
1192 continue;
1193 }
1194 if caught_up && let Some(merged) = settled_outcome {
1195 return if merged {
1196 ExitCode::SUCCESS
1197 } else {
1198 ExitCode::FAILURE
1199 };
1200 }
1201 if settling_kind && caught_up {
1202 match crate::daemon::request(Request::Show(show.clone())) {
1203 Ok(result) if result.response.ok => {
1204 let shown = result.response.data.unwrap_or_default();
1205 let value = &shown["value"];
1206 let terminal = match shown["kind"].as_str() {
1207 Some("ticket") => matches!(
1208 value["state"].as_str(),
1209 Some("merged" | "failed" | "needs_review")
1210 ),
1211 Some("run") => value["terminal"] == json!(true),
1212 _ => false,
1213 };
1214 if terminal {
1215 settled_outcome = Some(value["state"] == "merged");
1216 continue;
1217 }
1218 }
1219 Ok(result) => {
1220 return write_response(
1221 mode,
1222 Some("show"),
1223 stderr,
1224 &result.response,
1225 ExitCode::FAILURE,
1226 );
1227 }
1228 Err(error) => {
1229 return write_response(
1230 mode,
1231 Some("show"),
1232 stderr,
1233 &ResponseEnvelope::failure(None, error.error_body()),
1234 ExitCode::FAILURE,
1235 );
1236 }
1237 }
1238 }
1239 }
1240 Ok(result) => {
1241 return write_response(
1242 mode,
1243 Some("show"),
1244 stderr,
1245 &result.response,
1246 ExitCode::FAILURE,
1247 );
1248 }
1249 Err(error) => {
1250 return write_response(
1251 mode,
1252 Some("show"),
1253 stderr,
1254 &ResponseEnvelope::failure(None, error.error_body()),
1255 ExitCode::FAILURE,
1256 );
1257 }
1258 }
1259 if deadline.is_some_and(|deadline| std::time::Instant::now() >= deadline) {
1260 let reference = show.reference.as_deref().unwrap_or("dashboard");
1261 return write_cli_error(mode, stderr, format!("timed out following `{reference}`"));
1262 }
1263 std::thread::sleep(std::time::Duration::from_millis(500));
1264 }
1265}
1266
1267fn run_logs(
1271 args: LogsArgs,
1272 follow: bool,
1273 mode: OutputMode,
1274 stdout: &mut impl Write,
1275 stderr: &mut impl Write,
1276) -> ExitCode {
1277 if !follow {
1278 return run_daemon_request(Request::Logs(args), false, mode, stdout, stderr);
1279 }
1280 follow_logs(args, mode, stdout, stderr)
1281}
1282
1283fn follow_logs(
1292 mut args: LogsArgs,
1293 mode: OutputMode,
1294 stdout: &mut impl Write,
1295 stderr: &mut impl Write,
1296) -> ExitCode {
1297 let mut written = false;
1298 loop {
1299 match crate::daemon::request(Request::Logs(args.clone())) {
1300 Ok(mut result) => {
1301 if !result.response.ok {
1302 return write_response(
1303 mode,
1304 Some("logs"),
1305 stderr,
1306 &result.response,
1307 ExitCode::FAILURE,
1308 );
1309 }
1310 if written && let Some(data) = result.response.data.as_mut() {
1311 data["note"] = serde_json::Value::Null;
1312 }
1313 let data = result.response.data.clone().unwrap_or_default();
1314 let complete = data["complete"] == json!(true);
1315 let settled = complete && data["terminal"] == json!(true);
1316 let entries = data["entries"].as_array().map_or(0, Vec::len);
1317 if entries > 0 || (settled && !written) {
1318 if write_envelope(mode, Some("logs"), stdout, &result.response).is_err()
1319 || stdout.flush().is_err()
1320 {
1321 return ExitCode::FAILURE;
1322 }
1323 written = true;
1324 }
1325 if let Some(next) = data["next_cursor"].as_u64() {
1326 args.after = Some(next);
1327 }
1328 args.tail = None;
1329 if settled {
1330 return ExitCode::SUCCESS;
1331 }
1332 if !complete {
1333 continue;
1334 }
1335 }
1336 Err(error) => {
1337 return write_response(
1338 mode,
1339 Some("logs"),
1340 stderr,
1341 &ResponseEnvelope::failure(None, error.error_body()),
1342 ExitCode::FAILURE,
1343 );
1344 }
1345 }
1346 std::thread::sleep(std::time::Duration::from_millis(200));
1347 }
1348}
1349
1350fn format_event(event: &serde_json::Value) -> String {
1352 let time = event["occurred_at_ms"]
1353 .as_i64()
1354 .and_then(crate::clock::format_timestamp)
1355 .unwrap_or_default();
1356 let run = event["run"].as_str().unwrap_or("?");
1357 let ticket = event["ticket"].as_str().unwrap_or("?");
1358 let data = &event["data"];
1359 match event["kind"].as_str().unwrap_or("?") {
1360 "run_claimed" => {
1361 let attempt = data["attempt"].as_i64().unwrap_or(1);
1362 format!("{time} {ticket} claimed by {run} (attempt {attempt})")
1363 }
1364 "run_started" => format!("{time} {run} started on {ticket}"),
1365 "run_finished" => {
1366 let outcome = data["outcome"].as_str().unwrap_or("?");
1367 let state = data["ticket_state"].as_str().unwrap_or("?");
1368 format!("{time} {run} finished: {outcome} ({ticket} -> {state})")
1369 }
1370 "run_aborted" => format!("{time} {run} aborted before launch ({ticket} back to ready)"),
1371 "run_worktree_cleaned" => format!("{time} {run} worktree and branch removed"),
1372 "daemon_restart_requested" => {
1373 let active = data["active_runs"].as_u64().unwrap_or(0);
1374 let noun = if active == 1 { "run" } else { "runs" };
1375 format!("{time} daemon draining for restart ({active} {noun} active)")
1376 }
1377 kind => format!("{time} {kind} run={run} ticket={ticket}"),
1378 }
1379}
1380
1381fn run_stop_request(
1384 force: bool,
1385 mode: OutputMode,
1386 stdout: &mut impl Write,
1387 stderr: &mut impl Write,
1388) -> ExitCode {
1389 match crate::daemon::request_running(Request::Stop(StopArgs { force })) {
1390 Ok(Some(response)) if response.ok => {
1391 write_response(mode, Some("stop"), stdout, &response, ExitCode::SUCCESS)
1392 }
1393 Ok(Some(response)) => {
1394 write_response(mode, Some("stop"), stderr, &response, ExitCode::FAILURE)
1395 }
1396 Ok(None) => write_response(
1397 mode,
1398 Some("stop"),
1399 stdout,
1400 &ResponseEnvelope::success(None, json!({"stopping": false, "running": false})),
1401 ExitCode::SUCCESS,
1402 ),
1403 Err(error) => write_response(
1404 mode,
1405 Some("stop"),
1406 stderr,
1407 &ResponseEnvelope::failure(None, error.error_body()),
1408 ExitCode::FAILURE,
1409 ),
1410 }
1411}
1412
1413fn run_daemon_request(
1414 request: Request,
1415 report_started: bool,
1416 mode: OutputMode,
1417 stdout: &mut impl Write,
1418 stderr: &mut impl Write,
1419) -> ExitCode {
1420 let verb = request.verb();
1421 match crate::daemon::request(request) {
1422 Ok(mut result) => {
1423 if report_started {
1424 let data = result
1425 .response
1426 .data
1427 .as_mut()
1428 .and_then(serde_json::Value::as_object_mut);
1429 if let Some(data) = data {
1430 data.insert("started".into(), result.started.into());
1431 }
1432 }
1433 if result.response.ok {
1434 write_response(
1435 mode,
1436 Some(verb),
1437 stdout,
1438 &result.response,
1439 ExitCode::SUCCESS,
1440 )
1441 } else {
1442 write_response(
1443 mode,
1444 Some(verb),
1445 stderr,
1446 &result.response,
1447 ExitCode::FAILURE,
1448 )
1449 }
1450 }
1451 Err(error) => write_response(
1452 mode,
1453 Some(verb),
1454 stderr,
1455 &ResponseEnvelope::failure(None, error.error_body()),
1456 ExitCode::FAILURE,
1457 ),
1458 }
1459}
1460
1461fn run_worker_request(
1467 request: Request,
1468 mode: OutputMode,
1469 stdout: &mut impl Write,
1470 stderr: &mut impl Write,
1471) -> ExitCode {
1472 let verb = request.verb();
1473 let socket = std::env::var_os("SLOOP_SOCKET");
1474 let token = std::env::var("SLOOP_TOKEN").ok();
1475 let (Some(socket), Some(token)) = (socket, token) else {
1476 return write_response(
1477 mode,
1478 Some(verb),
1479 stderr,
1480 &ResponseEnvelope::failure(
1481 None,
1482 ErrorBody {
1483 code: ErrorCode::Unauthorized,
1484 message: "worker verbs require SLOOP_SOCKET and SLOOP_TOKEN from a run".into(),
1485 details: json!({}),
1486 },
1487 ),
1488 ExitCode::FAILURE,
1489 );
1490 };
1491
1492 let envelope = RequestEnvelope::new(
1493 RequestId::new(format!("req-{}", std::process::id())),
1494 request,
1495 Some(token),
1496 );
1497 match worker_exchange(&socket, &envelope) {
1498 Ok(reply) => {
1499 let ok = serde_json::from_str::<ResponseEnvelope>(&reply)
1500 .map(|response| response.ok)
1501 .unwrap_or(false);
1502 let written = if ok {
1503 writeln!(stdout, "{}", reply.trim_end())
1504 } else {
1505 writeln!(stderr, "{}", reply.trim_end())
1506 };
1507 if written.is_err() {
1508 return ExitCode::FAILURE;
1509 }
1510 if ok {
1511 ExitCode::SUCCESS
1512 } else {
1513 ExitCode::FAILURE
1514 }
1515 }
1516 Err(message) => write_response(
1517 mode,
1518 Some(verb),
1519 stderr,
1520 &ResponseEnvelope::failure(
1521 None,
1522 ErrorBody {
1523 code: ErrorCode::DaemonUnavailable,
1524 message,
1525 details: json!({}),
1526 },
1527 ),
1528 ExitCode::FAILURE,
1529 ),
1530 }
1531}
1532
1533fn worker_exchange(socket: &std::ffi::OsStr, envelope: &RequestEnvelope) -> Result<String, String> {
1534 let mut stream = UnixStream::connect(socket)
1535 .map_err(|error| format!("cannot connect to worker socket: {error}"))?;
1536 let encoded = envelope
1537 .encode()
1538 .map_err(|error| format!("cannot encode request: {error}"))?;
1539 stream
1540 .write_all(encoded.as_bytes())
1541 .and_then(|()| stream.write_all(b"\n"))
1542 .map_err(|error| format!("cannot send request: {error}"))?;
1543
1544 let mut reply = String::new();
1545 BufReader::new(stream)
1546 .read_line(&mut reply)
1547 .map_err(|error| format!("cannot read response: {error}"))?;
1548 if reply.trim_end().is_empty() {
1549 return Err("the daemon closed the connection without replying".into());
1550 }
1551 Ok(reply)
1552}
1553
1554fn write_cli_error(mode: OutputMode, output: &mut impl Write, message: String) -> ExitCode {
1555 write_response(
1556 mode,
1557 None,
1558 output,
1559 &ResponseEnvelope::failure(
1560 None,
1561 ErrorBody {
1562 code: ErrorCode::InvalidArguments,
1563 message,
1564 details: json!({}),
1565 },
1566 ),
1567 ExitCode::from(2),
1568 )
1569}
1570
1571fn write_response(
1572 mode: OutputMode,
1573 verb: Option<&str>,
1574 output: &mut impl Write,
1575 response: &ResponseEnvelope,
1576 success: ExitCode,
1577) -> ExitCode {
1578 if write_envelope(mode, verb, output, response).is_err() {
1579 return ExitCode::FAILURE;
1580 }
1581 success
1582}
1583
1584fn write_envelope(
1588 mode: OutputMode,
1589 verb: Option<&str>,
1590 output: &mut impl Write,
1591 response: &ResponseEnvelope,
1592) -> Result<(), ()> {
1593 match mode {
1594 OutputMode::Json => serde_json::to_writer(&mut *output, response)
1595 .map_err(|_| ())
1596 .and_then(|()| output.write_all(b"\n").map_err(|_| ())),
1597 OutputMode::Human(style) => output
1598 .write_all(self::render::render(verb, response, style).as_bytes())
1599 .map_err(|_| ()),
1600 }
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605 use clap::{Parser, ValueEnum};
1606 use serde_json::{Value, json};
1607
1608 use super::{Cli, expand_limit_shorthand, subcommand_synonym};
1609 use crate::protocol::{Capability, Request};
1610
1611 fn error_envelope(args: &[&str]) -> Value {
1614 let mut stdout = Vec::new();
1615 let mut stderr = Vec::new();
1616 let mut argv = vec!["sloop", "--json"];
1617 argv.extend_from_slice(args);
1618 super::run(argv, &mut stdout, &mut stderr);
1619 serde_json::from_slice(&stderr).expect("stderr carries a JSON envelope")
1620 }
1621
1622 #[test]
1626 fn the_limit_shorthand_expands_only_for_read_arguments() {
1627 let expanded = |argv: &[&str]| {
1628 let mut args: Vec<std::ffi::OsString> =
1629 argv.iter().map(std::ffi::OsString::from).collect();
1630 expand_limit_shorthand(&mut args);
1631 args.iter()
1632 .map(|arg| arg.to_string_lossy().into_owned())
1633 .collect::<Vec<_>>()
1634 };
1635
1636 assert_eq!(
1637 expanded(&["sloop", "list", "-2"]),
1638 ["sloop", "list", "--limit=2"]
1639 );
1640 assert_eq!(
1641 expanded(&["sloop", "--json", "list", "-2"]),
1642 ["sloop", "--json", "list", "--limit=2"]
1643 );
1644 assert_eq!(expanded(&["sloop", "list", "-0"])[2], "--limit=0");
1645 assert_eq!(
1646 expanded(&["sloop", "show", "log", "-2"]),
1647 ["sloop", "show", "log", "--limit=2"]
1648 );
1649 for untouched in [
1650 ["sloop", "list", "-abc"].as_slice(),
1651 ["sloop", "list", "-n"].as_slice(),
1652 ["sloop", "post", "list", "-2"].as_slice(),
1653 ["sloop", "watch", "-2"].as_slice(),
1654 ] {
1655 assert_eq!(expanded(untouched), untouched, "{untouched:?}");
1656 }
1657 }
1658
1659 #[test]
1660 fn unknown_subcommand_suggests_the_tickets_synonym() {
1661 let envelope = error_envelope(&["tickets"]);
1662
1663 assert_eq!(envelope["error"]["code"], "invalid_arguments");
1664 let message = envelope["error"]["message"]
1665 .as_str()
1666 .expect("error message");
1667 assert!(
1668 message.contains("did you mean `list`") && message.contains("sloop list"),
1669 "synonym remedy missing from: {message}"
1670 );
1671 }
1672
1673 #[test]
1674 fn unknown_subcommand_suggests_a_near_miss_spelling() {
1675 let envelope = error_envelope(&["statuss"]);
1676
1677 let message = envelope["error"]["message"]
1678 .as_str()
1679 .expect("error message");
1680 assert!(
1681 message.contains("status"),
1682 "near-miss suggestion missing from: {message}"
1683 );
1684 }
1685
1686 #[test]
1687 fn synonym_table_only_points_at_real_verbs() {
1688 use clap::CommandFactory;
1689
1690 let verbs: Vec<String> = Cli::command()
1691 .get_subcommands()
1692 .map(|subcommand| subcommand.get_name().to_owned())
1693 .collect();
1694 for attempted in ["tickets", "ls", "queue", "ps", "start", "kill", "abort"] {
1695 let verb = subcommand_synonym(attempted).expect("synonym maps to a verb");
1696 assert!(
1697 verbs.iter().any(|known| known == verb),
1698 "synonym `{attempted}` points at unknown verb `{verb}`"
1699 );
1700 }
1701 assert!(subcommand_synonym("definitely-not-a-verb").is_none());
1702 }
1703
1704 #[test]
1705 fn parses_every_documented_verb() {
1706 let commands: &[&[&str]] = &[
1707 &["sloop", "init"],
1708 &["sloop", "template", "ticket"],
1709 &["sloop", "template", "flow"],
1710 &["sloop", "template", "project"],
1711 &["sloop", "template", "config"],
1712 &["sloop", "daemon"],
1713 &["sloop", "post", "ticket.md", "--auto"],
1714 &["sloop", "post", "ticket.md", "--at", "03:00"],
1715 &["sloop", "post", "ticket.md", "--manual"],
1716 &["sloop", "post", "ticket.md", "--hold"],
1717 &["sloop", "run"],
1718 &["sloop", "run", "T1", "--at", "03:00"],
1719 &["sloop", "run", "--every", "30m", "--only", "T1,T7"],
1720 &["sloop", "run", "--overnight"],
1721 &["sloop", "retry", "T1"],
1722 &["sloop", "hold", "T1"],
1723 &["sloop", "ready", "T1"],
1724 &["sloop", "list"],
1725 &["sloop", "status"],
1726 &["sloop", "pause"],
1727 &["sloop", "resume"],
1728 &["sloop", "cancel", "R1"],
1729 &["sloop", "logs", "R1"],
1730 &["sloop", "logs", "R1", "--stage", "test"],
1731 &["sloop", "logs", "R1", "--tail", "50"],
1732 &[
1733 "sloop", "logs", "R1", "--stage", "test", "--tail", "5", "--follow",
1734 ],
1735 &["sloop", "watch"],
1736 &["sloop", "watch", "--tail", "50"],
1737 &["sloop", "watch", "T1"],
1738 &["sloop", "watch", "T1-r2", "--tail", "50"],
1739 &["sloop", "reindex"],
1740 &["sloop", "brief"],
1741 &["sloop", "show", "T1"],
1742 &["sloop", "note", "work", "in", "progress"],
1743 &["sloop", "verdict", "fail", "--reason", "changes requested"],
1744 ];
1745
1746 for command in commands {
1747 Cli::try_parse_from(*command).unwrap_or_else(|error| {
1748 panic!("failed to parse {command:?}: {error}");
1749 });
1750 }
1751 }
1752
1753 #[test]
1754 fn every_documented_verb_constructs_a_typed_request() {
1755 let commands: &[&[&str]] = &[
1756 &["sloop", "init"],
1757 &["sloop", "daemon"],
1758 &["sloop", "post", "ticket.md", "--auto"],
1759 &["sloop", "run"],
1760 &["sloop", "retry", "T1"],
1761 &["sloop", "hold", "T1"],
1762 &["sloop", "ready", "T1"],
1763 &["sloop", "list"],
1764 &["sloop", "status"],
1765 &["sloop", "pause"],
1766 &["sloop", "resume"],
1767 &["sloop", "cancel", "R1"],
1768 &["sloop", "logs", "R1"],
1769 &["sloop", "logs", "R1", "--stage", "test", "--tail", "5"],
1770 &["sloop", "watch"],
1771 &["sloop", "watch", "T1"],
1772 &["sloop", "reindex"],
1773 &["sloop", "brief"],
1774 &["sloop", "show", "T1"],
1775 &["sloop", "note", "working"],
1776 &["sloop", "verdict", "pass"],
1777 ];
1778
1779 for command in commands {
1780 Cli::try_parse_from(*command)
1781 .unwrap()
1782 .into_request()
1783 .unwrap_or_else(|error| panic!("failed to construct {command:?}: {error}"));
1784 }
1785 }
1786
1787 #[test]
1788 fn run_options_become_protocol_arguments() {
1789 let request = Cli::try_parse_from(["sloop", "run", "--every", "30m", "--only", "T1,T7"])
1790 .unwrap()
1791 .into_request()
1792 .unwrap();
1793
1794 assert_eq!(
1795 serde_json::to_value(request).unwrap(),
1796 json!({
1797 "verb": "run",
1798 "args": {
1799 "trigger": {"kind": "every", "interval_ms": 1_800_000},
1800 "only": ["T1", "T7"]
1801 }
1802 })
1803 );
1804 }
1805
1806 #[test]
1807 fn hold_becomes_a_distinct_post_trigger() {
1808 let request = Cli::try_parse_from(["sloop", "post", "ticket.md", "--hold"])
1809 .unwrap()
1810 .into_request()
1811 .unwrap();
1812
1813 assert_eq!(
1814 serde_json::to_value(request).unwrap(),
1815 json!({
1816 "verb": "post",
1817 "args": {
1818 "file": "ticket.md",
1819 "trigger": {"kind": "hold"}
1820 }
1821 })
1822 );
1823 }
1824
1825 #[test]
1826 fn worker_request_text_and_capability_are_preserved() {
1827 let request = Cli::try_parse_from(["sloop", "note", "work", "in", "progress"])
1828 .unwrap()
1829 .into_request()
1830 .unwrap();
1831
1832 assert_eq!(request.capability(), Capability::Worker);
1833 assert_eq!(
1834 serde_json::to_value(request).unwrap(),
1835 json!({"verb": "note", "args": {"text": "work in progress"}})
1836 );
1837 }
1838
1839 #[test]
1840 fn verdict_becomes_a_worker_request() {
1841 let request =
1842 Cli::try_parse_from(["sloop", "verdict", "fail", "--reason", "changes requested"])
1843 .unwrap()
1844 .into_request()
1845 .unwrap();
1846
1847 assert_eq!(request.capability(), Capability::Worker);
1848 assert_eq!(
1849 serde_json::to_value(request).unwrap(),
1850 json!({
1851 "verb": "verdict",
1852 "args": {"verdict": "fail", "reason": "changes requested"}
1853 })
1854 );
1855 }
1856
1857 #[test]
1858 fn operator_requests_are_classified_separately() {
1859 let request = Cli::try_parse_from(["sloop", "status"])
1860 .unwrap()
1861 .into_request()
1862 .unwrap();
1863
1864 assert_eq!(request.capability(), Capability::Operator);
1865 assert!(matches!(request, Request::Status(_)));
1866 }
1867
1868 fn stdout_of(args: &[&str]) -> String {
1872 let mut stdout = Vec::new();
1873 let mut stderr = Vec::new();
1874 let mut argv = vec!["sloop"];
1875 argv.extend_from_slice(args);
1876 let code = super::run(argv, &mut stdout, &mut stderr);
1877
1878 assert_eq!(
1879 format!("{code:?}"),
1880 format!("{:?}", std::process::ExitCode::SUCCESS),
1881 "stderr: {}",
1882 String::from_utf8_lossy(&stderr)
1883 );
1884 String::from_utf8(stdout).expect("stdout is UTF-8")
1885 }
1886
1887 #[test]
1888 fn every_template_kind_prints_verbatim_without_a_daemon() {
1889 for kind in ["ticket", "flow", "project", "config"] {
1890 let printed = stdout_of(&["template", kind]);
1891 let expected = super::TemplateKind::from_str(kind, false)
1892 .expect("kind is accepted")
1893 .text();
1894 assert_eq!(printed, expected, "`sloop template {kind}` was rewritten");
1895 }
1896 }
1897
1898 #[test]
1899 fn template_json_mode_wraps_the_text_in_an_envelope() {
1900 let mut stdout = Vec::new();
1901 let mut stderr = Vec::new();
1902 super::run(
1903 ["sloop", "--json", "template", "flow"],
1904 &mut stdout,
1905 &mut stderr,
1906 );
1907
1908 let envelope: Value = serde_json::from_slice(&stdout).expect("stdout carries an envelope");
1909 assert_eq!(envelope["ok"], true);
1910 assert_eq!(envelope["data"]["kind"], "flow");
1911 assert_eq!(
1912 envelope["data"]["template"].as_str(),
1913 Some(super::TemplateKind::Flow.text())
1914 );
1915 }
1916
1917 #[test]
1918 fn an_unknown_template_kind_lists_the_valid_kinds() {
1919 let envelope = error_envelope(&["template", "readme"]);
1920
1921 assert_eq!(envelope["error"]["code"], "invalid_arguments");
1922 let message = envelope["error"]["message"]
1923 .as_str()
1924 .expect("error message");
1925 for kind in ["ticket", "flow", "project", "config"] {
1926 assert!(message.contains(kind), "`{kind}` missing from: {message}");
1927 }
1928 }
1929
1930 #[test]
1934 fn template_never_becomes_a_daemon_request() {
1935 let error = Cli::try_parse_from(["sloop", "template", "ticket"])
1936 .unwrap()
1937 .into_request()
1938 .expect_err("template has no daemon request");
1939
1940 assert!(error.to_string().contains("printed locally"), "{error}");
1941 }
1942
1943 #[test]
1944 fn post_help_points_at_the_ticket_template() {
1945 let help = stdout_of(&["post", "--help"]);
1946
1947 assert!(help.contains("sloop template ticket"), "{help}");
1948 assert!(help.contains("sloop template flow"), "{help}");
1949 }
1950
1951 #[test]
1952 fn invalid_time_and_duration_fail_during_cli_parsing() {
1953 assert!(Cli::try_parse_from(["sloop", "run", "--at", "25:00"]).is_err());
1954 assert!(Cli::try_parse_from(["sloop", "run", "--every", "later"]).is_err());
1955 assert!(Cli::try_parse_from(["sloop", "run", "--every", "0m"]).is_err());
1956 }
1957
1958 #[test]
1959 fn run_accepts_only_one_trigger_mode() {
1960 let result = Cli::try_parse_from(["sloop", "run", "--at", "03:00", "--every", "30m"]);
1961 assert!(result.is_err());
1962 }
1963
1964 #[test]
1965 fn post_defaults_to_auto_and_accepts_only_one_explicit_trigger_mode() {
1966 let request = Cli::try_parse_from(["sloop", "post", "ticket.md"])
1967 .unwrap()
1968 .into_request()
1969 .unwrap();
1970 assert_eq!(
1971 serde_json::to_value(request).unwrap(),
1972 json!({
1973 "verb": "post",
1974 "args": {
1975 "file": "ticket.md",
1976 "trigger": {"kind": "auto"}
1977 }
1978 })
1979 );
1980 assert!(Cli::try_parse_from(["sloop", "post", "ticket.md", "--auto", "--manual"]).is_err());
1981 assert!(Cli::try_parse_from(["sloop", "post", "ticket.md", "--manual", "--hold"]).is_err());
1982 }
1983}