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
15use crate::protocol::{
16 EmptyArgs, ErrorBody, ErrorCode, EventsArgs, NoteArgs, PostActivation, PostArgs, Request,
17 RequestEnvelope, RequestId, ResponseEnvelope, RunActivation, RunArgs, RunReferenceArgs,
18 ShowArgs, StopArgs, TicketReferenceArgs, VerdictArgs, VerdictValue,
19};
20
21const TICKET_STATES_HELP: &str = "Ticket states:
22 ready Eligible for dispatch once a run is queued and gates are open.
23 held Prevented from running by an operator; release with `sloop ready`.
24 blocked Waiting for every ticket in `blocked_by` to be merged.
25 claimed Owned by an active run, including aftercare or recovery.
26 merged Terminal: completed work was integrated into the default branch.
27 failed Terminal: the agent exited unsuccessfully; requeue with `sloop retry`.
28 needs_review Terminal: aftercare could not merge the run; inspect manually.";
29
30#[derive(Debug, Parser)]
31#[command(
32 name = "sloop",
33 version,
34 about = "Schedule coding agents",
35 color = ColorChoice::Never
36)]
37pub struct Cli {
38 #[command(subcommand)]
39 pub command: Command,
40 #[arg(long, global = true)]
42 pub json: bool,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48enum OutputMode {
49 Json,
50 Human,
51}
52
53#[derive(Debug, Subcommand)]
54pub enum Command {
55 Init,
57 Daemon(DaemonCliArgs),
59 Post(PostCliArgs),
61 #[command(hide = true)]
63 Run(RunCliArgs),
64 #[command(hide = true)]
66 Retry { ticket: String },
67 #[command(hide = true)]
69 Hold { ticket: String },
70 #[command(hide = true)]
72 Ready { ticket: String },
73 List,
75 Status,
77 #[command(hide = true)]
79 Pause,
80 #[command(hide = true)]
82 Resume,
83 #[command(hide = true)]
85 Stop {
86 #[arg(long)]
88 force: bool,
89 },
90 #[command(hide = true)]
92 Cancel {
93 run: String,
95 },
96 #[command(hide = true)]
98 Logs {
99 run: String,
101 },
102 Watch {
104 #[arg(long, default_value_t = 20)]
106 tail: u32,
107 },
108 #[command(hide = true)]
110 Wait {
111 run: String,
113 #[arg(long, default_value_t = 3600)]
115 timeout: u64,
116 },
117 #[command(hide = true)]
119 Reindex,
120 Brief,
122 Show { r#ref: String },
124 #[command(hide = true)]
126 Note {
127 #[arg(required = true, trailing_var_arg = true)]
128 text: Vec<String>,
129 },
130 #[command(hide = true)]
132 Verdict {
133 verdict: VerdictCliValue,
134 #[arg(long)]
135 reason: Option<String>,
136 },
137}
138
139#[derive(Debug, Clone, Copy, ValueEnum)]
140pub enum VerdictCliValue {
141 Pass,
142 Fail,
143}
144
145#[derive(Debug, Args)]
146pub struct DaemonCliArgs {
147 #[command(subcommand)]
148 action: Option<DaemonAction>,
149 #[arg(long, hide = true)]
150 foreground: bool,
151}
152
153#[derive(Debug, Subcommand)]
154enum DaemonAction {
155 Restart,
157}
158
159#[derive(Debug, Args)]
160#[command(group(
161 ArgGroup::new("activation")
162 .args(["auto", "at", "manual", "hold"])
163 .multiple(false)
164))]
165pub struct PostCliArgs {
166 file: PathBuf,
168 #[arg(long, value_name = "PROJECT")]
170 project: Option<String>,
171 #[arg(long, value_name = "FLOW")]
173 flow: Option<String>,
174 #[arg(long)]
176 auto: bool,
177 #[arg(long, value_name = "TIME")]
179 at: Option<LocalTime>,
180 #[arg(long)]
182 manual: bool,
183 #[arg(long)]
185 hold: bool,
186}
187
188#[derive(Debug, Args)]
189#[command(group(
190 ArgGroup::new("activation")
191 .args(["at", "every", "overnight"])
192 .multiple(false)
193))]
194pub struct RunCliArgs {
195 ticket: Option<String>,
197 #[arg(long, value_name = "PROJECT", conflicts_with = "ticket")]
199 project: Option<String>,
200 #[arg(long, value_name = "TIME")]
202 at: Option<LocalTime>,
203 #[arg(long, value_name = "DURATION")]
205 every: Option<DurationMs>,
206 #[arg(long)]
208 overnight: bool,
209 #[arg(long, value_delimiter = ',', value_name = "TICKETS")]
211 only: Option<Vec<String>>,
212}
213
214impl Cli {
215 pub fn into_request(self) -> Result<Request, RequestConstructionError> {
216 self.command.try_into()
217 }
218}
219
220impl TryFrom<Command> for Request {
221 type Error = RequestConstructionError;
222
223 fn try_from(command: Command) -> Result<Self, Self::Error> {
224 let empty = EmptyArgs::default;
225 Ok(match command {
226 Command::Init => Self::Init(empty()),
227 Command::Daemon(args) => match args.action {
228 Some(DaemonAction::Restart) => Self::Restart(empty()),
229 None => Self::Daemon(empty()),
230 },
231 Command::Post(args) => Self::Post(args.try_into()?),
232 Command::Run(args) => Self::Run(args.into()),
233 Command::Retry { ticket } => Self::Retry(TicketReferenceArgs { ticket }),
234 Command::Hold { ticket } => Self::Hold(TicketReferenceArgs { ticket }),
235 Command::Ready { ticket } => Self::Ready(TicketReferenceArgs { ticket }),
236 Command::List => Self::List(empty()),
237 Command::Status => Self::Status(empty()),
238 Command::Pause => Self::Pause(empty()),
239 Command::Resume => Self::Resume(empty()),
240 Command::Stop { force } => Self::Stop(StopArgs { force }),
241 Command::Cancel { run } => Self::Cancel(RunReferenceArgs { run }),
242 Command::Logs { run } => Self::Logs(RunReferenceArgs { run }),
243 Command::Watch { tail } => Self::Events(EventsArgs {
244 after: None,
245 tail: Some(tail),
246 limit: None,
247 }),
248 Command::Wait { run, .. } => Self::Wait(RunReferenceArgs { run }),
249 Command::Reindex => Self::Reindex(empty()),
250 Command::Brief => Self::Brief(empty()),
251 Command::Show { r#ref } => Self::Show(ShowArgs { reference: r#ref }),
252 Command::Note { text } => Self::Note(NoteArgs {
253 text: text.join(" "),
254 }),
255 Command::Verdict { verdict, reason } => Self::Verdict(VerdictArgs {
256 verdict: match verdict {
257 VerdictCliValue::Pass => VerdictValue::Pass,
258 VerdictCliValue::Fail => VerdictValue::Fail,
259 },
260 reason,
261 }),
262 })
263 }
264}
265
266impl TryFrom<PostCliArgs> for PostArgs {
267 type Error = RequestConstructionError;
268
269 fn try_from(args: PostCliArgs) -> Result<Self, Self::Error> {
270 let file = args
271 .file
272 .into_os_string()
273 .into_string()
274 .map_err(|_| RequestConstructionError("ticket path must be valid UTF-8".into()))?;
275 let activation = if let Some(time) = args.at {
276 PostActivation::At { time: time.0 }
277 } else if args.manual {
278 PostActivation::Manual
279 } else if args.hold {
280 PostActivation::Hold
281 } else {
282 PostActivation::Auto
283 };
284
285 Ok(Self {
286 file,
287 project: args.project,
288 flow: args.flow,
289 activation,
290 })
291 }
292}
293
294impl From<RunCliArgs> for RunArgs {
295 fn from(args: RunCliArgs) -> Self {
296 let activation = if let Some(time) = args.at {
297 RunActivation::At { local_time: time.0 }
298 } else if let Some(interval) = args.every {
299 RunActivation::Every {
300 interval_ms: interval.0,
301 }
302 } else if args.overnight {
303 RunActivation::Overnight
304 } else {
305 RunActivation::Now
306 };
307
308 Self {
309 ticket: args.ticket,
310 project: args.project,
311 activation,
312 only: args.only.unwrap_or_default(),
313 }
314 }
315}
316
317#[derive(Debug, Clone, PartialEq, Eq)]
318pub struct RequestConstructionError(String);
319
320impl fmt::Display for RequestConstructionError {
321 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
322 formatter.write_str(&self.0)
323 }
324}
325
326impl std::error::Error for RequestConstructionError {}
327
328#[derive(Debug, Clone)]
329struct LocalTime(String);
330
331impl FromStr for LocalTime {
332 type Err = String;
333
334 fn from_str(value: &str) -> Result<Self, Self::Err> {
335 let (hour, minute) = value
336 .split_once(':')
337 .ok_or_else(|| "time must use HH:MM".to_owned())?;
338 if hour.len() != 2 || minute.len() != 2 {
339 return Err("time must use HH:MM".into());
340 }
341 let hour: u8 = hour.parse().map_err(|_| "hour must be numeric")?;
342 let minute: u8 = minute.parse().map_err(|_| "minute must be numeric")?;
343 if hour > 23 || minute > 59 {
344 return Err("time must be between 00:00 and 23:59".into());
345 }
346 Ok(Self(value.to_owned()))
347 }
348}
349
350#[derive(Debug, Clone, Copy)]
351struct DurationMs(u64);
352
353impl FromStr for DurationMs {
354 type Err = String;
355
356 fn from_str(value: &str) -> Result<Self, Self::Err> {
357 let digits = value.chars().take_while(char::is_ascii_digit).count();
358 let (amount, unit) = value.split_at(digits);
359 if amount.is_empty() || unit.is_empty() {
360 return Err("duration must include a positive number and unit (ms, s, m, or h)".into());
361 }
362 let amount: u64 = amount
363 .parse()
364 .map_err(|_| "duration amount is too large".to_owned())?;
365 if amount == 0 {
366 return Err("duration must be greater than zero".into());
367 }
368 let multiplier = match unit {
369 "ms" => 1,
370 "s" => 1_000,
371 "m" => 60_000,
372 "h" => 3_600_000,
373 _ => return Err("duration unit must be ms, s, m, or h".into()),
374 };
375 amount
376 .checked_mul(multiplier)
377 .map(Self)
378 .ok_or_else(|| "duration is too large".into())
379 }
380}
381
382pub fn run<I, T, O, E>(args: I, stdout: &mut O, stderr: &mut E) -> ExitCode
383where
384 I: IntoIterator<Item = T>,
385 T: Into<OsString> + Clone,
386 O: Write,
387 E: Write,
388{
389 let mut args: Vec<OsString> = args.into_iter().map(Into::into).collect();
390 let expanded_help = args.iter().any(|arg| arg == "--all")
391 && args
392 .iter()
393 .any(|arg| arg == "--help" || arg == "-h" || arg == "help");
394 if expanded_help {
395 args.retain(|arg| arg != "--all");
396 }
397
398 let mut command = Cli::command();
399 if expanded_help {
400 let subcommands: Vec<String> = command
401 .get_subcommands()
402 .map(|subcommand| subcommand.get_name().to_owned())
403 .collect();
404 for subcommand in subcommands {
405 command = command.mut_subcommand(subcommand, |subcommand| subcommand.hide(false));
406 }
407 command = command.after_help(TICKET_STATES_HELP);
408 } else {
409 command = command.after_help("Run `sloop --help --all` to see every command.");
410 }
411
412 match command
413 .try_get_matches_from(&args)
414 .and_then(|matches| Cli::from_arg_matches(&matches))
415 {
416 Ok(cli) => {
417 let mode = if cli.json {
418 OutputMode::Json
419 } else {
420 OutputMode::Human
421 };
422 run_command(cli.command, mode, stdout, stderr)
423 }
424 Err(error) => {
427 let mode = if args.iter().any(|arg| arg == "--json") {
428 OutputMode::Json
429 } else {
430 OutputMode::Human
431 };
432 match error.kind() {
433 ErrorKind::DisplayHelp => write_plain_or(
434 mode,
435 stdout,
436 error.to_string().trim_end(),
437 &ResponseEnvelope::success(
438 None,
439 json!({"kind": "help", "text": error.to_string().trim_end()}),
440 ),
441 ),
442 ErrorKind::DisplayVersion => write_plain_or(
443 mode,
444 stdout,
445 concat!("sloop ", env!("CARGO_PKG_VERSION")),
446 &ResponseEnvelope::success(
447 None,
448 json!({"kind": "version", "version": env!("CARGO_PKG_VERSION")}),
449 ),
450 ),
451 _ => write_cli_error(
452 mode,
453 stderr,
454 augment_unknown_subcommand(&error, error.to_string().trim_end().to_owned()),
455 ),
456 }
457 }
458 }
459}
460
461fn subcommand_synonym(attempted: &str) -> Option<&'static str> {
466 match attempted {
467 "tickets" | "ls" | "queue" => Some("list"),
468 "ps" => Some("status"),
469 "start" => Some("run"),
470 "kill" | "abort" => Some("cancel"),
471 _ => None,
472 }
473}
474
475fn augment_unknown_subcommand(error: &clap::Error, rendered: String) -> String {
481 if error.kind() != ErrorKind::InvalidSubcommand
482 || error.get(ContextKind::SuggestedSubcommand).is_some()
483 {
484 return rendered;
485 }
486 let Some(attempted) = invalid_subcommand(error) else {
487 return rendered;
488 };
489 let Some(verb) = subcommand_synonym(&attempted) else {
490 return rendered;
491 };
492 let tip = format!(
493 "\n\n tip: `{attempted}` is not a verb; did you mean `{verb}`? run `sloop {verb}`"
494 );
495 match rendered.find("\n\nUsage:") {
496 Some(index) => {
497 let mut augmented = rendered;
498 augmented.insert_str(index, &tip);
499 augmented
500 }
501 None => rendered + &tip,
502 }
503}
504
505fn invalid_subcommand(error: &clap::Error) -> Option<String> {
506 match error.get(ContextKind::InvalidSubcommand) {
507 Some(ContextValue::String(value)) => Some(value.clone()),
508 _ => None,
509 }
510}
511
512fn run_command(
513 command: Command,
514 mode: OutputMode,
515 stdout: &mut impl Write,
516 stderr: &mut impl Write,
517) -> ExitCode {
518 match command {
519 Command::Init => run_init(mode, stdout, stderr),
520 Command::Daemon(args) if args.foreground && args.action.is_none() => {
521 match crate::daemon::serve_current_repository() {
522 Ok(()) | Err(crate::daemon::DaemonError::AlreadyRunning) => ExitCode::SUCCESS,
523 Err(_) => ExitCode::FAILURE,
524 }
525 }
526 Command::Daemon(args) => {
527 let (request, report_started) = match args.action {
528 Some(DaemonAction::Restart) => (Request::Restart(EmptyArgs::default()), false),
529 None => (Request::Daemon(EmptyArgs::default()), true),
530 };
531 run_daemon_request(request, report_started, mode, stdout, stderr)
532 }
533 Command::Stop { force } => run_stop_request(force, mode, stdout, stderr),
534 Command::Wait { run, timeout } => run_wait(run, timeout, mode, stdout, stderr),
535 Command::Watch { tail } => run_watch(tail, mode, stdout, stderr),
536 command @ (Command::Post(_)
537 | Command::Run(_)
538 | Command::Retry { .. }
539 | Command::Hold { .. }
540 | Command::Ready { .. }
541 | Command::List
542 | Command::Status
543 | Command::Pause
544 | Command::Resume
545 | Command::Cancel { .. }
546 | Command::Logs { .. }
547 | Command::Reindex) => match Request::try_from(command) {
548 Ok(request) => run_daemon_request(request, false, mode, stdout, stderr),
549 Err(error) => write_cli_error(mode, stderr, error.to_string()),
550 },
551 command @ Command::Show { .. } => match Request::try_from(command) {
552 Ok(request)
553 if std::env::var_os("SLOOP_SOCKET").is_some()
554 || std::env::var_os("SLOOP_TOKEN").is_some() =>
555 {
556 run_worker_request(request, mode, stdout, stderr)
557 }
558 Ok(request) => run_daemon_request(request, false, mode, stdout, stderr),
559 Err(error) => write_cli_error(mode, stderr, error.to_string()),
560 },
561 command @ (Command::Brief | Command::Note { .. } | Command::Verdict { .. }) => {
562 match Request::try_from(command) {
563 Ok(request) => run_worker_request(request, mode, stdout, stderr),
564 Err(error) => write_cli_error(mode, stderr, error.to_string()),
565 }
566 }
567 }
568}
569
570fn write_plain_or(
573 mode: OutputMode,
574 output: &mut impl Write,
575 text: &str,
576 envelope: &ResponseEnvelope,
577) -> ExitCode {
578 match mode {
579 OutputMode::Json => write_response(mode, None, output, envelope, ExitCode::SUCCESS),
580 OutputMode::Human => {
581 if writeln!(output, "{text}").is_err() {
582 return ExitCode::FAILURE;
583 }
584 ExitCode::SUCCESS
585 }
586 }
587}
588
589fn run_init(mode: OutputMode, stdout: &mut impl Write, stderr: &mut impl Write) -> ExitCode {
590 let cwd = match std::env::current_dir() {
591 Ok(cwd) => cwd,
592 Err(error) => {
593 return write_response(
594 mode,
595 Some("init"),
596 stderr,
597 &ResponseEnvelope::failure(
598 None,
599 ErrorBody {
600 code: ErrorCode::Internal,
601 message: format!("cannot read current directory: {error}"),
602 details: json!({}),
603 },
604 ),
605 ExitCode::FAILURE,
606 );
607 }
608 };
609 match crate::init::init(&cwd) {
610 Ok(outcome) => write_response(
611 mode,
612 Some("init"),
613 stdout,
614 &ResponseEnvelope::success(
615 None,
616 json!({
617 "repository_root": outcome.repository_root.to_string_lossy(),
618 "created": outcome.created,
619 "existing": outcome.existing,
620 }),
621 ),
622 ExitCode::SUCCESS,
623 ),
624 Err(error) => {
625 let code = match error {
626 crate::init::InitError::Conflict { .. } => ErrorCode::Conflict,
627 crate::init::InitError::Io { .. } => ErrorCode::Internal,
628 };
629 write_response(
630 mode,
631 Some("init"),
632 stderr,
633 &ResponseEnvelope::failure(
634 None,
635 ErrorBody {
636 code,
637 message: error.to_string(),
638 details: json!({}),
639 },
640 ),
641 ExitCode::FAILURE,
642 )
643 }
644 }
645}
646
647fn run_wait(
651 run: String,
652 timeout_secs: u64,
653 mode: OutputMode,
654 stdout: &mut impl Write,
655 stderr: &mut impl Write,
656) -> ExitCode {
657 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
658 loop {
659 let result = crate::daemon::request(Request::Wait(RunReferenceArgs { run: run.clone() }));
660 match result {
661 Ok(result) if result.response.ok => {
662 let data = result.response.data.clone().unwrap_or_default();
663 if data["terminal"] == serde_json::Value::Bool(true) {
664 return if data["state"] == "merged" {
665 write_response(
666 mode,
667 Some("wait"),
668 stdout,
669 &result.response,
670 ExitCode::SUCCESS,
671 )
672 } else {
673 write_response(
674 mode,
675 Some("wait"),
676 stderr,
677 &result.response,
678 ExitCode::FAILURE,
679 )
680 };
681 }
682 }
683 Ok(result) => {
684 return write_response(
685 mode,
686 Some("wait"),
687 stderr,
688 &result.response,
689 ExitCode::FAILURE,
690 );
691 }
692 Err(error) => {
693 return write_response(
694 mode,
695 Some("wait"),
696 stderr,
697 &ResponseEnvelope::failure(None, error.error_body()),
698 ExitCode::FAILURE,
699 );
700 }
701 }
702 if std::time::Instant::now() >= deadline {
703 return write_cli_error(mode, stderr, format!("timed out waiting for run `{run}`"));
704 }
705 std::thread::sleep(std::time::Duration::from_millis(500));
706 }
707}
708
709fn run_watch(
715 tail: u32,
716 mode: OutputMode,
717 stdout: &mut impl Write,
718 stderr: &mut impl Write,
719) -> ExitCode {
720 let mut cursor: Option<i64> = None;
721 loop {
722 let args = match cursor {
723 Some(after) => EventsArgs {
724 after: Some(after),
725 tail: None,
726 limit: None,
727 },
728 None => EventsArgs {
729 after: None,
730 tail: Some(tail),
731 limit: None,
732 },
733 };
734 match crate::daemon::request(Request::Events(args)) {
735 Ok(result) if result.response.ok => {
736 let data = result.response.data.unwrap_or_default();
737 let events = data["events"].as_array().cloned().unwrap_or_default();
738 for event in &events {
739 let written = match mode {
740 OutputMode::Json => serde_json::to_writer(&mut *stdout, event)
741 .map_err(|_| ())
742 .and_then(|()| stdout.write_all(b"\n").map_err(|_| ())),
743 OutputMode::Human => {
744 writeln!(stdout, "{}", format_event(event)).map_err(|_| ())
745 }
746 };
747 if written.is_err() {
748 return ExitCode::FAILURE;
749 }
750 }
751 if stdout.flush().is_err() {
752 return ExitCode::FAILURE;
753 }
754 if let Some(next) = data["next_cursor"].as_i64() {
755 cursor = Some(next);
756 }
757 if !events.is_empty() && data["next_cursor"] != data["latest"] {
760 continue;
761 }
762 }
763 Ok(result) => {
764 return write_response(
765 mode,
766 Some("events"),
767 stderr,
768 &result.response,
769 ExitCode::FAILURE,
770 );
771 }
772 Err(error) => {
773 return write_response(
774 mode,
775 Some("events"),
776 stderr,
777 &ResponseEnvelope::failure(None, error.error_body()),
778 ExitCode::FAILURE,
779 );
780 }
781 }
782 std::thread::sleep(std::time::Duration::from_millis(500));
783 }
784}
785
786fn format_event(event: &serde_json::Value) -> String {
788 let time = event["occurred_at_ms"]
789 .as_i64()
790 .and_then(crate::clock::format_timestamp)
791 .unwrap_or_default();
792 let run = event["run"].as_str().unwrap_or("?");
793 let ticket = event["ticket"].as_str().unwrap_or("?");
794 let data = &event["data"];
795 match event["kind"].as_str().unwrap_or("?") {
796 "run_claimed" => {
797 let attempt = data["attempt"].as_i64().unwrap_or(1);
798 format!("{time} {ticket} claimed by {run} (attempt {attempt})")
799 }
800 "run_started" => format!("{time} {run} started on {ticket}"),
801 "run_finished" => {
802 let outcome = data["outcome"].as_str().unwrap_or("?");
803 let state = data["ticket_state"].as_str().unwrap_or("?");
804 format!("{time} {run} finished: {outcome} ({ticket} -> {state})")
805 }
806 "run_aborted" => format!("{time} {run} aborted before launch ({ticket} back to ready)"),
807 "run_worktree_cleaned" => format!("{time} {run} worktree and branch removed"),
808 "daemon_restart_requested" => {
809 let active = data["active_runs"].as_u64().unwrap_or(0);
810 let noun = if active == 1 { "run" } else { "runs" };
811 format!("{time} daemon draining for restart ({active} {noun} active)")
812 }
813 kind => format!("{time} {kind} run={run} ticket={ticket}"),
814 }
815}
816
817fn run_stop_request(
820 force: bool,
821 mode: OutputMode,
822 stdout: &mut impl Write,
823 stderr: &mut impl Write,
824) -> ExitCode {
825 match crate::daemon::request_running(Request::Stop(StopArgs { force })) {
826 Ok(Some(response)) if response.ok => {
827 write_response(mode, Some("stop"), stdout, &response, ExitCode::SUCCESS)
828 }
829 Ok(Some(response)) => {
830 write_response(mode, Some("stop"), stderr, &response, ExitCode::FAILURE)
831 }
832 Ok(None) => write_response(
833 mode,
834 Some("stop"),
835 stdout,
836 &ResponseEnvelope::success(None, json!({"stopping": false, "running": false})),
837 ExitCode::SUCCESS,
838 ),
839 Err(error) => write_response(
840 mode,
841 Some("stop"),
842 stderr,
843 &ResponseEnvelope::failure(None, error.error_body()),
844 ExitCode::FAILURE,
845 ),
846 }
847}
848
849fn run_daemon_request(
850 request: Request,
851 report_started: bool,
852 mode: OutputMode,
853 stdout: &mut impl Write,
854 stderr: &mut impl Write,
855) -> ExitCode {
856 let verb = request.verb();
857 match crate::daemon::request(request) {
858 Ok(mut result) => {
859 if report_started {
860 let data = result
861 .response
862 .data
863 .as_mut()
864 .and_then(serde_json::Value::as_object_mut);
865 if let Some(data) = data {
866 data.insert("started".into(), result.started.into());
867 }
868 }
869 if result.response.ok {
870 write_response(
871 mode,
872 Some(verb),
873 stdout,
874 &result.response,
875 ExitCode::SUCCESS,
876 )
877 } else {
878 write_response(
879 mode,
880 Some(verb),
881 stderr,
882 &result.response,
883 ExitCode::FAILURE,
884 )
885 }
886 }
887 Err(error) => write_response(
888 mode,
889 Some(verb),
890 stderr,
891 &ResponseEnvelope::failure(None, error.error_body()),
892 ExitCode::FAILURE,
893 ),
894 }
895}
896
897fn run_worker_request(
903 request: Request,
904 mode: OutputMode,
905 stdout: &mut impl Write,
906 stderr: &mut impl Write,
907) -> ExitCode {
908 let verb = request.verb();
909 let socket = std::env::var_os("SLOOP_SOCKET");
910 let token = std::env::var("SLOOP_TOKEN").ok();
911 let (Some(socket), Some(token)) = (socket, token) else {
912 return write_response(
913 mode,
914 Some(verb),
915 stderr,
916 &ResponseEnvelope::failure(
917 None,
918 ErrorBody {
919 code: ErrorCode::Unauthorized,
920 message: "worker verbs require SLOOP_SOCKET and SLOOP_TOKEN from a run".into(),
921 details: json!({}),
922 },
923 ),
924 ExitCode::FAILURE,
925 );
926 };
927
928 let envelope = RequestEnvelope::new(
929 RequestId::new(format!("req-{}", std::process::id())),
930 request,
931 Some(token),
932 );
933 match worker_exchange(&socket, &envelope) {
934 Ok(reply) => {
935 let ok = serde_json::from_str::<ResponseEnvelope>(&reply)
936 .map(|response| response.ok)
937 .unwrap_or(false);
938 let written = if ok {
939 writeln!(stdout, "{}", reply.trim_end())
940 } else {
941 writeln!(stderr, "{}", reply.trim_end())
942 };
943 if written.is_err() {
944 return ExitCode::FAILURE;
945 }
946 if ok {
947 ExitCode::SUCCESS
948 } else {
949 ExitCode::FAILURE
950 }
951 }
952 Err(message) => write_response(
953 mode,
954 Some(verb),
955 stderr,
956 &ResponseEnvelope::failure(
957 None,
958 ErrorBody {
959 code: ErrorCode::DaemonUnavailable,
960 message,
961 details: json!({}),
962 },
963 ),
964 ExitCode::FAILURE,
965 ),
966 }
967}
968
969fn worker_exchange(socket: &std::ffi::OsStr, envelope: &RequestEnvelope) -> Result<String, String> {
970 let mut stream = UnixStream::connect(socket)
971 .map_err(|error| format!("cannot connect to worker socket: {error}"))?;
972 let encoded = envelope
973 .encode()
974 .map_err(|error| format!("cannot encode request: {error}"))?;
975 stream
976 .write_all(encoded.as_bytes())
977 .and_then(|()| stream.write_all(b"\n"))
978 .map_err(|error| format!("cannot send request: {error}"))?;
979
980 let mut reply = String::new();
981 BufReader::new(stream)
982 .read_line(&mut reply)
983 .map_err(|error| format!("cannot read response: {error}"))?;
984 if reply.trim_end().is_empty() {
985 return Err("the daemon closed the connection without replying".into());
986 }
987 Ok(reply)
988}
989
990fn write_cli_error(mode: OutputMode, output: &mut impl Write, message: String) -> ExitCode {
991 write_response(
992 mode,
993 None,
994 output,
995 &ResponseEnvelope::failure(
996 None,
997 ErrorBody {
998 code: ErrorCode::InvalidArguments,
999 message,
1000 details: json!({}),
1001 },
1002 ),
1003 ExitCode::from(2),
1004 )
1005}
1006
1007fn write_response(
1008 mode: OutputMode,
1009 verb: Option<&str>,
1010 output: &mut impl Write,
1011 response: &ResponseEnvelope,
1012 success: ExitCode,
1013) -> ExitCode {
1014 let written = match mode {
1015 OutputMode::Json => serde_json::to_writer(&mut *output, response)
1016 .map_err(|_| ())
1017 .and_then(|()| output.write_all(b"\n").map_err(|_| ())),
1018 OutputMode::Human => output
1019 .write_all(crate::render::render(verb, response).as_bytes())
1020 .map_err(|_| ()),
1021 };
1022 if written.is_err() {
1023 return ExitCode::FAILURE;
1024 }
1025 success
1026}
1027
1028#[cfg(test)]
1029mod tests {
1030 use clap::Parser;
1031 use serde_json::{Value, json};
1032
1033 use super::{Cli, subcommand_synonym};
1034 use crate::protocol::{Capability, Request};
1035
1036 fn error_envelope(args: &[&str]) -> Value {
1039 let mut stdout = Vec::new();
1040 let mut stderr = Vec::new();
1041 let mut argv = vec!["sloop", "--json"];
1042 argv.extend_from_slice(args);
1043 super::run(argv, &mut stdout, &mut stderr);
1044 serde_json::from_slice(&stderr).expect("stderr carries a JSON envelope")
1045 }
1046
1047 #[test]
1048 fn unknown_subcommand_suggests_the_tickets_synonym() {
1049 let envelope = error_envelope(&["tickets"]);
1050
1051 assert_eq!(envelope["error"]["code"], "invalid_arguments");
1052 let message = envelope["error"]["message"]
1053 .as_str()
1054 .expect("error message");
1055 assert!(
1056 message.contains("did you mean `list`") && message.contains("sloop list"),
1057 "synonym remedy missing from: {message}"
1058 );
1059 }
1060
1061 #[test]
1062 fn unknown_subcommand_suggests_a_near_miss_spelling() {
1063 let envelope = error_envelope(&["statuss"]);
1066
1067 let message = envelope["error"]["message"]
1068 .as_str()
1069 .expect("error message");
1070 assert!(
1071 message.contains("status"),
1072 "near-miss suggestion missing from: {message}"
1073 );
1074 }
1075
1076 #[test]
1077 fn synonym_table_only_points_at_real_verbs() {
1078 use clap::CommandFactory;
1079
1080 let verbs: Vec<String> = Cli::command()
1081 .get_subcommands()
1082 .map(|subcommand| subcommand.get_name().to_owned())
1083 .collect();
1084 for attempted in ["tickets", "ls", "queue", "ps", "start", "kill", "abort"] {
1085 let verb = subcommand_synonym(attempted).expect("synonym maps to a verb");
1086 assert!(
1087 verbs.iter().any(|known| known == verb),
1088 "synonym `{attempted}` points at unknown verb `{verb}`"
1089 );
1090 }
1091 assert!(subcommand_synonym("definitely-not-a-verb").is_none());
1092 }
1093
1094 #[test]
1095 fn parses_every_documented_verb() {
1096 let commands: &[&[&str]] = &[
1097 &["sloop", "init"],
1098 &["sloop", "daemon"],
1099 &["sloop", "post", "ticket.md", "--auto"],
1100 &["sloop", "post", "ticket.md", "--at", "03:00"],
1101 &["sloop", "post", "ticket.md", "--manual"],
1102 &["sloop", "post", "ticket.md", "--hold"],
1103 &["sloop", "run"],
1104 &["sloop", "run", "T1", "--at", "03:00"],
1105 &["sloop", "run", "--every", "30m", "--only", "T1,T7"],
1106 &["sloop", "run", "--overnight"],
1107 &["sloop", "retry", "T1"],
1108 &["sloop", "hold", "T1"],
1109 &["sloop", "ready", "T1"],
1110 &["sloop", "list"],
1111 &["sloop", "status"],
1112 &["sloop", "pause"],
1113 &["sloop", "resume"],
1114 &["sloop", "cancel", "R1"],
1115 &["sloop", "logs", "R1"],
1116 &["sloop", "watch"],
1117 &["sloop", "watch", "--tail", "50"],
1118 &["sloop", "reindex"],
1119 &["sloop", "brief"],
1120 &["sloop", "show", "T1"],
1121 &["sloop", "note", "work", "in", "progress"],
1122 &["sloop", "verdict", "fail", "--reason", "changes requested"],
1123 ];
1124
1125 for command in commands {
1126 Cli::try_parse_from(*command).unwrap_or_else(|error| {
1127 panic!("failed to parse {command:?}: {error}");
1128 });
1129 }
1130 }
1131
1132 #[test]
1133 fn every_documented_verb_constructs_a_typed_request() {
1134 let commands: &[&[&str]] = &[
1135 &["sloop", "init"],
1136 &["sloop", "daemon"],
1137 &["sloop", "post", "ticket.md", "--auto"],
1138 &["sloop", "run"],
1139 &["sloop", "retry", "T1"],
1140 &["sloop", "hold", "T1"],
1141 &["sloop", "ready", "T1"],
1142 &["sloop", "list"],
1143 &["sloop", "status"],
1144 &["sloop", "pause"],
1145 &["sloop", "resume"],
1146 &["sloop", "cancel", "R1"],
1147 &["sloop", "logs", "R1"],
1148 &["sloop", "watch"],
1149 &["sloop", "reindex"],
1150 &["sloop", "brief"],
1151 &["sloop", "show", "T1"],
1152 &["sloop", "note", "working"],
1153 &["sloop", "verdict", "pass"],
1154 ];
1155
1156 for command in commands {
1157 Cli::try_parse_from(*command)
1158 .unwrap()
1159 .into_request()
1160 .unwrap_or_else(|error| panic!("failed to construct {command:?}: {error}"));
1161 }
1162 }
1163
1164 #[test]
1165 fn run_options_become_protocol_arguments() {
1166 let request = Cli::try_parse_from(["sloop", "run", "--every", "30m", "--only", "T1,T7"])
1167 .unwrap()
1168 .into_request()
1169 .unwrap();
1170
1171 assert_eq!(
1172 serde_json::to_value(request).unwrap(),
1173 json!({
1174 "verb": "run",
1175 "args": {
1176 "activation": {"kind": "every", "interval_ms": 1_800_000},
1177 "only": ["T1", "T7"]
1178 }
1179 })
1180 );
1181 }
1182
1183 #[test]
1184 fn hold_becomes_a_distinct_post_activation() {
1185 let request = Cli::try_parse_from(["sloop", "post", "ticket.md", "--hold"])
1186 .unwrap()
1187 .into_request()
1188 .unwrap();
1189
1190 assert_eq!(
1191 serde_json::to_value(request).unwrap(),
1192 json!({
1193 "verb": "post",
1194 "args": {
1195 "file": "ticket.md",
1196 "activation": {"kind": "hold"}
1197 }
1198 })
1199 );
1200 }
1201
1202 #[test]
1203 fn worker_request_text_and_capability_are_preserved() {
1204 let request = Cli::try_parse_from(["sloop", "note", "work", "in", "progress"])
1205 .unwrap()
1206 .into_request()
1207 .unwrap();
1208
1209 assert_eq!(request.capability(), Capability::Worker);
1210 assert_eq!(
1211 serde_json::to_value(request).unwrap(),
1212 json!({"verb": "note", "args": {"text": "work in progress"}})
1213 );
1214 }
1215
1216 #[test]
1217 fn verdict_becomes_a_worker_request() {
1218 let request =
1219 Cli::try_parse_from(["sloop", "verdict", "fail", "--reason", "changes requested"])
1220 .unwrap()
1221 .into_request()
1222 .unwrap();
1223
1224 assert_eq!(request.capability(), Capability::Worker);
1225 assert_eq!(
1226 serde_json::to_value(request).unwrap(),
1227 json!({
1228 "verb": "verdict",
1229 "args": {"verdict": "fail", "reason": "changes requested"}
1230 })
1231 );
1232 }
1233
1234 #[test]
1235 fn operator_requests_are_classified_separately() {
1236 let request = Cli::try_parse_from(["sloop", "status"])
1237 .unwrap()
1238 .into_request()
1239 .unwrap();
1240
1241 assert_eq!(request.capability(), Capability::Operator);
1242 assert!(matches!(request, Request::Status(_)));
1243 }
1244
1245 #[test]
1246 fn invalid_time_and_duration_fail_during_cli_parsing() {
1247 assert!(Cli::try_parse_from(["sloop", "run", "--at", "25:00"]).is_err());
1248 assert!(Cli::try_parse_from(["sloop", "run", "--every", "later"]).is_err());
1249 assert!(Cli::try_parse_from(["sloop", "run", "--every", "0m"]).is_err());
1250 }
1251
1252 #[test]
1253 fn run_accepts_only_one_activation_mode() {
1254 let result = Cli::try_parse_from(["sloop", "run", "--at", "03:00", "--every", "30m"]);
1255 assert!(result.is_err());
1256 }
1257
1258 #[test]
1259 fn post_defaults_to_auto_and_accepts_only_one_explicit_activation_mode() {
1260 let request = Cli::try_parse_from(["sloop", "post", "ticket.md"])
1261 .unwrap()
1262 .into_request()
1263 .unwrap();
1264 assert_eq!(
1265 serde_json::to_value(request).unwrap(),
1266 json!({
1267 "verb": "post",
1268 "args": {
1269 "file": "ticket.md",
1270 "activation": {"kind": "auto"}
1271 }
1272 })
1273 );
1274 assert!(Cli::try_parse_from(["sloop", "post", "ticket.md", "--auto", "--manual"]).is_err());
1275 assert!(Cli::try_parse_from(["sloop", "post", "ticket.md", "--manual", "--hold"]).is_err());
1276 }
1277}