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