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 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 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 #[arg(long, global = true, value_name = "PATH")]
127 pub config: Option<std::path::PathBuf>,
128
129 #[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 #[arg(long, value_name = "H")]
141 pub host: Option<String>,
142}
143
144#[derive(Subcommand, Debug)]
145pub enum Commands {
146 Run {
148 #[command(flatten)]
149 host: HostArg,
150 #[arg(long, value_name = "D")]
152 cwd: Option<String>,
153 #[arg(long, value_name = "S")]
155 max_secs: Option<u64>,
156 #[arg(long)]
158 wait: bool,
159 #[arg(long, requires = "wait")]
161 no_tail: bool,
162 #[arg(trailing_var_arg = true, required = true)]
183 cmd: Vec<String>,
184 },
185 Poll {
191 id: crate::wrapper::JobId,
192 #[command(flatten)]
193 host: HostArg,
194 #[arg(long)]
195 json: bool,
196 },
197 Wait {
203 id: crate::wrapper::JobId,
204 #[command(flatten)]
205 host: HostArg,
206 #[arg(long, value_name = "S")]
208 timeout: Option<u64>,
209 },
210 Tail {
218 id: crate::wrapper::JobId,
220 #[command(flatten)]
221 host: HostArg,
222 #[arg(short, long)]
224 follow: bool,
225 #[arg(long, conflicts_with_all = ["lines", "follow"])]
227 all: bool,
228 #[arg(short = 'n', value_name = "LINES", conflicts_with_all = ["all", "follow"])]
230 lines: Option<u64>,
231 },
232 Ls {
238 #[command(flatten)]
239 host: HostArg,
240 #[arg(long)]
242 all: bool,
243 #[arg(long)]
245 json: bool,
246 #[arg(long, conflicts_with = "json")]
252 full: bool,
253 },
254 Kill {
256 id: crate::wrapper::JobId,
257 #[command(flatten)]
258 host: HostArg,
259 #[arg(long)]
265 rm: bool,
266 },
267 Rm {
269 id: Option<crate::wrapper::JobId>,
271 #[arg(long, conflicts_with = "id")]
277 all: bool,
278 #[command(flatten)]
279 host: HostArg,
280 },
281 #[command(subcommand)]
283 Host(HostCmd),
284}
285
286#[derive(Subcommand, Debug)]
287pub enum HostCmd {
288 List {
290 #[arg(long)]
291 json: bool,
292 },
293 Info {
295 #[arg(long, value_name = "H")]
297 host: Option<String>,
298 #[arg(long)]
300 json: bool,
301 },
302}
303
304pub fn load_config(path: Option<&std::path::Path>) -> Result<Config> {
305 if let Some(p) = path {
308 return Config::load(p);
309 }
310
311 let default = crate::config::default_path()?;
312 if !default.exists() {
313 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
327pub 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 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 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
404fn 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
497fn 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
539fn 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 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 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 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 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 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 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 (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 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 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 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
1011fn 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
1026fn 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
1071fn 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
1100fn 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
1124fn 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 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 #[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 assert_eq!(collapse_whitespace("a\n\tb c"), "a b c");
1232 }
1233}