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