diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cd7e75e..7f3cd4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,11 @@ All notable changes to the Toolpath workspace are documented here. ## `path resume --remote` pushes a session to a remote host — 2026-08-17 `path resume --remote [-C ] -[--dry-run]` projects a local Claude Code session onto a remote host -and attaches to it under tmux. Re-running the same command reattaches -to the live tmux session. Every push is a fork; the local original is -never touched. +[--dry-run] [--overwrite]` projects a local Claude Code session onto +a remote host and attaches to it under tmux. Re-running the same +command reattaches to the live tmux session, or relaunches the +session from the existing remote file when tmux has exited. Every +push is a fork; the local original is never touched. - **`path-cli`** (0.19.0): - The local host does all toolpath work: it projects the @@ -29,6 +30,11 @@ never touched. re-pushing unchanged content targets the same remote file across invocations and input shapes. The tmux session is `path-` of the source session id. + - The ship step writes only when the target file is absent or + smaller than the local serialization (a partial earlier ship). An + existing file at full size or larger is kept with a notice and + the session launches on it, so remote-side progress survives a + re-push. `--overwrite` replaces the file unconditionally. - `-C` names the remote directory (absolute, physical; a symlinked value errors and names the physical path). The default is the local cwd with the local home prefix replaced by the remote diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 64f3dd28..ec5fc0ca 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -82,6 +82,13 @@ pub struct ResumeArgs { #[arg(long, requires = "remote")] pub dry_run: bool, + /// With --remote: replace an existing remote session file with + /// this push's content instead of keeping it. Without this flag + /// an existing file is kept (remote-side progress survives) and + /// the session launches on it. + #[arg(long, requires = "remote")] + pub overwrite: bool, + /// Skip the cache entirely when fetching from Pathbase: don't read /// an existing entry, don't write the fetched body. Useful for /// ephemeral environments where you don't want the cache to grow. @@ -655,6 +662,7 @@ mod tests { url: None, remote: None, dry_run: false, + overwrite: false, }; let recorder = RecordingExec::default(); @@ -793,6 +801,7 @@ mod tests { url: None, remote: None, dry_run: false, + overwrite: false, }; let (g, harness) = resolve_input(&args).unwrap(); let _path = ensure_path_with_agent(&g).unwrap(); @@ -829,6 +838,7 @@ mod tests { url: None, remote: None, dry_run: false, + overwrite: false, }; let (g, harness) = resolve_input(&args).unwrap(); let _ = ensure_path_with_agent(&g).unwrap(); @@ -892,6 +902,7 @@ mod tests { url: None, remote: None, dry_run: false, + overwrite: false, }; let result = resolve_input(&args); @@ -922,6 +933,7 @@ mod tests { url: None, remote: None, dry_run: false, + overwrite: false, }; let err = resolve_input(&args).unwrap_err(); let s = err.to_string(); diff --git a/crates/path-cli/src/cmd_resume/remote.rs b/crates/path-cli/src/cmd_resume/remote.rs index fbdd4ca7..a73a4659 100644 --- a/crates/path-cli/src/cmd_resume/remote.rs +++ b/crates/path-cli/src/cmd_resume/remote.rs @@ -162,7 +162,15 @@ pub fn run_remote( let slug_dir = path_to_string(&resolver.project_dir(&project_path)?)?; let target = path_to_string(&resolver.conversation_file(&project_path, &remote_id)?)?; - let ship_cmd = ship_command(&slug_dir, &target); + let mut conv = conv; + conv.set_session_id_and_cwd(&remote_id, &project_path); + let jsonl = crate::projection::serialize_jsonl(&conv)?; + + let ship_cmd = if args.overwrite { + ship_overwrite_command(&slug_dir, &target) + } else { + ship_command(&slug_dir, &target, jsonl.len()) + }; let launch_cmd = launch_command(&tmux_name, &project_path, &claude, &remote_id); if args.dry_run { @@ -180,11 +188,6 @@ pub fn run_remote( return Ok(()); } - let mut conv = conv; - conv.set_session_id_and_cwd(&remote_id, &project_path); - let jsonl = crate::projection::serialize_jsonl(&conv)?; - - eprintln!("Shipping session {remote_id} to {remote}:{target}"); let shipped = ssh.run(&ship_cmd, Some(jsonl.as_bytes()))?; if !shipped.status.success() { bail!( @@ -194,6 +197,22 @@ pub fn run_remote( String::from_utf8_lossy(&shipped.stderr).trim_end() ); } + if args.overwrite { + eprintln!("Shipped session {remote_id} to {remote}:{target} (--overwrite)"); + } else { + match parse_ship(&shipped)? { + ShipOutcome::Shipped => { + eprintln!("Shipped session {remote_id} to {remote}:{target}"); + } + ShipOutcome::Kept => { + eprintln!( + "Remote session file {target} on {remote} carries this push's \ + content or later progress; keeping it and launching without \ + a re-ship. Pass --overwrite to replace it." + ); + } + } + } eprintln!("Launching {tmux_name} in {project_path}"); let launched = ssh.run(&launch_cmd, None)?; @@ -407,7 +426,37 @@ fn validate_captured_path(value: &str, what: &str, output: &Output) -> Result String { +const TAG_SHIP: &str = "TP_SHIP"; +const SHIP_SHIPPED: &str = "shipped"; +const SHIP_KEPT: &str = "kept"; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum ShipOutcome { + Shipped, + Kept, +} + +/// Conditional ship: write only when the target is absent or smaller +/// than `local_len` bytes (a partial earlier ship). A target at full +/// size or larger keeps its bytes, so remote-side progress survives a +/// re-push. Both branches drain stdin and report one +/// `TP_SHIP=shipped` or `TP_SHIP=kept` line. +pub(crate) fn ship_command(slug_dir: &str, target: &str, local_len: usize) -> String { + format!( + "umask 077; if [ ! -e {t} ] || [ $(wc -c < {t}) -lt {local_len} ]; then \ + mkdir -p {d} && cat > {t} && printf '{tag}={shipped}\\n'; \ + else cat > /dev/null; printf '{tag}={kept}\\n'; fi", + d = sh_quote(slug_dir), + t = sh_quote(target), + tag = TAG_SHIP, + shipped = SHIP_SHIPPED, + kept = SHIP_KEPT, + ) +} + +/// Unconditional ship for `--overwrite`: replace the target with the +/// bytes on stdin. +pub(crate) fn ship_overwrite_command(slug_dir: &str, target: &str) -> String { format!( "umask 077; mkdir -p {} && cat > {}", sh_quote(slug_dir), @@ -415,6 +464,23 @@ pub(crate) fn ship_command(slug_dir: &str, target: &str) -> String { ) } +/// Parse the one `TP_SHIP=` line a conditional ship prints. Anything +/// else errors with the output verbatim. +pub(crate) fn parse_ship(output: &Output) -> Result { + let stdout = String::from_utf8_lossy(&output.stdout); + let line = stdout.trim(); + if line == format!("{TAG_SHIP}={SHIP_SHIPPED}") { + Ok(ShipOutcome::Shipped) + } else if line == format!("{TAG_SHIP}={SHIP_KEPT}") { + Ok(ShipOutcome::Kept) + } else { + bail!( + "unexpected ship output from the remote; output was:\n{}", + preflight_transcript(output) + ); + } +} + pub(crate) fn launch_command( tmux_name: &str, cwd: &str, @@ -794,8 +860,8 @@ mod tests { // ── remote command construction ────────────────────────────────── #[test] - fn ship_command_makes_dir_and_writes_under_umask() { - let cmd = ship_command( + fn ship_overwrite_command_makes_dir_and_writes_under_umask() { + let cmd = ship_overwrite_command( "/home/e/.claude/projects/-home-e-proj", "/home/e/.claude/projects/-home-e-proj/abc.jsonl", ); @@ -814,6 +880,70 @@ mod tests { ); } + #[test] + fn ship_command_guards_the_write_and_quotes_paths() { + let cmd = ship_command("/d/it's", "/d/it's/abc.jsonl", 1234); + assert!(cmd.starts_with("umask 077;"), "cmd: {cmd}"); + assert!(cmd.contains("! -e '/d/it'\\''s/abc.jsonl'"), "cmd: {cmd}"); + assert!(cmd.contains("-lt 1234"), "cmd: {cmd}"); + assert!(cmd.contains("TP_SHIP=shipped"), "cmd: {cmd}"); + assert!(cmd.contains("TP_SHIP=kept"), "cmd: {cmd}"); + } + + #[test] + fn ship_command_semantics_through_sh() { + use std::io::Write; + let dir = tempfile::tempdir().unwrap(); + let slug = dir.path().join("slug"); + let target = slug.join("s.jsonl"); + let local: &[u8] = b"line1\nline2\n"; + let cmd = ship_command( + slug.to_str().unwrap(), + target.to_str().unwrap(), + local.len(), + ); + + let run = |input: &[u8]| { + let mut child = std::process::Command::new("sh") + .arg("-c") + .arg(&cmd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.take().unwrap().write_all(input).unwrap(); + child.wait_with_output().unwrap() + }; + + // Absent target: shipped. + let out = run(local); + assert_eq!(parse_ship(&out).unwrap(), ShipOutcome::Shipped); + assert_eq!(std::fs::read(&target).unwrap(), local); + + // Target grown past the local length: kept, bytes intact. + let mut f = std::fs::OpenOptions::new() + .append(true) + .open(&target) + .unwrap(); + f.write_all(b"line3\n").unwrap(); + drop(f); + let out = run(local); + assert_eq!(parse_ship(&out).unwrap(), ShipOutcome::Kept); + assert_eq!(std::fs::read(&target).unwrap(), b"line1\nline2\nline3\n"); + + // Truncated target (a partial earlier ship): replaced. + std::fs::write(&target, b"line1\n").unwrap(); + let out = run(local); + assert_eq!(parse_ship(&out).unwrap(), ShipOutcome::Shipped); + assert_eq!(std::fs::read(&target).unwrap(), local); + } + + #[test] + fn parse_ship_rejects_unexpected_output() { + let err = parse_ship(&output_with_stdout("cat: disk full\n")).unwrap_err(); + assert!(err.to_string().contains("disk full"), "actual: {err}"); + } + #[test] fn launch_command_nests_quoting_for_tmux() { let cmd = launch_command("path-abcd1234", "/home/e/proj", "/usr/bin/claude", "id-1"); diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index 3acbc83e..205bdd29 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -264,6 +264,7 @@ fn cache_id_input_loads_and_projects() { harness: Some(Harness::Claude), remote: None, dry_run: false, + overwrite: false, no_cache: false, force: false, url: None, diff --git a/crates/path-cli/tests/resume_remote.rs b/crates/path-cli/tests/resume_remote.rs index d62abe9e..2f064dc9 100644 --- a/crates/path-cli/tests/resume_remote.rs +++ b/crates/path-cli/tests/resume_remote.rs @@ -98,6 +98,7 @@ fn same_document_mints_the_same_id_across_serializations() { let home = TestHome::new(); let shim = SshShim::new(); shim.respond(0, &preflight_ok(PROJECT, "none")); + shim.respond(1, "TP_SHIP=shipped\n"); let recorder = RecordingExec::default(); run_remote( &args_remote(doc.to_str().unwrap(), REMOTE, Some(PROJECT), false), @@ -125,6 +126,7 @@ fn fresh_push_ships_launches_and_attaches() { let doc_file = claude_doc(docs.path()); let shim = SshShim::new(); shim.respond(0, &preflight_ok(PROJECT, "none")); + shim.respond(1, "TP_SHIP=shipped\n"); let recorder = RecordingExec::default(); run_remote( @@ -154,17 +156,29 @@ fn fresh_push_ships_launches_and_attaches() { let resolver = toolpath_claude::PathResolver::new().with_home(REMOTE_HOME); let slug_dir = resolver.project_dir(PROJECT).unwrap(); let target = resolver.conversation_file(PROJECT, &remote_id).unwrap(); + let expected = expected_shipped_jsonl(&doc_file, &remote_id, PROJECT); let ship = shim.argv(1); assert_eq!(ship[0], REMOTE); - assert_eq!( - ship[1], - format!( - "umask 077; mkdir -p '{}' && cat > '{}'", + assert!(ship[1].starts_with("umask 077;"), "ship: {}", ship[1]); + assert!( + ship[1].contains(&format!("! -e '{}'", target.display())), + "ship: {}", + ship[1] + ); + assert!( + ship[1].contains(&format!("-lt {}", expected.len())), + "ship: {}", + ship[1] + ); + assert!( + ship[1].contains(&format!( + "mkdir -p '{}' && cat > '{}'", slug_dir.display(), target.display() - ) + )), + "ship: {}", + ship[1] ); - let expected = expected_shipped_jsonl(&doc_file, &remote_id, PROJECT); assert_eq!( String::from_utf8(shim.stdin_bytes(1)).unwrap(), expected, @@ -223,6 +237,102 @@ fn live_session_reattaches_without_ship_or_launch() { assert!(cap.args[2].contains("attach-session")); } +// ── Existing remote session file ──────────────────────────────────── + +#[test] +fn existing_remote_file_is_kept_and_launched() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + let shim = SshShim::new(); + shim.respond(0, &preflight_ok(PROJECT, "none")); + shim.respond(1, "TP_SHIP=kept\n"); + + let recorder = RecordingExec::default(); + run_remote( + &args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false), + &recorder, + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap(); + + assert_eq!(shim.calls(), 3, "preflight, conditional ship, launch"); + assert!(shim.argv(2)[1].contains("tmux new-session")); + let cap = recorder.captured(); + assert!(cap.args[2].contains("attach-session")); +} + +#[test] +fn overwrite_ships_unconditionally() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + let shim = SshShim::new(); + shim.respond(0, &preflight_ok(PROJECT, "none")); + + let mut args = args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false); + args.overwrite = true; + let recorder = RecordingExec::default(); + run_remote( + &args, + &recorder, + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap(); + + assert_eq!(shim.calls(), 3, "preflight, ship, launch"); + let raw_json = std::fs::read_to_string(&doc_file).unwrap(); + let graph = toolpath::v1::Graph::from_json(&raw_json).unwrap(); + let canonical = serde_json::to_string(&serde_json::to_value(&graph).unwrap()).unwrap(); + let remote_id = mint_remote_id(&canonical); + let resolver = toolpath_claude::PathResolver::new().with_home(REMOTE_HOME); + let slug_dir = resolver.project_dir(PROJECT).unwrap(); + let target = resolver.conversation_file(PROJECT, &remote_id).unwrap(); + assert_eq!( + shim.argv(1)[1], + format!( + "umask 077; mkdir -p '{}' && cat > '{}'", + slug_dir.display(), + target.display() + ) + ); + assert_eq!( + String::from_utf8(shim.stdin_bytes(1)).unwrap(), + expected_shipped_jsonl(&doc_file, &remote_id, PROJECT) + ); +} + +#[test] +fn unexpected_ship_output_errors_without_launch() { + let home = TestHome::new(); + let docs = tempfile::tempdir().unwrap(); + let doc_file = claude_doc(docs.path()); + let shim = SshShim::new(); + shim.respond(0, &preflight_ok(PROJECT, "none")); + shim.respond(1, "cat: write error\n"); + + let recorder = RecordingExec::default(); + let err = run_remote( + &args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false), + &recorder, + Some(&home.home_dir()), + &home.home_dir(), + &shim.search_path(), + true, + ) + .unwrap_err(); + + assert!(err.to_string().contains("ship output"), "actual: {err}"); + assert_eq!(shim.calls(), 2, "preflight and ship only; no launch"); + assert!(recorder.captured().binary.is_empty(), "no attach"); +} + // ── Dry run ───────────────────────────────────────────────────────── #[test] @@ -530,6 +640,7 @@ fn re_running_the_same_push_mints_the_same_id_and_target() { for _ in 0..2 { let shim = SshShim::new(); shim.respond(0, &preflight_ok(PROJECT, "none")); + shim.respond(1, "TP_SHIP=shipped\n"); run_remote( &args_remote(doc_file.to_str().unwrap(), REMOTE, Some(PROJECT), false), &RecordingExec::default(), diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs index 681fb004..aa010a2c 100644 --- a/crates/path-cli/tests/support/mod.rs +++ b/crates/path-cli/tests/support/mod.rs @@ -186,6 +186,7 @@ pub fn args_explicit(input: PathBuf, cwd: &Path, harness: Harness) -> ResumeArgs harness: Some(harness), remote: None, dry_run: false, + overwrite: false, no_cache: false, force: false, url: None, @@ -290,6 +291,7 @@ pub fn args_remote(input: &str, remote: &str, cwd: Option<&str>, dry_run: bool) harness: None, remote: Some(remote.to_string()), dry_run, + overwrite: false, no_cache: false, force: false, url: None,