From 06c1d81190ccc983315e25f5cb4c91334c382933 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 6 Jun 2026 12:26:58 +0200 Subject: [PATCH] feat: switch from tmux to simple nohup for remote jobs - Remove tmux-based execution; jobs now run via nohup + shell script - Remove p-agent daemon from workspace - Remove `p attach` command (use `p logs -f` instead) - Add `-d/--detach` flag to start job without streaming - Stream output via SSH tail -F - Ctrl+C detaches from stream; job keeps running - `p stop` kills via PID file - Worker requirements reduced to just rsync (no tmux needed) --- Cargo.lock | 21 +-- Cargo.toml | 2 +- agent/Cargo.toml | 11 -- agent/src/main.rs | 4 - p/Cargo.toml | 4 +- p/src/cli.rs | 8 +- p/src/commands/attach.rs | 97 -------------- p/src/commands/logs.rs | 52 ++++++- p/src/commands/mod.rs | 1 - p/src/commands/rm.rs | 14 +- p/src/commands/run.rs | 216 ++++++++---------------------- p/src/commands/stop.rs | 20 ++- p/src/commands/worker/register.rs | 35 ++--- p/src/main.rs | 1 - p/src/ssh.rs | 41 +----- 15 files changed, 151 insertions(+), 376 deletions(-) delete mode 100644 agent/Cargo.toml delete mode 100644 agent/src/main.rs delete mode 100644 p/src/commands/attach.rs diff --git a/Cargo.lock b/Cargo.lock index aa26bc1..ecb1ad3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,12 +73,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bitflags" version = "2.11.1" @@ -476,7 +470,6 @@ name = "p" version = "0.1.0" dependencies = [ "anyhow", - "base64", "chrono", "clap", "ctrlc", @@ -484,16 +477,10 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "shell-words", "uuid", ] -[[package]] -name = "p-agent" -version = "0.1.0" -dependencies = [ - "anyhow", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -619,6 +606,12 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 19bc466..1c77807 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["p", "agent"] +members = ["p"] resolver = "2" diff --git a/agent/Cargo.toml b/agent/Cargo.toml deleted file mode 100644 index e6b3a96..0000000 --- a/agent/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "p-agent" -version = "0.1.0" -edition = "2021" - -[[bin]] -name = "p-agent" -path = "src/main.rs" - -[dependencies] -anyhow = "1" diff --git a/agent/src/main.rs b/agent/src/main.rs deleted file mode 100644 index 0391491..0000000 --- a/agent/src/main.rs +++ /dev/null @@ -1,4 +0,0 @@ -fn main() { - println!("p-agent — worker-side agent for p"); - println!("(not yet implemented)"); -} diff --git a/p/Cargo.toml b/p/Cargo.toml index d8787e7..e7ecebd 100644 --- a/p/Cargo.toml +++ b/p/Cargo.toml @@ -22,7 +22,7 @@ dirs = "5" uuid = { version = "1", features = ["v4"] } # Timestamps and duration display chrono = "0.4" -# Encoding commands passed through SSH → tmux → bash -base64 = "0.22" +# Shell escaping for commands +shell-words = "1" # Ctrl+C handling (detach message) ctrlc = "3" diff --git a/p/src/cli.rs b/p/src/cli.rs index bf09503..9d23eed 100644 --- a/p/src/cli.rs +++ b/p/src/cli.rs @@ -5,7 +5,7 @@ use clap::{Parser, Subcommand}; name = "p", about = "Push jobs to remote worker machines", long_about = "\ -Push jobs to remote worker machines with directory sync and attach/detach support. +Push jobs to remote worker machines with directory sync and log streaming. Run a command on the default worker: p -- @@ -33,12 +33,6 @@ pub enum Command { all: bool, }, - /// Re-attach to the tmux session of a running job - Attach { - /// Job ID or unambiguous prefix - job_id: String, - }, - /// Print captured output of a job (running or finished) Logs { /// Job ID or unambiguous prefix diff --git a/p/src/commands/attach.rs b/p/src/commands/attach.rs deleted file mode 100644 index 8dc94b5..0000000 --- a/p/src/commands/attach.rs +++ /dev/null @@ -1,97 +0,0 @@ -use anyhow::{Context, Result}; -use chrono::Utc; - -use crate::{ - config, db, - job::{Job, JobStatus}, - ssh, -}; - -pub fn execute(job_id: &str) -> Result<()> { - let cfg = config::load()?; - let job = db::find(job_id)?.with_context(|| format!("job '{}' not found", job_id))?; - - // Allow attach on Running (normal) and Unknown (worker was temporarily - // unreachable — the job may still be live). - match job.status { - JobStatus::Running | JobStatus::Unknown => {} - _ => anyhow::bail!( - "job {} is not running (status: {})\n\ - use 'p logs {}' to view its output", - job.short_id(), - job.status.as_str(), - job.short_id(), - ), - } - - let worker = cfg.resolve_worker(Some(&job.worker))?.clone(); - let session = format!("p-{}", job.short_id()); - let sid = job.short_id().to_string(); - - { - let sid = sid.clone(); - ctrlc::set_handler(move || { - eprintln!( - "\nDetached. Use 'p attach {sid}' to re-attach or 'p logs {sid}' to view output." - ); - std::process::exit(0); - }) - .ok(); - } - - let attach_status = ssh::run_interactive(&worker, &format!("tmux attach -t '{}'", session))?; - - // If tmux exited non-zero the session doesn't exist on the worker. - if !attach_status.success() { - let exit_code = ssh::read_job_exitcode(&worker, &job.id); - if exit_code.is_none() { - // No exit code either — the job was lost without completing - // (e.g. worker restarted, tmux server killed). - eprintln!( - "error: tmux session for job {} no longer exists on '{}'.", - sid, worker.name - ); - eprintln!("The job was likely interrupted (worker restart or tmux server exit)."); - eprintln!("Use 'p logs {}' to see whatever output was captured.", sid); - db::save(&Job { - status: JobStatus::Unknown, - ..job - })?; - return Ok(()); - } - // Exit code exists — job finished cleanly but the session was already - // cleaned up. Fall through to normal reconciliation. - } - - let exit_code = ssh::read_job_exitcode(&worker, &job.id); - let finished_at = - ssh::read_job_finished_at(&worker, &job.id).unwrap_or_else(|| Utc::now().timestamp()); - - if let Some(ec) = exit_code { - ssh::run_capture( - &worker, - &format!("tmux kill-session -t '{}' 2>/dev/null || true", session), - ) - .ok(); - - db::save(&Job { - status: if ec == 0 { - JobStatus::Done - } else { - JobStatus::Failed - }, - exit_code: Some(ec), - finished_at: Some(finished_at), - ..job - })?; - - eprintln!("Job {} finished with exit code {}.", sid, ec); - } else { - eprintln!( - "Detached from job {}. Use 'p attach {}' to re-attach.", - sid, sid - ); - } - - Ok(()) -} diff --git a/p/src/commands/logs.rs b/p/src/commands/logs.rs index a56e886..bea4773 100644 --- a/p/src/commands/logs.rs +++ b/p/src/commands/logs.rs @@ -1,6 +1,11 @@ use anyhow::{Context, Result}; +use chrono::Utc; -use crate::{config, db, job::JobStatus, ssh}; +use crate::{ + config, db, + job::{Job, JobStatus}, + ssh, +}; pub fn execute(job_id: &str, follow: bool) -> Result<()> { let cfg = config::load()?; @@ -19,10 +24,12 @@ pub fn execute(job_id: &str, follow: bool) -> Result<()> { job.short_id() ); print_log(worker, &log)?; + reconcile_and_print_exitcode(worker, &job)?; } else { // Stream live output and exit automatically when the job finishes. // - // The script backgrounds `tail -f` (so its stdout still flows to the + // Use -F (--follow=name) to handle the file not existing yet. + // The script backgrounds `tail -F` (so its stdout still flows to the // client through the SSH pipe), then polls for the exitcode file that // run.sh writes the moment the job exits. A 1-second sleep after // detecting the exitcode file gives tail time to drain any bytes that @@ -33,7 +40,7 @@ pub fn execute(job_id: &str, follow: bool) -> Result<()> { // shell, terminating the whole script including the tail background job. let exitcode_path = format!("~/.p/jobs/{}/exitcode", job.id); let follow_cmd = format!( - "tail -n +1 -f {log} & \ + "tail -n +1 -F {log} & \ TAIL_PID=$!; \ while ! [ -f {exitcode} ]; do sleep 1; done; \ sleep 1; \ @@ -43,9 +50,16 @@ pub fn execute(job_id: &str, follow: bool) -> Result<()> { exitcode = exitcode_path, ); ssh::run_output(worker, &follow_cmd)?; + + // Reconcile state after streaming ends + reconcile_and_print_exitcode(worker, &job)?; } } else { print_log(worker, &log)?; + if job.status == JobStatus::Running { + // Check if job has finished + reconcile_and_print_exitcode(worker, &job)?; + } } Ok(()) @@ -59,3 +73,35 @@ fn print_log(worker: &crate::config::WorkerConfig, log: &str) -> Result<()> { print!("{}", out); Ok(()) } + +/// Check if the job has finished and update local state. Print exit code if done. +fn reconcile_and_print_exitcode(worker: &crate::config::WorkerConfig, job: &Job) -> Result<()> { + if job.status != JobStatus::Running { + // Already finished + if let Some(ec) = job.exit_code { + eprintln!("[Job done: exit {}]", ec); + } + return Ok(()); + } + + let exit_code = ssh::read_job_exitcode(worker, &job.id); + if let Some(ec) = exit_code { + let finished_at = + ssh::read_job_finished_at(worker, &job.id).unwrap_or_else(|| Utc::now().timestamp()); + + db::save(&Job { + status: if ec == 0 { + JobStatus::Done + } else { + JobStatus::Failed + }, + exit_code: Some(ec), + finished_at: Some(finished_at), + ..job.clone() + })?; + + eprintln!("[Job done: exit {}]", ec); + } + + Ok(()) +} diff --git a/p/src/commands/mod.rs b/p/src/commands/mod.rs index 09f498a..be749d4 100644 --- a/p/src/commands/mod.rs +++ b/p/src/commands/mod.rs @@ -1,4 +1,3 @@ -pub mod attach; pub mod logs; pub mod ls; pub mod prune; diff --git a/p/src/commands/rm.rs b/p/src/commands/rm.rs index 4be2bb8..fc9f551 100644 --- a/p/src/commands/rm.rs +++ b/p/src/commands/rm.rs @@ -16,14 +16,14 @@ pub fn execute(job_id: &str, force: bool) -> Result<()> { sid ); } - // Kill the tmux session before wiping the files. - let session = format!("p-{}", sid); + // Kill the job via PID before wiping the files. let worker = cfg.resolve_worker(Some(&job.worker))?; - ssh::run_capture( - worker, - &format!("tmux kill-session -t '{}' 2>/dev/null || true", session), - ) - .ok(); + let pid_path = format!("~/.p/jobs/{}/pid", job.id); + let kill_cmd = format!( + "PID=$(cat {} 2>/dev/null) && kill \"$PID\" 2>/dev/null", + pid_path + ); + ssh::run_capture(worker, &kill_cmd).ok(); } // Remove remote job dir and work dir (best-effort — warn if unreachable). diff --git a/p/src/commands/run.rs b/p/src/commands/run.rs index ba98eb8..f3b7e71 100644 --- a/p/src/commands/run.rs +++ b/p/src/commands/run.rs @@ -1,5 +1,4 @@ use anyhow::{Context, Result}; -use base64::{engine::general_purpose::STANDARD as B64, Engine}; use chrono::Utc; use std::io::IsTerminal; use uuid::Uuid; @@ -25,7 +24,6 @@ pub fn execute( let id = Uuid::new_v4().to_string(); let short_id = &id[..8]; - let session = format!("p-{}", short_id); // Resolve the remote home directory once using an absolute path so that // every subsequent call — SSH, rsync, and scripts on the worker — all @@ -78,27 +76,31 @@ pub fn execute( }; db::save(&job)?; - // ── 4. Write files and start the job on the worker ──────────────────────── - - let cmd_b64 = B64.encode(&cmd_str); - let run_sh = build_run_sh(&session, &job_dir, &work_dir, &worker.name); - let run_sh_b64 = B64.encode(&run_sh); + // ── 4. Write metadata and start the job on the worker ───────────────────── + // Write cmd, started_at, and the run script + let run_sh = build_run_sh(&job_dir, &work_dir, &cmd_str); let setup = format!( - "printf '%s' '{cmd_b64}' > {job_dir}/cmd && \ - printf '%s' '{run_sh_b64}' | base64 -d > {job_dir}/run.sh && \ - chmod +x {job_dir}/run.sh && \ + "printf '%s' {} > {job_dir}/cmd && \ date +%s > {job_dir}/started_at && \ - tmux new-session -d -s '{session}' {job_dir}/run.sh", - job_dir = job_dir, - cmd_b64 = cmd_b64, - run_sh_b64 = run_sh_b64, - session = session, + touch {job_dir}/output.log && \ + printf '%s' {} > {job_dir}/run.sh && \ + chmod +x {job_dir}/run.sh", + shell_words::quote(&cmd_str), + shell_words::quote(&run_sh), + job_dir = job_dir ); + ssh::run_capture(&worker, &setup).context("failed to write job metadata")?; - ssh::run_capture(&worker, &setup).context("failed to set up job on worker")?; + // Start the run script with nohup + // The PID is saved to a file for later termination via `p stop` + let launch_cmd = format!( + "cd {} && nohup {}/run.sh & echo $! > {}/pid", + work_dir, job_dir, job_dir + ); + ssh::run_capture(&worker, &launch_cmd).context("failed to start job on worker")?; - // ── 4. Attach or detach ─────────────────────────────────────────────────────── + // ── 5. Detach or stream output ──────────────────────────────────────────── if detach { // Non-interactive mode: print only the job UUID to stdout and exit. @@ -108,41 +110,43 @@ pub fn execute( return Ok(()); } - // Interactive mode: attach to the tmux session. - // run.sh keeps the session alive after the job finishes (via `read`), - // so there is no race between job completion and our attach call. - // - // Ctrl+B D detaches cleanly mid-run; the job keeps going in the background. - // Any other key on the "press any key" screen triggers `tmux detach-client` - // from within run.sh — no [exited] or [detached] flash. + // Interactive mode: stream output.log in real-time. + // Ctrl+C detaches from the stream — the job keeps running. let sid = short_id.to_string(); ctrlc::set_handler(move || { eprintln!( - "\nDetached. Use 'p attach {sid}' to re-attach or 'p logs {sid}' to view output." + "\nDetached. Use 'p logs -f {}' to resume watching or 'p stop {}' to kill.", + sid, sid ); std::process::exit(0); }) .ok(); eprintln!("Job {} started on {}.", short_id, worker.name); - ssh::run_interactive(&worker, &format!("tmux attach -t '{}'", session))?; - // ── 5. Reconcile status after returning ─────────────────────────────────── + // Stream the log file, exiting when the job finishes + // Use -F (--follow=name) to handle the file not existing yet + let log_path = format!("{}/output.log", job_dir); + let exitcode_path = format!("{}/exitcode", job_dir); + let stream_cmd = format!( + "tail -n +1 -F {} & \ + TAIL_PID=$!; \ + while ! [ -f {} ]; do sleep 1; done; \ + sleep 0.5; \ + kill \"$TAIL_PID\" 2>/dev/null; \ + wait \"$TAIL_PID\" 2>/dev/null", + log_path, exitcode_path + ); + ssh::run_output(&worker, &stream_cmd)?; + + // ── 6. Reconcile status after returning ─────────────────────────────────── let exit_code = ssh::read_job_exitcode(&worker, &id); let finished_at = ssh::read_job_finished_at(&worker, &id).unwrap_or_else(|| Utc::now().timestamp()); if let Some(ec) = exit_code { - // Job is done. The session might still be alive if the user Ctrl+B D'd - // from the "press any key" screen — kill it to clean up the lingering `read`. - ssh::run_capture( - &worker, - &format!("tmux kill-session -t '{}' 2>/dev/null || true", session), - ) - .ok(); - db::save(&Job { status: if ec == 0 { JobStatus::Done @@ -154,145 +158,35 @@ pub fn execute( ..job })?; - eprintln!("Job {} finished with exit code {}.", short_id, ec); + eprintln!("[Job done: exit {}]", ec); } else { - // User detached mid-run (Ctrl+B D). Job is still running on the worker. + // Connection dropped or job lost db::save(&Job { - status: JobStatus::Running, + status: JobStatus::Unknown, ..job })?; eprintln!( - "Detached from job {}. Use 'p attach {}' to re-attach or 'p logs {}' to view output.", - short_id, short_id, short_id + "Connection lost. Job {} may still be running. Use 'p logs -f {}' to resume.", + short_id, short_id ); } Ok(()) } -/// Build the shell script that runs inside the tmux pane. -/// -/// `pub(crate)` so it can be tested. -pub(crate) fn build_run_sh(session: &str, job_dir: &str, work_dir: &str, worker: &str) -> String { - // Truncate worker name for display to avoid overflowing the status bar. - let worker_display = if worker.len() > 20 { - &worker[..20] - } else { - worker - }; - +/// Build the shell script that runs on the worker. +fn build_run_sh(job_dir: &str, work_dir: &str, cmd_str: &str) -> String { format!( - // Shebang + status bar setup - "#!/bin/bash\n\ - tmux set-option -t '{session}' status on 2>/dev/null\n\ - tmux set-option -t '{session}' status-style 'fg=colour250,bg=colour235' 2>/dev/null\n\ - tmux set-option -t '{session}' status-left-length 60 2>/dev/null\n\ - tmux set-option -t '{session}' status-right-length 20 2>/dev/null\n\ - tmux set-option -t '{session}' status-left \ - '#[fg=colour39,bold] {session} #[fg=colour250,nobold]| {worker_display} ' 2>/dev/null\n\ - tmux set-option -t '{session}' status-right \ - '#[fg=colour250] running ' 2>/dev/null\n\ - \n\ - # Run the command, tee output to log\n\ - cd {work_dir}\n\ - cmd=$(base64 -d < {job_dir}/cmd)\n\ - bash -c \"$cmd\" 2>&1 | tee {job_dir}/output.log\n\ - EXIT_CODE=${{PIPESTATUS[0]}}\n\ - echo \"$EXIT_CODE\" > {job_dir}/exitcode\n\ - date +%s > {job_dir}/finished_at\n\ - \n\ - # Update status bar to show result\n\ - if [ \"$EXIT_CODE\" -eq 0 ]; then\n\ - tmux set-option -t '{session}' status-right \ - '#[fg=colour2,bold] done [0] ' 2>/dev/null\n\ - else\n\ - tmux set-option -t '{session}' status-right \ - \"#[fg=colour1,bold] done [$EXIT_CODE] \" 2>/dev/null\n\ - fi\n\ - \n\ - # Keep the session open so the user can read final output\n\ - printf '\\n\\033[2m--- done [exit %d] - press any key to detach ---\\033[0m\\n' \ - \"$EXIT_CODE\"\n\ - read -rn 1 -s\n\ - \n\ - # Detach cleanly — no [exited] / [detached] flash\n\ - tmux detach-client -s '{session}' 2>/dev/null || true\n", - session = session, - worker_display = worker_display, - job_dir = job_dir, + r#"#!/bin/bash +cd {work_dir} +{cmd} 2>&1 | tee {job_dir}/output.log +EXIT_CODE=${{PIPESTATUS[0]}} +echo "$EXIT_CODE" > {job_dir}/exitcode +date +%s > {job_dir}/finished_at +"#, work_dir = work_dir, + cmd = cmd_str, + job_dir = job_dir ) } - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - fn sample_script() -> String { - build_run_sh( - "p-a3f2b091", - "/home/ubuntu/.p/jobs/a3f2b091-0000-0000-0000-000000000000", - "/home/ubuntu/.p/workdirs/a3f2b091-0000-0000-0000-000000000000", - "beefy", - ) - } - - #[test] - fn script_starts_with_shebang() { - assert!(sample_script().starts_with("#!/bin/bash\n")); - } - - #[test] - fn script_contains_session_name() { - assert!(sample_script().contains("p-a3f2b091")); - } - - #[test] - fn script_contains_job_and_work_dirs() { - let s = sample_script(); - assert!(s.contains("/home/ubuntu/.p/jobs/a3f2b091")); - assert!(s.contains("/home/ubuntu/.p/workdirs/a3f2b091")); - } - - #[test] - fn script_captures_exit_code_via_pipestatus() { - // Must use PIPESTATUS[0] to get the command's exit code, not tee's. - assert!(sample_script().contains("PIPESTATUS[0]")); - } - - #[test] - fn script_writes_exitcode_file() { - assert!(sample_script().contains("exitcode")); - } - - #[test] - fn script_tees_output_to_log() { - assert!(sample_script().contains("tee")); - assert!(sample_script().contains("output.log")); - } - - #[test] - fn script_waits_for_keypress_before_detach() { - let s = sample_script(); - assert!(s.contains("read -rn 1 -s")); - assert!(s.contains("tmux detach-client")); - } - - #[test] - fn script_sets_up_status_bar() { - let s = sample_script(); - assert!(s.contains("status on")); - assert!(s.contains("beefy")); - } - - #[test] - fn worker_name_truncated_at_20_chars() { - let s = build_run_sh("p-test", "/j", "/w", "a-very-long-worker-name-here"); - // "a-very-long-worker-name-here" is 28 chars; only first 20 should appear. - assert!(s.contains("a-very-long-worker-n")); - assert!(!s.contains("a-very-long-worker-name-here")); - } -} diff --git a/p/src/commands/stop.rs b/p/src/commands/stop.rs index 846a7af..abf25ec 100644 --- a/p/src/commands/stop.rs +++ b/p/src/commands/stop.rs @@ -20,14 +20,22 @@ pub fn execute(job_id: &str) -> Result<()> { } let worker = cfg.resolve_worker(Some(&job.worker))?; - let session = format!("p-{}", job.short_id()); let sid = job.short_id().to_string(); - // Kill the tmux session. This terminates the job process and run.sh. - ssh::run_capture( - worker, - &format!("tmux kill-session -t '{}' 2>/dev/null || true", session), - )?; + // Read the PID file and kill the process + // Use a subshell that always succeeds to avoid SSH errors + let kill_cmd = format!( + "PID=$(cat ~/.p/jobs/{}/pid 2>/dev/null); \ + if [ -n \"$PID\" ]; then \ + kill \"$PID\" 2>/dev/null; \ + sleep 1; \ + kill -9 \"$PID\" 2>/dev/null; \ + fi; \ + echo 15 > ~/.p/jobs/{}/exitcode; \ + date +%s > ~/.p/jobs/{}/finished_at", + job.id, job.id, job.id + ); + ssh::run_capture(worker, &kill_cmd)?; db::save(&Job { status: JobStatus::Stopped, diff --git a/p/src/commands/worker/register.rs b/p/src/commands/worker/register.rs index 1e7f110..69c4b4f 100644 --- a/p/src/commands/worker/register.rs +++ b/p/src/commands/worker/register.rs @@ -33,6 +33,17 @@ pub fn execute(connection: &str, name: Option<&str>) -> Result<()> { config::save(&cfg)?; + // Check that the worker is reachable + let worker = cfg.get_worker(&name).unwrap(); + print!("Checking worker connectivity... "); + if ssh::is_reachable(worker) { + println!("ok"); + } else { + println!(); + println!("note: could not reach '{}' (worker may be offline)", name); + println!(" run 'p worker ls --check' once it's available"); + } + if first { println!("Registered '{}' and set as default worker.", name); } else { @@ -40,29 +51,5 @@ pub fn execute(connection: &str, name: Option<&str>) -> Result<()> { println!("Run 'p worker default {}' to make it the default.", name); } - // Check that the worker has the tools p needs. - let worker = cfg.get_worker(&name).unwrap(); - print!("Checking worker dependencies... "); - match ssh::check_dependencies(worker) { - Ok(ref missing) if missing.is_empty() => println!("ok"), - Ok(missing) => { - println!(); - println!( - "warning: '{}' is missing required tools: {}", - name, - missing.join(", ") - ); - println!(" install them and re-register, or jobs will fail at launch"); - } - Err(_) => { - println!(); - println!( - "note: could not reach '{}' to check dependencies (worker may be offline)", - name - ); - println!(" run 'p worker ls --check' once it's available"); - } - } - Ok(()) } diff --git a/p/src/main.rs b/p/src/main.rs index a35618c..a69c71e 100644 --- a/p/src/main.rs +++ b/p/src/main.rs @@ -56,7 +56,6 @@ fn main() -> Result<()> { let cli = cli::Cli::parse(); match cli.command { cli::Command::Ls { all } => commands::ls::execute(all), - cli::Command::Attach { job_id } => commands::attach::execute(&job_id), cli::Command::Logs { job_id, follow } => commands::logs::execute(&job_id, follow), cli::Command::Stop { job_id } => commands::stop::execute(&job_id), cli::Command::Pull { diff --git a/p/src/ssh.rs b/p/src/ssh.rs index e51ffca..8c6ccbc 100644 --- a/p/src/ssh.rs +++ b/p/src/ssh.rs @@ -30,8 +30,8 @@ pub fn parse_connection(conn: &str) -> (String, Option) { /// Extract the bare hostname from a connection string (used as a default worker name). pub fn hostname_from_connection(conn: &str) -> String { let (user_host, _port) = parse_connection(conn); - match user_host.rsplit_once('@') { - Some((_user, host)) => host.to_string(), + match user_host.rfind('@') { + Some(i) => user_host[i + 1..].to_string(), None => user_host, } } @@ -74,26 +74,6 @@ fn run_with_timeout(worker: &WorkerConfig, remote_cmd: &str, timeout_secs: u32) Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } -/// Run a command over SSH with an interactive terminal (inherits stdin/stdout/stderr). -/// -/// Forces `TERM=xterm-256color` on the remote to avoid failures with terminal -/// emulators whose terminfo entry is not installed on the worker (e.g. ghostty, -/// kitty). The user's actual emulator capabilities are unaffected — only the -/// terminfo lookup on the remote side changes. -pub fn run_interactive( - worker: &WorkerConfig, - remote_cmd: &str, -) -> Result { - let mut args = vec!["-t".to_string()]; - args.extend(ssh_args(worker)); - args.push(format!("TERM=xterm-256color {}", remote_cmd)); - - Command::new("ssh") - .args(&args) - .status() - .context("failed to spawn ssh") -} - /// Run a remote command and stream its output to the local terminal via a plain /// SSH pipe (no PTY). Use this for output streaming where interactive terminal /// features are not needed. Ctrl+C on the client kills ssh and the remote process. @@ -147,7 +127,7 @@ pub fn is_reachable(worker: &WorkerConfig) -> bool { // ── Job-status helpers ──────────────────────────────────────────────────────── -/// Read the exit code written by run.sh on the worker, if the job has finished. +/// Read the exit code written by the job on the worker, if the job has finished. pub fn read_job_exitcode(worker: &WorkerConfig, job_id: &str) -> Option { let cmd = format!("cat ~/.p/jobs/{}/exitcode 2>/dev/null", job_id); run_capture(worker, &cmd) @@ -155,7 +135,7 @@ pub fn read_job_exitcode(worker: &WorkerConfig, job_id: &str) -> Option { .and_then(|s| s.trim().parse().ok()) } -/// Read the finish timestamp written by run.sh on the worker, if the job has finished. +/// Read the finish timestamp written by the job on the worker, if the job has finished. pub fn read_job_finished_at(worker: &WorkerConfig, job_id: &str) -> Option { let cmd = format!("cat ~/.p/jobs/{}/finished_at 2>/dev/null", job_id); run_capture(worker, &cmd) @@ -214,19 +194,6 @@ pub fn poll_jobs( Ok(map) } -/// Check that the required worker-side tools (tmux, base64) are installed. -/// Returns the names of any missing tools. Uses a 5-second timeout so -/// `p worker register` doesn't hang on offline workers. -pub fn check_dependencies(worker: &WorkerConfig) -> Result> { - let script = "missing=''; \ - command -v tmux >/dev/null 2>&1 || missing=\"$missing tmux\"; \ - command -v base64 >/dev/null 2>&1 || missing=\"$missing base64\"; \ - printf '%s' \"$missing\""; - - let out = run_with_timeout(worker, script, 5)?; - Ok(out.split_whitespace().map(|s| s.to_string()).collect()) -} - // ── Tests ───────────────────────────────────────────────────────────────────── #[cfg(test)]