1use 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 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 #[arg(long, global = true, value_name = "PATH")]
129 pub config: Option<std::path::PathBuf>,
130
131 #[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 #[arg(long, value_name = "H")]
148 pub host: Option<String>,
149}
150
151#[derive(Subcommand, Debug)]
152pub enum Commands {
153 Run {
155 #[command(flatten)]
156 host: HostArg,
157 #[arg(long, value_name = "D")]
159 cwd: Option<String>,
160 #[arg(long, value_name = "S")]
162 max_secs: Option<u64>,
163 #[arg(long)]
165 wait: bool,
166 #[arg(long, requires = "wait")]
168 no_tail: bool,
169 #[arg(trailing_var_arg = true, required = true)]
190 cmd: Vec<String>,
191 },
192 Poll {
198 id: crate::wrapper::JobId,
199 #[command(flatten)]
200 host: HostArg,
201 #[arg(long)]
202 json: bool,
203 },
204 Wait {
210 id: crate::wrapper::JobId,
211 #[command(flatten)]
212 host: HostArg,
213 #[arg(long, value_name = "S")]
215 timeout: Option<u64>,
216 },
217 Tail {
225 id: crate::wrapper::JobId,
227 #[command(flatten)]
228 host: HostArg,
229 #[arg(short, long)]
231 follow: bool,
232 #[arg(long, conflicts_with_all = ["lines", "follow"])]
234 all: bool,
235 #[arg(short = 'n', value_name = "LINES", conflicts_with_all = ["all", "follow"])]
237 lines: Option<u64>,
238 },
239 Ls {
245 #[command(flatten)]
246 host: HostArg,
247 #[arg(long)]
249 all: bool,
250 #[arg(long)]
252 json: bool,
253 #[arg(long, conflicts_with = "json")]
259 full: bool,
260 },
261 Kill {
263 id: crate::wrapper::JobId,
264 #[command(flatten)]
265 host: HostArg,
266 #[arg(long)]
272 rm: bool,
273 },
274 Rm {
276 id: Option<crate::wrapper::JobId>,
278 #[arg(long, conflicts_with = "id")]
284 all: bool,
285 #[command(flatten)]
286 host: HostArg,
287 },
288 #[command(subcommand)]
290 Host(HostCmd),
291}
292
293#[derive(Subcommand, Debug)]
294pub enum HostCmd {
295 List {
297 #[arg(long)]
298 json: bool,
299 },
300 Info {
302 #[arg(long, value_name = "H")]
304 host: Option<String>,
305 #[arg(long)]
307 json: bool,
308 },
309}
310
311pub fn load_config(path: Option<&std::path::Path>) -> Result<Config> {
312 if let Some(p) = path {
315 return Config::load(p);
316 }
317
318 let default = crate::config::default_path()?;
319 if !default.exists() {
320 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
334pub 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 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 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
411fn 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
504fn 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
546fn 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 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 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_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 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 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 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 (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 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 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 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
1035fn 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
1050fn 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
1095fn 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
1124fn 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
1148fn 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 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 #[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 assert_eq!(collapse_whitespace("a\n\tb c"), "a b c");
1256 }
1257}