Skip to main content

coop/
cli.rs

1//! The command surface.
2//!
3//! The command shapes are settled by the spec and declared up front so
4//! `--help` stays honest while each implementation lands.
5
6use std::collections::VecDeque;
7use std::io::Write;
8
9use anyhow::Result;
10use clap::{Args, Parser, Subcommand};
11use serde::Serialize;
12
13use crate::config::{Config, Host};
14use crate::dispatch_warn::{DispatchWarning, dispatch_warnings};
15use crate::errors::{CoopError, EXIT_NO_MASTER};
16use crate::probe::{State, probe};
17use crate::transport::{Ssh, Transport};
18
19#[derive(Serialize)]
20struct JsonItems<T> {
21    items: T,
22    count: usize,
23}
24
25#[derive(Serialize)]
26struct HostListJson<'a> {
27    name: &'a str,
28    target: &'a str,
29    socket: String,
30    master: bool,
31}
32
33#[derive(Serialize)]
34struct HostInfoJson<'a> {
35    name: &'a str,
36    target: &'a str,
37    master: bool,
38    os: &'a str,
39    arch: &'a str,
40    cores: Option<u64>,
41    ram_gb: Option<u64>,
42    gpu: &'a str,
43    socket: String,
44    remedy: &'a str,
45}
46
47#[derive(Serialize)]
48struct JobJson<'a> {
49    id: &'a str,
50    host: &'a str,
51    state: &'static str,
52    rc: Option<i32>,
53    age_secs: u64,
54    cmd: &'a str,
55}
56
57#[derive(Serialize)]
58struct PollJson {
59    state: &'static str,
60    rc: Option<i32>,
61    log_size: u64,
62}
63
64#[derive(Serialize)]
65struct UnreachableJson<'a> {
66    host: &'a str,
67    why: &'a str,
68    remedy: &'a str,
69}
70
71#[derive(Serialize)]
72struct JobsJson<'a> {
73    items: Vec<JobJson<'a>>,
74    unreachable: Vec<UnreachableJson<'a>>,
75}
76
77#[derive(Parser, Debug)]
78#[command(
79    name = "coop",
80    // From Cargo.toml, so `coop --version` cannot drift from the published
81    // crate. A released binary that cannot say which version it is makes a bug
82    // report unactionable.
83    version,
84    about = "Fire remote jobs down a private ssh channel nothing else can take.",
85    long_about = "\
86Hand coop a command, get an id back, then poll, wait or tail against that id.
87You never see ssh, never see tmux, and never hold a connection.
88
89coop uses its OWN ssh ControlPath, so it cannot contend with git fetch, rsync or
90anything else on the default socket. The coop channel is never lent to local
91commands like rsync or git fetch.
92
93Operational facts:
94
95  * coop does NOT open the ssh master. `ssh -MNf` needs a TTY for a hardware
96    token and cannot prompt from a background call. This costs one token tap per
97    ControlPersist window.
98
99    Exit 3 means the master is missing, and it needs a HUMAN: someone may have
100    to touch a hardware key. If you are an agent or a script, STOP and ask the
101    operator to run the printed command. Do not retry, do not run `ssh -MNf`
102    yourself, and do not fall back to `ssh host command` -- that holds a session
103    channel for the whole job, which is the failure coop exists to remove.
104  * jobs run in a NON-login, NON-interactive shell, so login profiles do not
105    run. Bash still sources ~/.bashrc over ssh, so a PATH set there does reach
106    a job; ~/.bash_profile does not run, so a version manager's `activate` has
107    not happened. Put its shims dir on PATH in ~/.bashrc, or source what you
108    need in the command: coop run 'source ~/.zshrc && npm test'.
109  * stdout and stderr are merged into one log, in the order the job wrote them;
110    redirect inside your command to separate them.
111  * poll and wait print NO job output; `coop tail <id>` is the output verb.
112  * do NOT pipe your command into head or tail. `rc` becomes the pipe's, so a
113    failed build reports 0 and every `&&` after it proceeds. coop already
114    shapes the output for you: `coop tail <id> -n 3` instead of `| tail -3`.
115
116Exit status:
117  0   coop operation or job succeeded
118  3   no ssh control master
119  4   timed out waiting
120  5   orphaned job
121  6   connection dropped while waiting
122  <n> wait/--wait return the job's own exit code"
123)]
124pub struct Cli {
125    /// Config file (default: ~/.config/coop/config.toml)
126    #[arg(long, global = true, value_name = "PATH")]
127    pub config: Option<std::path::PathBuf>,
128
129    /// Suppress successful next-step hints on stderr
130    #[arg(long, global = true)]
131    pub quiet: bool,
132
133    #[command(subcommand)]
134    pub command: Commands,
135}
136
137#[derive(Args, Debug)]
138pub struct HostArg {
139    /// Which configured host. Optional when exactly one is configured.
140    #[arg(long, value_name = "H")]
141    pub host: Option<String>,
142}
143
144#[derive(Subcommand, Debug)]
145pub enum Commands {
146    /// Dispatch a command and print its job id
147    Run {
148        #[command(flatten)]
149        host: HostArg,
150        /// Directory to run in (default: the host's default_cwd, else $HOME)
151        #[arg(long, value_name = "D")]
152        cwd: Option<String>,
153        /// Kill the remote job after S seconds (0 means unbounded)
154        #[arg(long, value_name = "S")]
155        max_secs: Option<u64>,
156        /// Block locally until the job finishes; unlike --max-secs, this does not kill it
157        #[arg(long)]
158        wait: bool,
159        /// With --wait: print the log once at the end instead of streaming
160        #[arg(long, requires = "wait")]
161        no_tail: bool,
162        /// The command to run.
163        ///
164        /// Everything after the first word is part of the command, so coop's
165        /// own flags go BEFORE it: `coop run --wait ls`, not
166        /// `coop run ls --wait`. Use `--` when the command takes flags coop
167        /// also has: `coop run -- ls --all`.
168        ///
169        /// stdout and stderr are merged into one log, in the order the job
170        /// wrote them; redirect inside your command to separate them.
171        ///
172        /// Do NOT pipe the command into head or tail to keep the log small.
173        /// `rc` becomes the pipe's -- measured: `sh -c 'echo x; exit 1' |
174        /// tail -3` exits 0 -- so a failed job reports success and any `&&`
175        /// after it runs anyway, and `rc` is the artifact coop's whole design
176        /// rests on. Let the job be the work and let coop shape the output:
177        /// `coop tail <id> -n 3`, or plain `coop tail <id>`, which already
178        /// caps the read at 64KB. If your remote sh supports it,
179        /// `set -o pipefail` keeps a genuine pipeline honest; it is not
180        /// portable POSIX, so coop does not add it for you -- the command is
181        /// yours.
182        #[arg(trailing_var_arg = true, required = true)]
183        cmd: Vec<String>,
184    },
185    /// Print state, but no job output; prints nothing from the job; use coop tail <id>
186    ///
187    /// What `coop tail` gives you is one log: stdout and stderr are merged into
188    /// one log, in the order the job wrote them; redirect inside your command
189    /// to separate them.
190    Poll {
191        id: crate::wrapper::JobId,
192        #[command(flatten)]
193        host: HostArg,
194        #[arg(long)]
195        json: bool,
196    },
197    /// Block until done; prints nothing; use coop tail <id>
198    ///
199    /// What `coop tail` gives you is one log: stdout and stderr are merged into
200    /// one log, in the order the job wrote them; redirect inside your command
201    /// to separate them.
202    Wait {
203        id: crate::wrapper::JobId,
204        #[command(flatten)]
205        host: HostArg,
206        /// Stop waiting locally after S seconds; the remote job keeps running
207        #[arg(long, value_name = "S")]
208        timeout: Option<u64>,
209    },
210    /// Print a job's merged stdout and stderr as raw bytes
211    ///
212    /// stdout and stderr are merged into one log, in the order the job wrote
213    /// them; redirect inside your command to separate them. There is one
214    /// artifact per job on purpose: splitting it would mean two files, two
215    /// probe offsets, and a lost interleaving, to serve a case a redirect in
216    /// your own command already covers.
217    Tail {
218        /// The job whose log to print.
219        id: crate::wrapper::JobId,
220        #[command(flatten)]
221        host: HostArg,
222        /// Follow until the job finishes
223        #[arg(short, long)]
224        follow: bool,
225        /// Print the whole log instead of the last 64KB
226        #[arg(long, conflicts_with_all = ["lines", "follow"])]
227        all: bool,
228        /// Print the last N lines instead of the last 64KB
229        #[arg(short = 'n', value_name = "LINES", conflicts_with_all = ["all", "follow"])]
230        lines: Option<u64>,
231    },
232    /// List jobs
233    ///
234    /// The human table collapses whitespace and truncates commands to keep one
235    /// job on one scannable line. Use --full to read a long command, --json for
236    /// the machine surface.
237    Ls {
238        #[command(flatten)]
239        host: HostArg,
240        /// Include finished jobs older than the default 24-hour window
241        #[arg(long)]
242        all: bool,
243        /// Emit machine-readable rows with complete, unmodified commands
244        #[arg(long)]
245        json: bool,
246        /// Print each command in full instead of truncating it to fit one line
247        ///
248        /// The table shortens a long command so one job stays one scannable
249        /// row. This prints the whole thing, for reading rather than
250        /// scanning; `--json` remains the machine surface.
251        #[arg(long, conflicts_with = "json")]
252        full: bool,
253    },
254    /// Kill a running job
255    Kill {
256        id: crate::wrapper::JobId,
257        #[command(flatten)]
258        host: HostArg,
259        /// Drop the job's state directory too, in the same round trip
260        ///
261        /// `kill` then `rm` is the common pair -- ending a job you did not
262        /// mean to start usually means discarding its output as well. Doing
263        /// both here costs one ssh call instead of two on a capped channel.
264        #[arg(long)]
265        rm: bool,
266    },
267    /// Drop a job's state directory
268    Rm {
269        /// The job to remove. Omit with --all.
270        id: Option<crate::wrapper::JobId>,
271        /// Remove every FINISHED job, ignoring keep_days.
272        ///
273        /// Running jobs and orphans are kept: `--all` never stops work, and an
274        /// orphan is evidence rather than mud. Use `coop rm <id>` or `coop kill`
275        /// to end a named job.
276        #[arg(long, conflicts_with = "id")]
277        all: bool,
278        #[command(flatten)]
279        host: HostArg,
280    },
281    /// Inspect configured hosts
282    #[command(subcommand)]
283    Host(HostCmd),
284}
285
286#[derive(Subcommand, Debug)]
287pub enum HostCmd {
288    /// List configured hosts and whether each has a control master
289    List {
290        #[arg(long)]
291        json: bool,
292    },
293    /// Probe host OS, architecture, cores, memory, and GPU
294    Info {
295        /// Probe only this configured host
296        #[arg(long, value_name = "H")]
297        host: Option<String>,
298        /// Emit every capability as machine-readable JSON
299        #[arg(long)]
300        json: bool,
301    },
302}
303
304pub fn load_config(path: Option<&std::path::Path>) -> Result<Config> {
305    // An explicit --config is the user asserting the file exists; a missing one
306    // is their typo to see, not ours to paper over with a template.
307    if let Some(p) = path {
308        return Config::load(p);
309    }
310
311    let default = crate::config::default_path()?;
312    if !default.exists() {
313        // First run. A bare "No such file or directory" is a dead end: it names
314        // a path but not what belongs in it. Seed a commented template so the
315        // next step is to edit a file that already exists.
316        crate::config::seed(&default)?;
317        anyhow::bail!(
318            "no hosts configured yet\n  \
319             wrote a template to {}\n  \
320             edit it to name a host, then run `coop host list`",
321            default.display()
322        );
323    }
324    Config::load(&default)
325}
326
327/// `coop host list`.
328///
329/// A down master is *information* for this verb, not an error: "which of my
330/// hosts can I use right now" is the question being asked, so it prints the
331/// state and exits 0. Every other verb treats a down master as exit 3.
332pub fn host_list(cfg: &Config, t: &dyn Transport, json: bool) -> Result<()> {
333    let rows: Vec<(&crate::config::Host, bool)> =
334        cfg.hosts().iter().map(|h| (h, t.master_alive(h))).collect();
335
336    if json {
337        // JSON is an agent-facing contract, and this is now one of three
338        // emitters. Typed serialization makes escaping mandatory instead of a
339        // convention each new field can forget.
340        let items = rows
341            .iter()
342            .map(|(host, master)| HostListJson {
343                name: &host.name,
344                target: &host.target,
345                socket: host.socket.to_string_lossy().into_owned(),
346                master: *master,
347            })
348            .collect::<Vec<_>>();
349        println!(
350            "{}",
351            serde_json::to_string(&JsonItems {
352                count: items.len(),
353                items,
354            })?
355        );
356        return Ok(());
357    }
358
359    print_table(
360        &["NAME", "MASTER", "TARGET", "SOCKET"],
361        &rows
362            .iter()
363            .map(|(h, up)| {
364                vec![
365                    h.name.clone(),
366                    if *up { "up" } else { "down" }.to_string(),
367                    h.target.clone(),
368                    h.socket.display().to_string(),
369                ]
370            })
371            .collect::<Vec<_>>(),
372    );
373    if rows.iter().any(|(_, up)| !up) {
374        eprintln!(
375            "\nsome hosts have no control master. coop cannot open one \
376             (ssh -MNf needs a TTY for a hardware token).\n\
377             A human may need to tap a key; ask rather than retrying:"
378        );
379        for (h, up) in &rows {
380            if !up {
381                // Rendered by the same code every other verb uses, so the
382                // socket directory is prepared here too: ssh cannot create it
383                // and the printed command fails without it, after the 2FA
384                // prompt.
385                eprintln!("  {}", crate::errors::master_command(h));
386            }
387        }
388    }
389    Ok(())
390}
391
392#[derive(Debug)]
393struct HostInfo<'a> {
394    host: &'a Host,
395    master: bool,
396    os: String,
397    arch: String,
398    cores: Option<u64>,
399    ram_gb: Option<u64>,
400    gpu: String,
401    remedy: Option<String>,
402}
403
404/// Probe only on explicit `host info`: `host list` is a lock-exempt
405/// `ssh -O check`, so adding a session there would make the reachability check
406/// contend with jobs. The full probe measured 0.27s and stays one round trip.
407fn host_info(cfg: &Config, t: &dyn Transport, host_filter: Option<&str>, json: bool) -> Result<()> {
408    let hosts: Vec<&Host> = match host_filter {
409        Some(name) => vec![cfg.host(Some(name))?],
410        None => cfg.hosts().iter().collect(),
411    };
412    let mut rows = Vec::with_capacity(hosts.len());
413    for host in hosts {
414        if !t.master_alive(host) {
415            rows.push(HostInfo {
416                host,
417                master: false,
418                os: "unknown".into(),
419                arch: "unknown".into(),
420                cores: None,
421                ram_gb: None,
422                gpu: "unknown".into(),
423                remedy: Some(crate::errors::master_command(host)),
424            });
425            continue;
426        }
427        let output = t.run(host, host_info_script())?;
428        if output.code != 0 {
429            anyhow::bail!(
430                "probing host {} failed: {}",
431                host.name,
432                output.stderr.trim()
433            );
434        }
435        rows.push(parse_host_info(host, &output.text()));
436    }
437
438    for row in rows.iter().filter(|row| !row.master) {
439        eprintln!("{}: unreachable (no control master)", row.host.name);
440        if let Some(remedy) = &row.remedy {
441            eprintln!("  {remedy}");
442            eprintln!("  a human may need to tap a hardware key; ask rather than retrying");
443        }
444    }
445
446    if json {
447        let items = rows
448            .iter()
449            .map(|row| HostInfoJson {
450                name: &row.host.name,
451                target: &row.host.target,
452                master: row.master,
453                os: &row.os,
454                arch: &row.arch,
455                cores: row.cores,
456                ram_gb: row.ram_gb,
457                gpu: &row.gpu,
458                socket: row.host.socket.to_string_lossy().into_owned(),
459                remedy: row.remedy.as_deref().unwrap_or(""),
460            })
461            .collect::<Vec<_>>();
462        println!(
463            "{}",
464            serde_json::to_string(&JsonItems {
465                count: items.len(),
466                items,
467            })?
468        );
469        return Ok(());
470    }
471
472    print_table(
473        &[
474            "NAME", "MASTER", "OS", "ARCH", "CORES", "RAM", "GPU", "TARGET",
475        ],
476        &rows
477            .iter()
478            .map(|row| {
479                vec![
480                    row.host.name.clone(),
481                    if row.master { "up" } else { "down" }.to_string(),
482                    row.os.clone(),
483                    row.arch.clone(),
484                    row.cores
485                        .map_or_else(|| "unknown".into(), |n| n.to_string()),
486                    row.ram_gb
487                        .map_or_else(|| "unknown".into(), |n| format!("{n}GB")),
488                    row.gpu.clone(),
489                    row.host.target.clone(),
490                ]
491            })
492            .collect::<Vec<_>>(),
493    );
494    Ok(())
495}
496
497/// One aligned table with a header, for every human-readable listing.
498///
499/// There were three renderers and two of them were wrong: `host list` printed
500/// bare rows with no header, so the reader had to know that field two was the
501/// master state, and `host info` printed a FIXED-WIDTH header over unpadded
502/// rows -- so the header and the data disagreed about where a column began as
503/// soon as a value was wider than its title, which is every real hostname.
504/// `ls` was the only correct one, and this is its logic, shared.
505///
506/// The last column is never padded, so it can run long without trailing
507/// whitespace on every line. Widths count CHARACTERS, not bytes: a command or
508/// a hostname can be non-ASCII, and byte widths would misalign it.
509fn print_table(head: &[&str], rows: &[Vec<String>]) {
510    let mut width: Vec<usize> = head.iter().map(|h| h.chars().count()).collect();
511    for row in rows {
512        for (w, cell) in width.iter_mut().zip(row) {
513            *w = (*w).max(cell.chars().count());
514        }
515    }
516
517    let render = |cells: &[String]| {
518        let last = cells.len().saturating_sub(1);
519        let mut line = String::new();
520        for (i, cell) in cells.iter().enumerate() {
521            if i == last {
522                line.push_str(cell);
523            } else {
524                line.push_str(&format!("{cell:<width$}  ", width = width[i]));
525            }
526        }
527        line
528    };
529
530    println!(
531        "{}",
532        render(&head.iter().map(|h| (*h).to_string()).collect::<Vec<_>>())
533    );
534    for row in rows {
535        println!("{}", render(row));
536    }
537}
538
539/// One portable best-effort script. Every platform-specific command falls
540/// back, and `command -v` guards nvidia-smi because `missing | awk` succeeds.
541fn host_info_script() -> &'static str {
542    "os=$(uname -s 2>/dev/null || echo unknown); \
543     arch=$(uname -m 2>/dev/null || echo unknown); \
544     cores=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo unknown); \
545     if [ -r /proc/meminfo ]; then \
546       ram=$(awk '/MemTotal/{printf \"%.0f\", $2/1048576}' /proc/meminfo 2>/dev/null); \
547     elif command -v sysctl >/dev/null 2>&1; then \
548       bytes=$(sysctl -n hw.memsize 2>/dev/null); \
549       case $bytes in *[!0-9]*|'') ram=unknown;; *) ram=$((bytes / 1073741824));; esac; \
550     else ram=unknown; fi; \
551     [ -n \"$ram\" ] || ram=unknown; \
552     if command -v nvidia-smi >/dev/null 2>&1; then \
553       gpu=$(nvidia-smi --query-gpu=name,memory.total --format=csv,noheader 2>/dev/null | \
554             awk 'NR <= 2 { if (NR > 1) printf \"; \"; printf \"%s\", $0 }'); \
555     elif command -v system_profiler >/dev/null 2>&1; then \
556       gpu=$(system_profiler SPDisplaysDataType 2>/dev/null | \
557             awk -F: '/Chipset Model/{sub(/^[[:space:]]*/, \"\", $2); print $2; exit}'); \
558     else gpu=none; fi; \
559     [ -n \"$gpu\" ] || gpu=none; \
560     printf '%s\\t%s\\t%s\\t%s\\t%s\\n' \"$os\" \"$arch\" \"$cores\" \"$ram\" \"$gpu\""
561}
562
563fn parse_host_info<'a>(host: &'a Host, text: &str) -> HostInfo<'a> {
564    let mut fields = text.trim_end().splitn(5, '\t');
565    let os = fields
566        .next()
567        .filter(|s| !s.is_empty())
568        .unwrap_or("unknown")
569        .to_string();
570    let arch = fields
571        .next()
572        .filter(|s| !s.is_empty())
573        .unwrap_or("unknown")
574        .to_string();
575    let cores = fields.next().and_then(|s| s.parse().ok());
576    let ram_gb = fields.next().and_then(|s| s.parse().ok());
577    let gpu = fields
578        .next()
579        .filter(|s| !s.is_empty())
580        .unwrap_or("unknown")
581        .to_string();
582    HostInfo {
583        host,
584        master: true,
585        os,
586        arch,
587        cores,
588        ram_gb,
589        gpu,
590        remedy: None,
591    }
592}
593
594pub fn poll(t: &dyn Transport, host: &Host, id: &crate::wrapper::JobId, json: bool) -> Result<i32> {
595    poll_with_hint(t, host, id, json, true)
596}
597
598fn poll_with_hint(
599    t: &dyn Transport,
600    host: &Host,
601    id: &crate::wrapper::JobId,
602    json: bool,
603    quiet: bool,
604) -> Result<i32> {
605    // State only: `poll` discards log bytes, so asking for them would transfer
606    // the whole log -- potentially hundreds of MB -- while holding the single
607    // session channel and the ticket lock. That is invariant 3 violated by the
608    // cheapest verb in the tool.
609    crate::errors::require_master(t, host)?;
610    let result = probe(t, host, id, crate::probe::From::StateOnly)?;
611    if json {
612        println!("{}", poll_json(&result.state, result.log_size));
613    } else {
614        match result.state {
615            State::Running => println!("running"),
616            State::Done(code) => println!("{code}"),
617            State::Orphan => println!("orphan"),
618        }
619    }
620    if !quiet {
621        match result.state {
622            State::Running => {
623                eprintln!("next: coop wait {id} to block; coop tail {id} -f to follow")
624            }
625            State::Done(_) => {
626                eprintln!("next: coop tail {id} for output; coop rm {id} to drop its state")
627            }
628            State::Orphan => eprintln!(
629                "orphan: no exit code will arrive\nnext: coop tail {id} for output; coop rm {id} to drop its state"
630            ),
631        }
632    }
633    Ok(0)
634}
635
636fn poll_json(state: &State, log_size: u64) -> String {
637    let (state, rc) = match state {
638        State::Running => ("running", None),
639        State::Done(code) => ("done", Some(*code)),
640        State::Orphan => ("orphan", None),
641    };
642    serde_json::to_string(&PollJson {
643        state,
644        rc,
645        log_size,
646    })
647    .expect("poll json is numbers and static strings")
648}
649
650pub fn wait(
651    t: &dyn Transport,
652    host: &Host,
653    id: &crate::wrapper::JobId,
654    timeout: Option<u64>,
655) -> Result<i32> {
656    crate::errors::require_master(t, host)?;
657    crate::tail::wait_only(t, host, id, timeout)
658}
659
660pub fn dispatch(cli: Cli) -> Result<i32> {
661    let cfg = load_config(cli.config.as_deref())?;
662    let quiet = cli.quiet;
663    match cli.command {
664        Commands::Run {
665            host,
666            cwd,
667            max_secs,
668            wait,
669            no_tail,
670            cmd,
671        } => {
672            let host = cfg.host(host.host.as_deref())?;
673            // `--` is the caller saying "everything after this is the
674            // command", so an explicit separator silences the warning. clap
675            // strips it, so look at the raw arguments.
676            if !std::env::args().any(|a| a == "--") {
677                warn_about_swallowed_flags(&cmd);
678            }
679            let command = command_from_args(&cmd);
680            let has_runtime_cap = max_secs.unwrap_or(host.max_job_secs) > 0;
681            match crate::run::dispatch(&Ssh, host, &command, cwd.as_deref(), max_secs) {
682                Ok(id) => {
683                    println!("{id}");
684                    std::io::stdout().flush()?;
685                    // Warn AFTER dispatch, so the advice can name the job it
686                    // is about. Warning first meant printing a literal
687                    // `coop tail <id>` at the one moment a real id did not
688                    // exist yet -- and the job starts regardless, so a reader
689                    // was told something was wrong with no way to act on it.
690                    // coop never blocks on a heuristic: the command belongs to
691                    // the caller (AGENTS.md), and a pipeline may be deliberate.
692                    if !quiet {
693                        warn_about_dispatch_patterns(&command, has_runtime_cap, &id);
694                    }
695                    if !wait {
696                        if !quiet {
697                            eprintln!("next: coop wait {id} for the exit code");
698                            eprintln!("      coop tail {id} for output");
699                        }
700                        return Ok(0);
701                    }
702                    let stdout = std::io::stdout().lock();
703                    let mut output = HintWriter::new(stdout);
704                    let result = if no_tail {
705                        crate::tail::follow_deferred(&Ssh, host, &id, &mut output)
706                    } else {
707                        crate::tail::follow(&Ssh, host, &id, 0, &mut output)
708                    };
709                    if let Ok(code) = result {
710                        // The exit code is the cheap gate. Only a failed job
711                        // earns even the bounded log-tail scan below.
712                        if code != 0 && !quiet {
713                            missing_tool_hint(&output.tail());
714                        }
715                        if !quiet {
716                            eprintln!("next: coop rm {id} to drop its state");
717                        }
718                    }
719                    result
720                }
721                Err(error)
722                    if matches!(
723                        error.downcast_ref::<CoopError>(),
724                        Some(CoopError::NoMaster { .. })
725                    ) =>
726                {
727                    eprintln!("coop: {error}");
728                    Ok(EXIT_NO_MASTER)
729                }
730                Err(error) => Err(error),
731            }
732        }
733        Commands::Host(HostCmd::List { json }) => {
734            host_list(&cfg, &Ssh, json)?;
735            if !quiet {
736                // Mirror the surface the caller actually asked for. Handing
737                // `--json` to someone who just read a table gives a human a
738                // machine format, and dropping it for someone parsing JSON
739                // gives a parser a table. Same verb either way.
740                let next = if json {
741                    "coop host info --json"
742                } else {
743                    "coop host info"
744                };
745                eprintln!("next: {next} for OS, cores, RAM, and GPU");
746            }
747            Ok(0)
748        }
749        Commands::Host(HostCmd::Info { host, json }) => {
750            host_info(&cfg, &Ssh, host.as_deref(), json)?;
751            Ok(0)
752        }
753        Commands::Poll { id, host, json } => {
754            poll_with_hint(&Ssh, cfg.host(host.host.as_deref())?, &id, json, quiet)
755        }
756        Commands::Wait { id, host, timeout } => {
757            let result = wait(&Ssh, cfg.host(host.host.as_deref())?, &id, timeout);
758            if result.is_ok() && !quiet {
759                eprintln!("next: coop tail {id} for output; coop rm {id} to drop its state");
760            }
761            result
762        }
763        Commands::Tail {
764            id,
765            host,
766            follow,
767            all,
768            lines,
769        } => {
770            let host = cfg.host(host.host.as_deref())?;
771            let mut stdout = std::io::stdout().lock();
772            if follow {
773                crate::tail::follow(&Ssh, host, &id, 0, &mut stdout)
774            } else {
775                let selection = match lines {
776                    Some(lines) => crate::tail::Selection::Lines(lines),
777                    None if all => crate::tail::Selection::All,
778                    None => crate::tail::Selection::LastBytes,
779                };
780                crate::tail::once(&Ssh, host, &id, selection, &mut stdout)?;
781                Ok(0)
782            }
783        }
784        Commands::Ls {
785            host,
786            all,
787            json,
788            full,
789        } => {
790            let (rows, unreachable, hidden) =
791                crate::jobs::list_with_hidden(&cfg, &Ssh, host.host.as_deref(), all)?;
792            print_jobs(&rows, &unreachable, hidden, json, full, quiet);
793            Ok(0)
794        }
795        Commands::Kill { id, host, rm } => {
796            let host = cfg.host(host.host.as_deref())?;
797            let rc = crate::jobs::kill(&Ssh, host, &id)?;
798            if rm {
799                // The kill already wrote `rc`, so the job is finished and
800                // `remove` will take it. One more round trip is unavoidable --
801                // the kill must land before the state can go -- but the caller
802                // does not have to make the decision twice.
803                let removed =
804                    crate::jobs::remove(&Ssh, host, &crate::jobs::Target::One(id.clone()))?;
805                if !quiet {
806                    match removed.first() {
807                        Some(id) => eprintln!("killed and removed {id} (was done {rc})"),
808                        None => eprintln!("killed {id}, but its state was already gone"),
809                    }
810                }
811                return Ok(0);
812            }
813            if !quiet {
814                eprintln!("killed {id}; now done {rc}");
815                eprintln!("next: coop rm {id} to drop its state, or kill --rm next time");
816            }
817            Ok(0)
818        }
819        Commands::Rm { id, all, host } => {
820            let host = cfg.host(host.host.as_deref())?;
821            let target = match (id, all) {
822                (Some(id), _) => crate::jobs::Target::One(id),
823                (None, true) => crate::jobs::Target::AllDone,
824                // clap cannot express "one of these is required" across a
825                // positional and a flag, so say what to do rather than
826                // printing a bare usage error.
827                (None, false) => anyhow::bail!(
828                    "name a job, or pass --all to remove every finished one\n  \
829                     coop rm <id>\n  coop rm --all"
830                ),
831            };
832            let removed = crate::jobs::remove(&Ssh, host, &target)?;
833            // Report what happened: `--all` on a clean host is silent
834            // otherwise, which reads as a failure.
835            match removed.len() {
836                0 => eprintln!("coop: nothing to remove"),
837                1 => println!("{}", removed[0]),
838                n => {
839                    for id in &removed {
840                        println!("{id}");
841                    }
842                    eprintln!("coop: removed {n} finished jobs");
843                }
844            }
845            Ok(0)
846        }
847    }
848}
849
850const HINT_SCAN_BYTES: usize = 64 * 1024;
851
852struct HintWriter<W> {
853    inner: W,
854    tail: VecDeque<u8>,
855}
856
857impl<W> HintWriter<W> {
858    fn new(inner: W) -> Self {
859        Self {
860            inner,
861            tail: VecDeque::with_capacity(HINT_SCAN_BYTES),
862        }
863    }
864
865    fn tail(&self) -> Vec<u8> {
866        self.tail.iter().copied().collect()
867    }
868}
869
870impl<W: Write> Write for HintWriter<W> {
871    fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
872        let written = self.inner.write(bytes)?;
873        self.tail.extend(&bytes[..written]);
874        if self.tail.len() > HINT_SCAN_BYTES {
875            self.tail.drain(..self.tail.len() - HINT_SCAN_BYTES);
876        }
877        Ok(written)
878    }
879
880    fn flush(&mut self) -> std::io::Result<()> {
881        self.inner.flush()
882    }
883}
884
885fn missing_tool_hint(log_tail: &[u8]) {
886    let text = String::from_utf8_lossy(log_tail).to_ascii_lowercase();
887    if [
888        "command not found",
889        "not found on path",
890        "no such file or directory",
891    ]
892    .iter()
893    .any(|pattern| text.contains(pattern))
894    {
895        eprintln!(
896            "coop: the job's shell is non-login, so ~/.bash_profile did not run. If this is a missing tool, put its shims dir on PATH: coop run 'export PATH=$HOME/.elan/bin:$PATH; <cmd>'"
897        );
898    }
899}
900
901fn print_jobs(
902    rows: &[crate::jobs::Row],
903    unreachable: &[crate::jobs::Unreachable],
904    hidden: usize,
905    json: bool,
906    full: bool,
907    quiet: bool,
908) {
909    for host in unreachable {
910        eprintln!("{}: unreachable ({})", host.host, host.why);
911        if let Some(remedy) = &host.remedy {
912            eprintln!("  {remedy}");
913            eprintln!("  a human may need to tap a hardware key; ask rather than retrying");
914        }
915    }
916    if json {
917        let items = rows
918            .iter()
919            .map(|row| {
920                let (state, rc) = match row.state {
921                    State::Running => ("running", None),
922                    State::Done(code) => ("done", Some(code)),
923                    State::Orphan => ("orphan", None),
924                };
925                JobJson {
926                    id: &row.id,
927                    host: &row.host,
928                    state,
929                    rc,
930                    age_secs: row.age_secs,
931                    cmd: &row.cmd,
932                }
933            })
934            .collect();
935        let down = unreachable
936            .iter()
937            .map(|host| UnreachableJson {
938                host: &host.host,
939                why: &host.why,
940                // Carry the remedy in JSON too: a script cannot parse the
941                // stderr prose, and "unreachable" without the fix is not
942                // actionable for an agent either.
943                remedy: host.remedy.as_deref().unwrap_or(""),
944            })
945            .collect();
946        println!(
947            "{}",
948            serde_json::to_string(&JobsJson {
949                items,
950                unreachable: down,
951            })
952            .expect("serializing string-backed job rows cannot fail")
953        );
954        if rows.is_empty() && hidden == 0 && !quiet {
955            eprintln!("no jobs; next: coop run <cmd>");
956        }
957        return;
958    }
959
960    if rows.is_empty() {
961        if !quiet {
962            if hidden == 0 {
963                eprintln!("no jobs; next: coop run <cmd>");
964            } else {
965                eprintln!("{hidden} older finished jobs hidden; next: coop ls --all");
966            }
967        }
968        return;
969    }
970
971    // Aligned columns with a header. Tab-separated output with no header left
972    // the reader counting fields to work out which number was the exit code and
973    // which the age -- and `--json` already covers the machine case, so this
974    // one is for a person.
975    // COMMAND is last so it can run long without padding every line -- which
976    // is why `print_table` never pads its final column.
977    print_table(
978        &["ID", "HOST", "STATE", "RC", "AGE", "COMMAND"],
979        &rows
980            .iter()
981            .map(|row| {
982                let (state, rc) = match row.state {
983                    State::Running => ("running", "-".to_string()),
984                    State::Done(code) => ("done", code.to_string()),
985                    State::Orphan => ("orphan", "-".to_string()),
986                };
987                vec![
988                    row.id.clone(),
989                    row.host.clone(),
990                    state.to_string(),
991                    rc,
992                    format_age(row.age_secs),
993                    if full {
994                        collapse_whitespace(&row.cmd)
995                    } else {
996                        display_command(&row.cmd)
997                    },
998                ]
999            })
1000            .collect::<Vec<_>>(),
1001    );
1002    if !quiet {
1003        let id = &rows[0].id;
1004        eprintln!("next: coop poll {id}; coop tail {id}");
1005        if hidden > 0 {
1006            eprintln!("{hidden} older finished jobs hidden; next: coop ls --all");
1007        }
1008    }
1009}
1010
1011/// Preserve the two documented command forms: one argument is a shell string;
1012/// multiple arguments are an argv-style command whose boundaries must survive
1013/// the remote shell. The local shell has already removed the caller's quoting,
1014/// so joining with spaces cannot distinguish `"a b"` from `a b`.
1015fn command_from_args(args: &[String]) -> String {
1016    match args {
1017        [command] => command.clone(),
1018        _ => args
1019            .iter()
1020            .map(|arg| format!("'{}'", arg.replace('\'', "'\\''")))
1021            .collect::<Vec<_>>()
1022            .join(" "),
1023    }
1024}
1025
1026/// Warn when the command contains something that looks like a coop flag.
1027///
1028/// `run` takes the command as trailing arguments, so `coop run ls --wait` sends
1029/// `--wait` to `ls` rather than to coop. That has to be true -- otherwise you
1030/// could not run a command that takes flags -- but it fails silently: the job
1031/// dispatches, no output appears because `--wait` never reached coop, and the
1032/// exit code is whatever the command made of the stray argument. `ls` exits 1
1033/// on an unknown flag, which reads as a coop bug.
1034///
1035/// So this warns rather than erroring: the command really might want the flag,
1036/// and refusing would break `coop run -- rsync --delete ...`.
1037fn warn_about_swallowed_flags(cmd: &[String]) {
1038    const COOP_FLAGS: [&str; 8] = [
1039        "--wait",
1040        "--no-tail",
1041        "--max-secs",
1042        "--quiet",
1043        "--cwd",
1044        "--host",
1045        "--json",
1046        "--all",
1047    ];
1048    let found: Vec<&str> = cmd
1049        .iter()
1050        .skip(1)
1051        .filter_map(|arg| COOP_FLAGS.iter().find(|f| *f == arg).copied())
1052        .collect();
1053    if found.is_empty() {
1054        return;
1055    }
1056    eprintln!(
1057        "coop: warning: {} went to the command, not to coop",
1058        found.join(", ")
1059    );
1060    eprintln!(
1061        "  coop flags go before the command: coop run {} {}",
1062        found.join(" "),
1063        cmd.first().map(String::as_str).unwrap_or("<cmd>")
1064    );
1065    eprintln!(
1066        "  to silence this, separate them explicitly: coop run -- {}",
1067        command_from_args(cmd)
1068    );
1069}
1070
1071/// Report dispatch-pattern warnings for a job that is already running.
1072///
1073/// Every warning names the job and how to end it. The job exists by the time
1074/// this runs -- coop warns rather than blocking, because the command belongs
1075/// to the caller and a final pipeline may be exactly what they meant -- so
1076/// "here is what looks wrong" without "here is how to stop it" leaves the
1077/// reader holding a running job and no next step. That is worse for the
1078/// unbounded-loop case than saying nothing, since an unbounded loop is
1079/// precisely the job that will not end on its own.
1080fn warn_about_dispatch_patterns(command: &str, has_max_secs: bool, id: &crate::wrapper::JobId) {
1081    for warning in dispatch_warnings(command, has_max_secs) {
1082        match warning {
1083            DispatchWarning::PipelineStatus => eprintln!(
1084                "coop: warning: a final head/tail pipeline may hide the job's failure\n  \
1085                 rc will be the pipe's, so a failed command can report success\n  \
1086                 let coop shape the output instead: coop tail {id} -n 3\n  \
1087                 if intentional, set -o pipefail before the pipeline\n  \
1088                 to start over:  coop kill --rm {id}"
1089            ),
1090            DispatchWarning::UnboundedLoop => eprintln!(
1091                "coop: warning: this looks like an unbounded loop, and nothing will stop it\n  \
1092                 it holds a tmux session and a growing log until the host reboots\n  \
1093                 stop it now:   coop kill --rm {id}\n  \
1094                 then bound it: coop run --max-secs <seconds> '<cmd>'"
1095            ),
1096        }
1097    }
1098}
1099
1100/// Compact relative age: `45s`, `12m`, `3h`, `2d`.
1101///
1102/// Raw seconds made the reader do arithmetic to answer the only question they
1103/// were asking -- is this recent? -- and got worse the older the job was.
1104fn format_age(secs: u64) -> String {
1105    match secs {
1106        s if s < 60 => format!("{s}s"),
1107        s if s < 3600 => format!("{}m", s / 60),
1108        s if s < 86400 => format!("{}h", s / 3600),
1109        s => format!("{}d", s / 86400),
1110    }
1111}
1112
1113fn display_command(command: &str) -> String {
1114    const WIDTH: usize = 80;
1115
1116    let collapsed = collapse_whitespace(command);
1117    if collapsed.chars().count() <= WIDTH {
1118        return collapsed;
1119    }
1120
1121    collapsed.chars().take(WIDTH - 1).chain(['…']).collect()
1122}
1123
1124/// Whitespace collapsed, but nothing dropped.
1125///
1126/// Newlines and tabs still cannot reach the table -- an embedded newline would
1127/// break one job across several rows that look like separate jobs -- but the
1128/// text itself is complete. This is what `--full` prints.
1129fn collapse_whitespace(command: &str) -> String {
1130    command.split_whitespace().collect::<Vec<_>>().join(" ")
1131}
1132
1133#[cfg(test)]
1134mod tests {
1135    use super::{collapse_whitespace, command_from_args, display_command, host_info, poll_json};
1136    use crate::config::Config;
1137    use crate::probe::State;
1138    use crate::transport::{Fake, Output};
1139
1140    #[test]
1141    fn host_info_uses_one_round_trip_per_reachable_host() {
1142        let cfg = Config::parse("[hosts.one]\n[hosts.two]\n").unwrap();
1143        let fake = Fake::new();
1144        fake.push(Output::ok("Linux\tx86_64\t8\t16\tnone\n"))
1145            .push(Output::ok("Darwin\tarm64\t10\t32\tApple GPU\n"));
1146
1147        host_info(&cfg, &fake, None, true).unwrap();
1148
1149        assert_eq!(fake.scripts().len(), 2);
1150    }
1151
1152    #[test]
1153    fn poll_json_is_typed_like_the_other_surfaces() {
1154        // Hand-rolled concatenation would stay valid for these fields and then
1155        // regress the moment a string needed escaping. serde is the contract
1156        // the other emitters already use.
1157        assert_eq!(
1158            poll_json(&State::Running, 12),
1159            r#"{"state":"running","rc":null,"log_size":12}"#
1160        );
1161        assert_eq!(
1162            poll_json(&State::Done(5), 0),
1163            r#"{"state":"done","rc":5,"log_size":0}"#
1164        );
1165        assert_eq!(
1166            poll_json(&State::Orphan, 99),
1167            r#"{"state":"orphan","rc":null,"log_size":99}"#
1168        );
1169    }
1170
1171    #[test]
1172    fn command_arguments_are_shell_quoted_without_changing_shell_strings() {
1173        assert_eq!(
1174            command_from_args(&["printf '[%s]' 'a b' c; echo".into()]),
1175            "printf '[%s]' 'a b' c; echo"
1176        );
1177        assert_eq!(
1178            command_from_args(&[
1179                "printf".into(),
1180                "[%s]".into(),
1181                "a b".into(),
1182                "".into(),
1183                "it's".into(),
1184            ]),
1185            "'printf' '[%s]' 'a b' '' 'it'\\''s'"
1186        );
1187    }
1188
1189    #[test]
1190    fn display_command_truncates_on_character_boundaries() {
1191        const LIMIT: usize = 80;
1192        let exact = "é".repeat(LIMIT);
1193        let over = "é".repeat(LIMIT + 1);
1194
1195        assert_eq!(display_command(&exact), exact);
1196        assert_eq!(
1197            display_command(&over),
1198            format!("{}…", "é".repeat(LIMIT - 1))
1199        );
1200    }
1201
1202    #[test]
1203    fn display_command_collapses_whitespace() {
1204        assert_eq!(display_command("one\n\ttwo   three"), "one two three");
1205        assert_eq!(display_command(""), "");
1206    }
1207
1208    /// `--full` exists so a person can read a long command without JSON.
1209    ///
1210    /// The table truncates at 80 characters so one job stays one scannable
1211    /// row, which is right for scanning and wrong for "what did this actually
1212    /// run". Before this, the only way to recover the text was `--json`, so a
1213    /// human debugging their own command had to pipe coop through a parser.
1214    ///
1215    /// Both paths still collapse whitespace: an embedded newline would split
1216    /// one job across rows that look like separate jobs. `--full` keeps every
1217    /// character, it does not keep the layout.
1218    #[test]
1219    fn full_keeps_the_whole_command_while_the_default_truncates() {
1220        let long = format!("echo {}", "x".repeat(120));
1221
1222        let truncated = display_command(&long);
1223        assert!(truncated.ends_with('\u{2026}'), "{truncated:?}");
1224        assert_eq!(truncated.chars().count(), 80);
1225
1226        let complete = collapse_whitespace(&long);
1227        assert_eq!(complete, long, "--full must not drop anything");
1228        assert!(!complete.contains('\u{2026}'));
1229
1230        // Whitespace still collapses on the full path.
1231        assert_eq!(collapse_whitespace("a\n\tb  c"), "a b c");
1232    }
1233}