Skip to main content

sloop/
cli.rs

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    /// Emit JSON envelopes instead of human-readable output.
41    #[arg(long, global = true)]
42    pub json: bool,
43}
44
45/// How responses are written. Envelopes are always produced internally;
46/// `Human` translates them at the final write.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48enum OutputMode {
49    Json,
50    Human,
51}
52
53#[derive(Debug, Subcommand)]
54pub enum Command {
55    /// Scaffold a Sloop project.
56    Init,
57    /// Ensure the daemon is running.
58    Daemon(DaemonCliArgs),
59    /// Register a ticket file.
60    Post(PostCliArgs),
61    /// Enqueue a run.
62    #[command(hide = true)]
63    Run(RunCliArgs),
64    /// Make a failed ticket ready to run again.
65    #[command(hide = true)]
66    Retry { ticket: String },
67    /// Prevent a ready ticket from being dispatched.
68    #[command(hide = true)]
69    Hold { ticket: String },
70    /// Release a held ticket for dispatch.
71    #[command(hide = true)]
72    Ready { ticket: String },
73    /// List ticket names, states, and why they are not running.
74    List,
75    /// Show daemon state.
76    Status,
77    /// Stop spawning new agents.
78    #[command(hide = true)]
79    Pause,
80    /// Resume spawning agents.
81    #[command(hide = true)]
82    Resume,
83    /// Stop the daemon.
84    #[command(hide = true)]
85    Stop {
86        /// Cancel active runs instead of refusing to stop.
87        #[arg(long)]
88        force: bool,
89    },
90    /// Cancel a run and preserve its worktree.
91    #[command(hide = true)]
92    Cancel {
93        /// Run alias, ticket reference, or run-id prefix.
94        run: String,
95    },
96    /// Show output from a run.
97    #[command(hide = true)]
98    Logs {
99        /// Run alias, ticket reference, or run-id prefix.
100        run: String,
101    },
102    /// Follow ticket and run activity as it happens.
103    Watch {
104        /// Number of recent events to show before following.
105        #[arg(long, default_value_t = 20)]
106        tail: u32,
107    },
108    /// Block until a run reaches a terminal state.
109    #[command(hide = true)]
110    Wait {
111        /// Run alias, ticket reference, or run-id prefix.
112        run: String,
113        /// Give up after this many seconds.
114        #[arg(long, default_value_t = 3600)]
115        timeout: u64,
116    },
117    /// Rebuild local state from committed files and Git.
118    #[command(hide = true)]
119    Reindex,
120    /// Show the current worker's assignment.
121    Brief,
122    /// Show a ticket, run, or project by reference.
123    Show { r#ref: String },
124    /// Append an advisory note to the current run.
125    #[command(hide = true)]
126    Note {
127        #[arg(required = true, trailing_var_arg = true)]
128        text: Vec<String>,
129    },
130    /// Report the current stage's verdict.
131    #[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    /// Drain active runs and restart with the binary currently installed.
156    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    /// Markdown ticket to register.
167    file: PathBuf,
168    /// Project receiving the ticket; defaults to `default`.
169    #[arg(long, value_name = "PROJECT")]
170    project: Option<String>,
171    /// Flow the ticket binds to; defaults to the repository's default flow.
172    #[arg(long, value_name = "FLOW")]
173    flow: Option<String>,
174    /// Queue one run for the next available opportunity (default).
175    #[arg(long)]
176    auto: bool,
177    /// Queue one run for the next occurrence of a local time.
178    #[arg(long, value_name = "TIME")]
179    at: Option<LocalTime>,
180    /// Register the ticket without creating a run.
181    #[arg(long)]
182    manual: bool,
183    /// Register the ticket as held without creating a run.
184    #[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    /// Run a specific ticket instead of selecting ready work.
196    ticket: Option<String>,
197    /// Select ready work from one project.
198    #[arg(long, value_name = "PROJECT", conflicts_with = "ticket")]
199    project: Option<String>,
200    /// Start at a local time, such as 03:00.
201    #[arg(long, value_name = "TIME")]
202    at: Option<LocalTime>,
203    /// Recur at an interval, such as 30m.
204    #[arg(long, value_name = "DURATION")]
205    every: Option<DurationMs>,
206    /// Run according to the configured overnight window.
207    #[arg(long)]
208    overnight: bool,
209    /// Restrict selection to the comma-separated ticket IDs.
210    #[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        // Parsing failed, so the flag is read from the raw arguments: an
425        // agent asking for `--json --help` still gets an envelope.
426        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
461/// Synonyms an agent is likely to type that clap's edit-distance matcher does
462/// not catch, each mapped to the real verb it should have used. This is the one
463/// place to add an alias; keep it small and keep every entry pointed at a verb
464/// that exists. Suggestions are text only — nothing here executes.
465fn 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
475/// Adds a remedy to clap's "unrecognized subcommand" error. clap already
476/// appends a `tip:` line when the typo is a near-miss of a real verb, and that
477/// text rides through the JSON envelope unchanged, so we leave those alone and
478/// only fill the gap: when similarity matching finds nothing but our synonym
479/// table does, point the caller at the verb they meant.
480fn 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
570/// Writes plain text in human mode or the given envelope in JSON mode; used
571/// for help and version, which have no verb-shaped payload.
572fn 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
647/// Polls the daemon until the run is terminal. The exit code is the outcome
648/// (`0` only for `merged`), so scripts and CI can gate on a run directly.
649/// Client-side wall-clock polling; the daemon stays stateless.
650fn 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
709/// Follows the activity feed until interrupted. Same client-side polling
710/// model as `wait`: each iteration asks the daemon for events past the
711/// cursor from the previous page, so the daemon stays stateless and any
712/// other client (a dashboard, a websocket bridge) can stream the same way.
713/// In `--json` mode each event is written as one NDJSON line.
714fn 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                // A full page means more events are already waiting; skip the
758                // sleep and drain them before pausing.
759                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
786/// Renders one activity event as a human `watch` line.
787fn 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
817/// `stop` is the one operator verb that must never resurrect a daemon: an
818/// unreachable socket already means the desired state.
819fn 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
897/// Sends a worker verb over the per-run socket injected by the agent adapter.
898/// Worker verbs never resurrect a daemon: without a run's `SLOOP_SOCKET` and
899/// `SLOOP_TOKEN` there is no state worth talking to, so they fail loudly. The
900/// daemon's reply envelope is written verbatim; agents are the only callers
901/// and the envelope is the API.
902fn 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    /// Drives the full CLI entry point and returns the error envelope written
1037    /// to stderr, exactly as an agent using `--json` would receive it.
1038    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        // clap's own similarity matcher supplies this tip; the envelope must
1064        // carry it through unchanged.
1065        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}