diff --git a/docs/core/STATUS.md b/docs/core/STATUS.md index b924967..d928f9a 100644 --- a/docs/core/STATUS.md +++ b/docs/core/STATUS.md @@ -12,6 +12,8 @@ Codemux is past Linux MVP and shipping cross-platform binaries. The workspace sh Landed on `main` after the `v0.7.9` tag (unreleased) are three changes: **OpenCode conversation sync across cloud-push** (issue #16), a **project-favicon cache-bust fix**, and a **dev-only Tauri mock runtime**. (1) **OpenCode conversation sync** — the OpenCode counterpart to PR #15's Claude conversation sync. Pushing a workspace with an active OpenCode pane now continues that conversation on the remote (and pull-back continues it on the laptop), verified monotonic across 3+ cycles. Because OpenCode keeps every conversation in one SQLite DB (`~/.local/share/opencode/opencode.db`) that can't be rsynced without clobbering the host's other history, the sync uses OpenCode's **own `export`/`import` CLI** instead of a bespoke `rusqlite` row extractor (deliberately *not* the issue's original plan — the schema is large and fast-moving). `opencode export ` produces a portable bundle; `opencode import` on the receiving side preserves the session id, associates the session with the cwd it runs from, and is idempotent — so only the one synced session is touched and the host's unrelated OpenCode sessions are never clobbered (the explicit acceptance criterion). New module `src-tauri/src/ssh/opencode_db_sync.rs` (push `sync_opencode_session` + pull `pull_opencode_session`), wired into `commands/hosts.rs` alongside the Claude JSONL sync; `terminal/daemon_backed.rs` gained a generalized `build_agent_relaunch_command` that builds `opencode --session ` / `--continue` (the Claude-only relaunch synthesis is now per-agent), plus an `opencode` session adapter for local app-restart parity. Verified end-to-end against a Docker SSH host (`scripts/e2e/opencode-sync-e2e.sh` + env-gated `src-tauri/tests/opencode_sync_roundtrip.rs`): a session grew 4→644 messages across a host continuation, preserved over 3 push/pull cycles, with the unrelated host session held intact throughout. See `docs/features/opencode-conversation-sync.md`. (2) **Project-favicon cache-bust fix** (PR #71) — re-saving a project image (or re-opening the picker) now appends a changing `&v=` token to the derived favicon-service URL via `resolveImageUrl(input, cacheBust)`, so a site that changed its favicon visibly refreshes instead of the WebView serving the same stale cached bytes forever; direct/data URLs are passed through untouched. Touches `src/lib/project-image.ts`, `src/components/ui/project-avatar.tsx`, `src/components/layout/sidebar-project-group.tsx`, `src/components/overlays/project-image-dialog.tsx`. (3) **Dev-only Tauri mock runtime** (PR #82) — a `src/dev/` shim (`tauri-mock.ts` + `mock-fixtures.ts`, ~1k lines) installs a faithful `window.__TAURI_INTERNALS__` so the real React UI boots in a plain browser tab under `npm run dev` (Vite at `localhost:1420`) with seed data and no desktop window or Rust backend, enabling the Codemux browser-pane screenshot workflow for UI work. Dual-guarded in `main.tsx` (`import.meta.env.DEV` tree-shakes it out of production; `!("__TAURI_INTERNALS__" in window)` keeps it dormant under `npm run tauri:dev`). See `docs/features/dev-mock-runtime.md`. +Also landed on `main` after the `v0.7.9` tag (unreleased): **headless-daemon worktree provisioning parity** (issue #78). The daemon's `worktree_create` MCP tool now provisions a new worktree the same way the desktop does: gitignored include files (`.env` & co, `.codemuxinclude` → defaults) are copied from the parent repo synchronously before the tool returns, and the project's `.codemux/config.json` setup commands run on a background thread through a new UI-free `crate::scripts::run_setup_commands` core (shared with the desktop's `run_setup_scripts_with_config`, now a thin Tauri-event wrapper over it) with the full `CODEMUX_ROOT_PATH`/`CODEMUX_WORKSPACE_PATH`/`CODEMUX_BRANCH`/`CODEMUX_PORT` (+ NAME/ID) env and the same deterministic `allocate_workspace_port` port. The tool response gains a `setup` summary (`{port, includes_copied, setup_commands, setup_running}`); setup progress/failures log to the daemon's stderr, and a failing setup script never fails the tool call. Combined with the earlier daemon-side fetch-before-branch in `remote/git.rs` (issue #76 parity), remote-created branches start at the freshly-fetched `origin/` tip with graceful offline/local-only fallback. Verified by 3 new unit tests in `remote/tools/mod.rs`, a full-daemon HTTP integration test (`http_worktree_create_provisions_like_desktop` in `src-tauri/tests/codemux_remote_serve_mcp.rs`, stale-clone scenario), and a live Docker e2e against the real binary. See `docs/features/setup-teardown.md` § "Headless Daemon Parity", `docs/features/remote-hosts.md`. + Shipped in `v0.7.9` is **"Operate a remote workspace in place" (Open on host)** — the no-pull remote-operation capability (issue #64). The Workspaces overview's host-backed sibling row gains an **"Open on host"** action (enabled when the host is configured locally) that creates a local *attach-in-place* workspace: `WorkspaceSnapshot` gains `remote_cwd` (the workspace's real on-host directory) + `attach_only` (operated in place, no local files), `create_remote_attach_workspace` builds a ready single-terminal workspace with `host_id` set and **nothing copied under `~/.codemux/` locally**, and the daemon-backed terminal path (`remote_spawn_cwd`) spawns into `remote_cwd` over the existing SSH-tunneled pty-daemon so commands run on the host with live streaming. Persistence is real: `ssh::tunnel::build_remote_command` now **reuses a still-running daemon** (via a `.pid` liveness probe) or **spawns it detached** (`setsid`/`nohup`, stdio redirected) instead of `exec`-ing it in the SSH foreground, so closing the app leaves the host process running and reopening re-tunnels + `client.list()`-reattaches the live sessions (a strict improvement for the push flow too). The command (`workspace_open_on_host`) resolves the host-backed sync row → local host → `origin_path`, is idempotent, and is excluded from `reconcile_from_snapshot` so it never creates a duplicate cloud row; the overview dedupes the sibling card against the open in-place view and renders an "on host" badge with detach-only close. See `docs/features/remote-in-place.md`. Shipped in `v0.7.9` is a **multi-device robustness + remote-persistence pass** layered on top of repo-unit sync. (1) **SSH tunnel health is now surfaced in the UI**: the `TunnelStatus` the supervisor already computed (`connected`/`pending`/`reconnecting`/`circuit_open`) is bridged to the frontend via a new `tunnel-status-changed` event + `spawn_tunnel_status_forwarder` (self-terminating per supervisor), a zustand `tunnel-status-store` fed by an app-root `useTunnelStatusEvents` hook, and a sidebar pill — amber **"Reconnecting…"** on a sleep/wake or WiFi flap, red **"Connection lost — re-push"** once the circuit breaker trips — so a dropped tunnel no longer looks like a frozen workspace. (2) **Host persistence**: auto-upgrade no longer kills host-side agents — `hosts_upgrade` probes the daemon's `live_terminals` (via `codemux-remote serve status`) and **defers** the systemd-unit restart when sessions are live (`UpgradeOutcome::Skipped`); separately, the local pty-daemon now **idle-reaps** itself after 1h with zero sessions (hard re-check under lock so it can never reap a live session). (3) **Workspaces-sync robustness**: project-first remote pull with a real protected root (new local-only `default_branch` column + `resolve_default_branch`/`ensure_origin_head` + `workspaces_adopt_project` "Pull project" action), serialized adopts via a per-row creation lock, client-side `dedupe_sibling_rows` collapse of cross-device duplicate cards, daemon-side `collapse_main_for_uid`/`normalize_main_workspaces` (one repo root per project), uid-keyed collision-safe host paths (`-`), and a non-destructive `workspaces_reconcile_copy` action for legacy divergent copies. (4) **OpenFlow comm-log fix**: the daemon-backed agent spawn path (default since persistent agents) now tees cleaned PTY output to the communication log via the shared `comm_log_entry_for_chunk` helper, so daemon-spawned OpenFlow agents stop producing an empty log that blinded stuck-detection. See `docs/features/remote-hosts.md`, `docs/features/persistent-agents.md`, `docs/features/workspaces-sync.md`, `docs/features/workspaces-overview.md`, `docs/features/openflow.md`, `docs/plans/repo-unit-sync.md`. diff --git a/docs/features/remote-hosts.md b/docs/features/remote-hosts.md index 1049a38..2f53928 100644 --- a/docs/features/remote-hosts.md +++ b/docs/features/remote-hosts.md @@ -137,7 +137,7 @@ Headless tool surface (advertised via `tools/list`): | Tool | Purpose | |---|---| | `workspace_create` | Register a new workspace. | -| `worktree_create` | `git worktree add` a branch under `~/.codemux/worktrees//` (desktop layout) and register it with `kind = worktree` sharing the parent repo's `project_uid`. | +| `worktree_create` | `git worktree add` a branch under `~/.codemux/worktrees//` (desktop layout) and register it with `kind = worktree` sharing the parent repo's `project_uid`. Desktop parity (issue #78): fetches `base` from origin first so new branches start at the remote tip (best-effort, 10s cap, offline falls back to local refs), copies gitignored include files (`.env` & co, `.codemuxinclude` → defaults) from the parent repo before returning, and runs the project's `.codemux/config.json` setup commands on a background thread with `CODEMUX_ROOT_PATH`/`CODEMUX_WORKSPACE_PATH`/`CODEMUX_BRANCH`/`CODEMUX_PORT` (+ NAME/ID) set — `CODEMUX_PORT` comes from the same deterministic `allocate_workspace_port` hash as the desktop. The response's `setup` field reports `{port, includes_copied, setup_commands, setup_running}`; setup progress/failures go to the daemon's stderr (journal). The Settings-UI script fallback is desktop-only (it lives in the desktop SQLite DB), so headless config is file-based only. | | `workspace_list` | All workspaces, newest first. | | `workspace_info` | One workspace by id. | | `workspace_update` | Mutate name/branch/notes. | diff --git a/docs/features/setup-teardown.md b/docs/features/setup-teardown.md index 26fef3f..8aebe6a 100644 --- a/docs/features/setup-teardown.md +++ b/docs/features/setup-teardown.md @@ -98,6 +98,24 @@ Setup can be re-run on an existing workspace: Re-run executes the full pipeline: `.codemuxinclude` file copy, then setup commands. +### Headless Daemon Parity (remote hosts) + +The headless `codemux-remote serve` daemon runs the same pipeline when an +agent creates a worktree via the `worktree_create` MCP tool (issue #78): + +- Worktree includes copy runs synchronously before the tool returns. +- Setup commands run on a background thread via the shared + `crate::scripts::run_setup_commands` core — identical commands, + identical `CODEMUX_*` env, same deterministic `CODEMUX_PORT`. +- Progress goes to the daemon's stderr (journal) instead of Tauri + events; the tool response carries a `setup` summary + (`{port, includes_copied, setup_commands, setup_running}`). +- Config is file-based only (`.codemux/config.json` in worktree → repo + root). The Settings-UI fallback lives in the desktop SQLite DB and + does not exist on a headless host. +- A failing setup script never fails the tool call — the workspace is + still created and registered, like the desktop. + ### Run Command Behavior - `Ctrl+Shift+G` or command palette "Run Dev Command" @@ -146,8 +164,9 @@ The "Configure" button opens Settings > Projects. Dismiss persists per-project. - `src-tauri/src/config/workspace_config.rs` — config struct, reader, git root resolver, `read_effective_config` - `src-tauri/src/database.rs` — `ProjectScripts` struct, DB get/set methods -- `src-tauri/src/scripts.rs` — setup/teardown/run execution, `.codemuxinclude` processing, port allocation +- `src-tauri/src/scripts.rs` — setup/teardown/run execution, `.codemuxinclude` processing, port allocation; `run_setup_commands` is the UI-free core shared with the headless daemon - `src-tauri/src/commands/workspace.rs` — lifecycle hooks, `run_project_dev_command`, Tauri commands +- `src-tauri/src/remote/tools/mod.rs` — headless `worktree_create` provisioning (`provision_worktree_workspace`) - `src-tauri/src/control.rs` — `rerun_setup` socket command - `src-tauri/src/cli.rs` — `codemux workspace rerun-setup` CLI command - `src-tauri/src/commands/database.rs` — `get_project_scripts`, `set_project_scripts` commands diff --git a/docs/features/worktree-setup.md b/docs/features/worktree-setup.md index c22aef1..bfc4b91 100644 --- a/docs/features/worktree-setup.md +++ b/docs/features/worktree-setup.md @@ -22,6 +22,8 @@ config/master.key Files are copied (not symlinked), preserving directory structure. Runs before setup scripts so copied files are available during setup. +The same bootstrap (includes copy + setup scripts + env vars below) also runs when a worktree is created on a remote host through the headless daemon's `worktree_create` MCP tool — see `docs/features/setup-teardown.md` § "Headless Daemon Parity". + ## Environment Variables Setup and teardown scripts receive: diff --git a/docs/plans/mcp-on-remote.md b/docs/plans/mcp-on-remote.md index f0038e2..c01f37c 100644 --- a/docs/plans/mcp-on-remote.md +++ b/docs/plans/mcp-on-remote.md @@ -319,7 +319,7 @@ Again: **do not build this in v1.** The point of listing it is to lock in the fo - `pty.rs` — minimal portable-pty wrapper, per-terminal ring buffer (1 MiB cap). - `server.rs` — axum HTTP routes: `GET /health`, `GET /tools/list`, `POST /tools/call`. - `mcp.rs` — JSON-RPC 2.0 stdio MCP server (`initialize`, `tools/list`, `tools/call`) forwarding to the local daemon over HTTP. - - `tools/mod.rs` — 12 headless tools: workspace_{create,list,info,update,close}, worktree_create (added `v0.7.5`, backed by `remote/git.rs`), terminal_{spawn,write,read,list,close}, app_status. + - `tools/mod.rs` — 12 headless tools: workspace_{create,list,info,update,close}, worktree_create (added `v0.7.5`, backed by `remote/git.rs`; since issue #78 it also provisions like the desktop — fetch-before-branch, worktree-includes copy, background `.codemux/config.json` setup commands with `CODEMUX_*` env + deterministic port, summarized in the response's `setup` field), terminal_{spawn,write,read,list,close}, app_status. - **CLI subcommands on `codemux-remote`**: `serve` (long-running daemon with graceful SIGTERM/SIGINT shutdown), `serve status`, `serve stop`, `mcp` (stdio MCP for an agent CLI). - **Tests**: 26 unit tests in the `remote` module + 8 end-to-end integration tests in `src-tauri/tests/codemux_remote_serve_mcp.rs` covering the full HTTP roundtrip, the MCP stdio roundtrip (the headline test), and edge cases (auth required, singleton check, status JSON, missing-daemon error path, PTY echo roundtrip). - **Example systemd user unit** at `scripts/codemux-remote.service.example` with install instructions. diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 09271f7..752aa4e 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -288,11 +288,33 @@ mod tests { .await .unwrap(); - // Exactly one `codemux` row — the always-on hardcoded one. - let codemux_rows: Vec<_> = - result.iter().filter(|s| s.name == "codemux").collect(); - assert_eq!(codemux_rows.len(), 1); - assert_eq!(codemux_rows[0].primary_source(), McpConfigSource::Codemux); + // The project-scope `codemux` entry must be suppressed: no + // codemux row may carry a project-level source. (Scoped to + // project sources rather than a global count because + // `list_mcp_servers` also scans real user-level config paths — + // on a developer machine where `codemux-remote serve` has + // auto-registered itself into e.g. `~/.claude.json`, a + // user-level codemux row legitimately coexists with the + // hardcoded self row.) + assert!( + !result.iter().any(|s| { + s.name == "codemux" + && s.sources.iter().any(|src| matches!( + src, + McpConfigSource::ClaudeProject | McpConfigSource::CodemuxProject + )) + }), + "project-scope codemux entry leaked into {:?}", + result + ); + // The always-on hardcoded self row is present exactly once. + assert_eq!( + result + .iter() + .filter(|s| s.primary_source() == McpConfigSource::Codemux) + .count(), + 1 + ); // `demo` from the same file still shows up. assert!(result.iter().any(|s| s.name == "demo")); } diff --git a/src-tauri/src/remote/tools/mod.rs b/src-tauri/src/remote/tools/mod.rs index 72358f3..aa71d35 100644 --- a/src-tauri/src/remote/tools/mod.rs +++ b/src-tauri/src/remote/tools/mod.rs @@ -54,7 +54,7 @@ pub fn catalog() -> Vec { }, ToolSpec { name: "worktree_create", - description: "Create a git worktree + register a Codemux workspace in one call — the headless equivalent of the desktop's worktree_create. Runs `git worktree add` under ~/.codemux/worktrees// and records the resulting workspace. Use this (NOT workspace_create) to fork a branch off an existing git repo on this host. For a brand-new project, first `git init` a folder (e.g. via terminal_spawn/terminal_write), then call this against it.", + description: "Create a git worktree + register a Codemux workspace in one call — the headless equivalent of the desktop's worktree_create. Runs `git worktree add` under ~/.codemux/worktrees// (fetching `base` from origin first so new branches start at the remote tip) and records the resulting workspace. Also provisions the worktree like the desktop: gitignored include files (.env & co) are copied from the parent repo before this returns, and the project's `.codemux/config.json` setup commands run in the background with CODEMUX_ROOT_PATH/CODEMUX_WORKSPACE_PATH/CODEMUX_BRANCH/CODEMUX_PORT set (see the `setup` field of the response). Use this (NOT workspace_create) to fork a branch off an existing git repo on this host. For a brand-new project, first `git init` a folder (e.g. via terminal_spawn/terminal_write), then call this against it.", input_schema: json!({ "type": "object", "properties": { @@ -277,7 +277,109 @@ fn worktree_create(params: &Value, store: &WorkspaceStore) -> ToolResult { Some(created.repo_root.to_string_lossy().to_string()), ) .map_err(workspace_err)?; - Ok(json!({ "workspace": ws })) + + // Desktop parity (issue #78): provision the new worktree the same + // way the desktop does after `git worktree add`. Provisioning never + // fails the tool — a workspace whose setup script broke is still a + // registered, usable workspace (matching the desktop, where setup + // failures only surface as a notification). + let setup = provision_worktree_workspace(&ws); + + Ok(json!({ "workspace": ws, "setup": setup })) +} + +/// Headless equivalent of the desktop's `spawn_setup_scripts` pipeline: +/// copy gitignored include files (`.env` & co) from the parent repo, +/// then run the project's setup commands with the standard `CODEMUX_*` +/// env and the deterministic per-workspace port. +/// +/// - The includes copy is fast and runs inline, so the files are in +/// place the moment `worktree_create` returns. +/// - Setup commands can take minutes (`npm install`), so they run on a +/// detached background thread — same fire-and-forget shape as the +/// desktop — and the tool response only reports what was scheduled. +/// +/// Differences from the desktop, by design: +/// - Config comes from `.codemux/config.json` (workspace dir → repo +/// root) only. The Settings-UI fallback lives in the desktop's +/// SQLite database, which does not exist on a headless host. +/// - Progress goes to stderr (visible in the daemon's journal) instead +/// of Tauri events, because there is no frontend to notify. +fn provision_worktree_workspace(ws: &Workspace) -> Value { + let workspace_path = std::path::PathBuf::from(&ws.path); + let root_path = crate::scripts::resolve_root_path(&workspace_path); + let config = crate::config::workspace_config::read_workspace_config(&workspace_path); + + // Step 1: worktree includes (file → setting is desktop-only → defaults). + let setting_patterns = config + .as_ref() + .map(|c| c.worktree_includes.clone()) + .unwrap_or_default(); + let includes_copied = match crate::scripts::process_worktree_includes( + &root_path, + &workspace_path, + &setting_patterns, + ) { + Ok(result) => result.copied, + Err(e) => { + eprintln!( + "[codemux-remote] worktree includes failed for workspace {}: {e}", + ws.id + ); + Vec::new() + } + }; + + // Step 2: setup commands, in the background. The port is derived + // from the workspace id exactly like the desktop, so a project's + // setup script sees a stable CODEMUX_PORT for this workspace. + let port = crate::scripts::allocate_workspace_port(&ws.id); + let setup_commands = config.as_ref().map(|c| c.setup.len()).unwrap_or(0); + + if let Some(config) = config.filter(|c| !c.setup.is_empty()) { + let ws_id = ws.id.clone(); + let ws_name = ws.name.clone(); + let branch = ws.branch.clone(); + std::thread::spawn(move || { + let outcome = crate::scripts::run_setup_commands( + &workspace_path, + &ws_name, + &ws_id, + &config, + &root_path, + branch.as_deref(), + Some(port), + &mut |event| match event { + crate::scripts::SetupEvent::Progress { + command, + index, + total, + } => eprintln!( + "[codemux-remote] setup {}/{total} for workspace {ws_id}: {command}", + index + 1 + ), + crate::scripts::SetupEvent::Failed { + command, exit_code, .. + } => eprintln!( + "[codemux-remote] setup command `{command}` failed (exit {exit_code:?}) for workspace {ws_id}" + ), + crate::scripts::SetupEvent::Complete => eprintln!( + "[codemux-remote] setup complete for workspace {ws_id}" + ), + }, + ); + if let Err(e) = outcome { + eprintln!("[codemux-remote] setup failed for workspace {ws_id}: {e}"); + } + }); + } + + json!({ + "port": port, + "includes_copied": includes_copied, + "setup_commands": setup_commands, + "setup_running": setup_commands > 0, + }) } fn workspace_list(store: &WorkspaceStore) -> ToolResult { @@ -510,6 +612,186 @@ mod tests { } } + /// Commit any pending changes in `dir` (used to land `.codemux/` + /// config + `.gitignore` after `init_repo`). + fn commit_all(dir: &std::path::Path, message: &str) { + for args in [&["add", "."][..], &["commit", "-m", message][..]] { + assert!( + Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .unwrap() + .status + .success(), + "git {args:?}" + ); + } + } + + /// Desktop-parity provisioning (issue #78): the daemon's + /// worktree_create must copy gitignored include files into the new + /// worktree and run the project's setup script with the standard + /// CODEMUX_* env + deterministic port. + #[test] + #[serial] + fn worktree_create_runs_setup_scripts_with_env_and_includes() { + let home = TempDir::new().unwrap(); + let prev_home = std::env::var_os("HOME"); + std::env::set_var("HOME", home.path()); + + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + // Gitignored .env in the parent repo — the includes step must + // copy it into the worktree (defaults cover `.env`). + std::fs::write(repo.path().join(".gitignore"), ".env\nsetup-ran.txt\n").unwrap(); + std::fs::write(repo.path().join(".env"), "SECRET=from-root").unwrap(); + // Committed setup config: the script records its env into a + // marker file inside the worktree. + std::fs::create_dir_all(repo.path().join(".codemux")).unwrap(); + std::fs::write( + repo.path().join(".codemux/config.json"), + r#"{"setup": ["printf '%s|%s|%s|%s|%s' \"$CODEMUX_BRANCH\" \"$CODEMUX_PORT\" \"$CODEMUX_ROOT_PATH\" \"$CODEMUX_WORKSPACE_PATH\" \"$CODEMUX_WORKSPACE_ID\" > setup-ran.txt"]}"#, + ) + .unwrap(); + commit_all(repo.path(), "add provisioning config"); + + let store_dir = TempDir::new().unwrap(); + let store = open_store(&store_dir); + let params = json!({ + "repo_path": repo.path().to_string_lossy(), + "branch": "feature/provision", + "base": "main" + }); + let out = worktree_create(¶ms, &store).expect("worktree_create"); + let ws = &out["workspace"]; + let setup = &out["setup"]; + let wt = std::path::PathBuf::from(ws["path"].as_str().unwrap()); + + // The tool response reports the provisioning summary. + assert_eq!(setup["setup_commands"], 1); + assert_eq!(setup["setup_running"], true); + let port = setup["port"].as_u64().expect("port") as u16; + assert!((3100..6500).contains(&port), "port {port} out of range"); + assert!( + setup["includes_copied"] + .as_array() + .unwrap() + .iter() + .any(|v| v == ".env"), + "includes_copied: {}", + setup["includes_copied"] + ); + + // Includes are copied synchronously: present as soon as the + // tool returns. + assert_eq!( + std::fs::read_to_string(wt.join(".env")).unwrap(), + "SECRET=from-root" + ); + + // Setup commands run in the background; poll for the marker. + let marker = wt.join("setup-ran.txt"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !marker.exists() { + assert!( + std::time::Instant::now() < deadline, + "setup script never ran (no {})", + marker.display() + ); + std::thread::sleep(std::time::Duration::from_millis(50)); + } + let recorded = std::fs::read_to_string(&marker).unwrap(); + let parts: Vec<&str> = recorded.split('|').collect(); + assert_eq!(parts.len(), 5, "marker: {recorded}"); + assert_eq!(parts[0], "feature/provision", "CODEMUX_BRANCH"); + assert_eq!(parts[1], port.to_string(), "CODEMUX_PORT"); + assert_eq!( + std::fs::canonicalize(parts[2]).unwrap(), + std::fs::canonicalize(repo.path()).unwrap(), + "CODEMUX_ROOT_PATH should be the parent repo root" + ); + assert_eq!( + std::fs::canonicalize(parts[3]).unwrap(), + std::fs::canonicalize(&wt).unwrap(), + "CODEMUX_WORKSPACE_PATH should be the worktree" + ); + assert_eq!(parts[4], ws["id"].as_str().unwrap(), "CODEMUX_WORKSPACE_ID"); + + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + + /// No `.codemux/config.json` → no setup commands, but the tool + /// still succeeds and reports an allocated port (graceful path). + #[test] + #[serial] + fn worktree_create_without_setup_config_is_graceful() { + let home = TempDir::new().unwrap(); + let prev_home = std::env::var_os("HOME"); + std::env::set_var("HOME", home.path()); + + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + let store_dir = TempDir::new().unwrap(); + let store = open_store(&store_dir); + let params = json!({ + "repo_path": repo.path().to_string_lossy(), + "branch": "no-config", + "base": "main" + }); + let out = worktree_create(¶ms, &store).expect("worktree_create"); + assert_eq!(out["setup"]["setup_commands"], 0); + assert_eq!(out["setup"]["setup_running"], false); + assert!(out["setup"]["port"].as_u64().is_some()); + + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + + /// A failing setup script must not fail worktree_create: the + /// workspace is still created and registered (matching the desktop, + /// where setup failures only surface as a notification). + #[test] + #[serial] + fn worktree_create_with_failing_setup_still_succeeds() { + let home = TempDir::new().unwrap(); + let prev_home = std::env::var_os("HOME"); + std::env::set_var("HOME", home.path()); + + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + std::fs::create_dir_all(repo.path().join(".codemux")).unwrap(); + std::fs::write( + repo.path().join(".codemux/config.json"), + r#"{"setup": ["exit 7"]}"#, + ) + .unwrap(); + commit_all(repo.path(), "add failing setup"); + + let store_dir = TempDir::new().unwrap(); + let store = open_store(&store_dir); + let params = json!({ + "repo_path": repo.path().to_string_lossy(), + "branch": "failing-setup", + "base": "main" + }); + let out = worktree_create(¶ms, &store) + .expect("tool must not fail when the setup script fails"); + assert_eq!(out["setup"]["setup_commands"], 1); + assert!(out["workspace"]["id"].is_string()); + + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + #[test] #[serial] fn worktree_create_dispatch_rejects_non_repo() { diff --git a/src-tauri/src/scripts.rs b/src-tauri/src/scripts.rs index 51252a2..22980a0 100644 --- a/src-tauri/src/scripts.rs +++ b/src-tauri/src/scripts.rs @@ -329,18 +329,46 @@ pub fn run_setup_scripts( ) } -/// Run setup scripts with a pre-resolved config and root path. -/// The `root_path` should be resolved on the calling thread (not inside a spawned thread) -/// to avoid race conditions with worktree `.git` file resolution. -pub fn run_setup_scripts_with_config( +/// Progress notifications from the shared setup-command runner core. +/// +/// The desktop (`run_setup_scripts_with_config`) maps these to the Tauri +/// events the frontend listens for (`workspace-setup-progress` / +/// `workspace-setup-failed` / `workspace-setup-complete`); the headless +/// daemon (`remote::tools::worktree_create`) logs them to stderr because +/// there is no frontend to notify. Fields are borrowed: events are +/// consumed synchronously by the notifier. +pub enum SetupEvent<'a> { + Progress { + command: &'a str, + index: usize, + total: usize, + }, + Failed { + command: &'a str, + stdout: &'a str, + stderr: &'a str, + exit_code: Option, + }, + Complete, +} + +/// UI-free core of the setup-command runner — shared by the desktop and +/// the headless daemon's `worktree_create` tool so both paths run +/// identical commands with identical env (`CODEMUX_ROOT_PATH` / +/// `CODEMUX_WORKSPACE_PATH` / `CODEMUX_BRANCH` / `CODEMUX_PORT` plus +/// `CODEMUX_WORKSPACE_NAME` / `CODEMUX_WORKSPACE_ID`). Commands run +/// sequentially via `sh -c` in `workspace_path`; the first failure stops +/// the pipeline and is returned as `Err`. +#[allow(clippy::too_many_arguments)] +pub fn run_setup_commands( workspace_path: &Path, workspace_name: &str, workspace_id: &str, - app_handle: &AppHandle, config: &WorkspaceConfig, root_path: &Path, branch: Option<&str>, port: Option, + notify: &mut dyn FnMut(SetupEvent<'_>), ) -> Result<(), String> { if config.setup.is_empty() { return Ok(()); @@ -356,15 +384,11 @@ pub fn run_setup_scripts_with_config( let total = config.setup.len(); for (index, command) in config.setup.iter().enumerate() { - let _ = app_handle.emit( - "workspace-setup-progress", - SetupProgress { - workspace_id: workspace_id.to_string(), - command: command.clone(), - index, - total, - }, - ); + notify(SetupEvent::Progress { + command, + index, + total, + }); let mut cmd = std::process::Command::new("sh"); cmd.arg("-c") @@ -385,16 +409,12 @@ pub fn run_setup_scripts_with_config( let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let exit_code = output.status.code(); - let _ = app_handle.emit( - "workspace-setup-failed", - SetupFailed { - workspace_id: workspace_id.to_string(), - command: command.clone(), - stdout: stdout.clone(), - stderr: stderr.clone(), - exit_code, - }, - ); + notify(SetupEvent::Failed { + command, + stdout: &stdout, + stderr: &stderr, + exit_code, + }); return Err(format!( "Setup command `{command}` failed (exit {}): {}", @@ -406,16 +426,82 @@ pub fn run_setup_scripts_with_config( } } - let _ = app_handle.emit( - "workspace-setup-complete", - SetupComplete { - workspace_id: workspace_id.to_string(), - }, - ); + notify(SetupEvent::Complete); Ok(()) } +/// Run setup scripts with a pre-resolved config and root path. +/// The `root_path` should be resolved on the calling thread (not inside a spawned thread) +/// to avoid race conditions with worktree `.git` file resolution. +/// +/// Thin Tauri wrapper over [`run_setup_commands`]: forwards each +/// [`SetupEvent`] to the frontend as the events it has always listened +/// for, with unchanged payload shapes. +#[allow(clippy::too_many_arguments)] +pub fn run_setup_scripts_with_config( + workspace_path: &Path, + workspace_name: &str, + workspace_id: &str, + app_handle: &AppHandle, + config: &WorkspaceConfig, + root_path: &Path, + branch: Option<&str>, + port: Option, +) -> Result<(), String> { + run_setup_commands( + workspace_path, + workspace_name, + workspace_id, + config, + root_path, + branch, + port, + &mut |event| match event { + SetupEvent::Progress { + command, + index, + total, + } => { + let _ = app_handle.emit( + "workspace-setup-progress", + SetupProgress { + workspace_id: workspace_id.to_string(), + command: command.to_string(), + index, + total, + }, + ); + } + SetupEvent::Failed { + command, + stdout, + stderr, + exit_code, + } => { + let _ = app_handle.emit( + "workspace-setup-failed", + SetupFailed { + workspace_id: workspace_id.to_string(), + command: command.to_string(), + stdout: stdout.to_string(), + stderr: stderr.to_string(), + exit_code, + }, + ); + } + SetupEvent::Complete => { + let _ = app_handle.emit( + "workspace-setup-complete", + SetupComplete { + workspace_id: workspace_id.to_string(), + }, + ); + } + }, + ) +} + pub fn run_teardown_scripts( workspace_path: &Path, workspace_name: &str, diff --git a/src-tauri/tests/codemux_remote_serve_mcp.rs b/src-tauri/tests/codemux_remote_serve_mcp.rs index c82f840..902f520 100644 --- a/src-tauri/tests/codemux_remote_serve_mcp.rs +++ b/src-tauri/tests/codemux_remote_serve_mcp.rs @@ -41,6 +41,15 @@ fn binary_path() -> PathBuf { struct ServeFixture { child: Child, state_dir: TempDir, + /// Isolated `HOME` for the daemon. Two reasons it must never be + /// the developer's real home: (1) `worktree_create` materialises + /// worktrees under `$HOME/.codemux/worktrees/...`; (2) on startup + /// the daemon auto-registers `codemux-remote mcp` into agent + /// configs (`~/.claude.json`, …) — pointing them at this build's + /// transient debug binary, which both pollutes the developer's + /// machine and breaks the `commands::mcp` discovery unit tests + /// that scan real user-level config paths. + home: TempDir, endpoint: String, secret: String, } @@ -55,6 +64,7 @@ impl ServeFixture { bin ); let state_dir = TempDir::new().expect("tempdir"); + let home = TempDir::new().expect("home tempdir"); // Spawn the daemon. Inherit stderr so test failures surface // the daemon's own diagnostics. Redirect stdout to /dev/null // because we don't care about it (the daemon logs to stderr). @@ -62,6 +72,7 @@ impl ServeFixture { cmd.arg("serve") .arg("--state-dir") .arg(state_dir.path()) + .env("HOME", home.path()) .stdout(Stdio::null()) .stderr(Stdio::inherit()); let child = cmd.spawn().expect("spawn codemux-remote serve"); @@ -108,7 +119,7 @@ impl ServeFixture { std::thread::sleep(Duration::from_millis(100)); } - Self { child, state_dir, endpoint, secret } + Self { child, state_dir, home, endpoint, secret } } fn stop(mut self) { @@ -218,6 +229,157 @@ fn http_workspace_create_then_list() { fx.stop(); } +/// Run `git -C `, asserting success; returns trimmed stdout. +fn git(dir: &std::path::Path, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(dir) + .args(args) + .output() + .expect("spawn git"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).trim().to_string() +} + +/// Headless worktree provisioning parity (issue #78), end-to-end +/// through the real daemon over HTTP: +/// +/// 1. A bare "origin" + a local clone whose `origin/main` is one +/// commit stale (a publisher clone pushed after the local clone). +/// 2. `worktree_create` against the local clone must land the new +/// branch on the FRESH origin tip (fetch-before-branch), not the +/// stale local ref. +/// 3. The gitignored `.env` must be copied into the worktree before +/// the tool returns. +/// 4. The project's `.codemux/config.json` setup script must run with +/// the CODEMUX_* env + the deterministic port from the response. +#[test] +fn http_worktree_create_provisions_like_desktop() { + let work = TempDir::new().expect("work tempdir"); + + // Seed repo with provisioning config committed. + let seed = work.path().join("seed"); + std::fs::create_dir_all(&seed).unwrap(); + git(&seed, &["init", "--initial-branch=main"]); + git(&seed, &["config", "user.email", "t@e.com"]); + git(&seed, &["config", "user.name", "T"]); + std::fs::write(seed.join("README.md"), "hi").unwrap(); + std::fs::write(seed.join(".gitignore"), ".env\nsetup-ran.txt\n").unwrap(); + std::fs::create_dir_all(seed.join(".codemux")).unwrap(); + std::fs::write( + seed.join(".codemux/config.json"), + r#"{"setup": ["printf '%s|%s|%s|%s' \"$CODEMUX_BRANCH\" \"$CODEMUX_PORT\" \"$CODEMUX_ROOT_PATH\" \"$CODEMUX_WORKSPACE_PATH\" > setup-ran.txt"]}"#, + ) + .unwrap(); + git(&seed, &["add", "."]); + git(&seed, &["commit", "-m", "init"]); + + // Bare origin + local clone + publisher that makes local stale. + let bare = work.path().join("origin.git"); + git(work.path(), &["clone", "--bare", seed.to_str().unwrap(), bare.to_str().unwrap()]); + let local = work.path().join("local"); + git(work.path(), &["clone", bare.to_str().unwrap(), local.to_str().unwrap()]); + let publisher = work.path().join("publisher"); + git(work.path(), &["clone", bare.to_str().unwrap(), publisher.to_str().unwrap()]); + git( + &publisher, + &["-c", "user.name=T", "-c", "user.email=t@e.com", + "commit", "--allow-empty", "-m", "remote-only"], + ); + git(&publisher, &["push", "origin", "main"]); + + let remote_tip = git(&bare, &["rev-parse", "main"]); + let stale = git(&local, &["rev-parse", "refs/remotes/origin/main"]); + assert_ne!(stale, remote_tip, "precondition: local origin/main must be stale"); + + // The gitignored .env exists only in the local clone's root — + // exactly the untracked artifact the includes step must carry over. + std::fs::write(local.join(".env"), "SECRET=remote-host").unwrap(); + + // The fixture isolates HOME, so the worktree lands in its tempdir. + let fx = ServeFixture::start(); + let client = reqwest::blocking::Client::new(); + + let resp = client + .post(format!("{}/tools/call", fx.endpoint)) + .bearer_auth(&fx.secret) + .json(&json!({ + "name": "worktree_create", + "arguments": { + "repo_path": local.to_string_lossy(), + "branch": "provisioned", + "base": "main" + } + })) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + let body: Value = resp.json().unwrap(); + assert_eq!(body["ok"], json!(true), "body: {body}"); + + let ws = &body["data"]["workspace"]; + let setup = &body["data"]["setup"]; + let wt = PathBuf::from(ws["path"].as_str().expect("workspace.path")); + assert!( + wt.starts_with(fx.home.path()), + "worktree must live under the isolated HOME: {}", + wt.display() + ); + + // (2) Fetch-before-branch: new branch starts at the fresh tip. + assert_eq!( + git(&wt, &["rev-parse", "HEAD"]), + remote_tip, + "daemon-created branch must start at the freshly-fetched origin/main" + ); + + // (3) Includes copied synchronously. + assert_eq!( + std::fs::read_to_string(wt.join(".env")).unwrap(), + "SECRET=remote-host" + ); + assert!(setup["includes_copied"] + .as_array() + .unwrap() + .iter() + .any(|v| v == ".env")); + + // (4) Setup script ran with CODEMUX_* env + the reported port. + assert_eq!(setup["setup_commands"], json!(1)); + let port = setup["port"].as_u64().expect("setup.port"); + let marker = wt.join("setup-ran.txt"); + let deadline = Instant::now() + Duration::from_secs(15); + while !marker.exists() { + assert!( + Instant::now() < deadline, + "setup script never produced {}", + marker.display() + ); + std::thread::sleep(Duration::from_millis(100)); + } + let recorded = std::fs::read_to_string(&marker).unwrap(); + let parts: Vec<&str> = recorded.split('|').collect(); + assert_eq!(parts.len(), 4, "marker: {recorded}"); + assert_eq!(parts[0], "provisioned", "CODEMUX_BRANCH"); + assert_eq!(parts[1], port.to_string(), "CODEMUX_PORT"); + assert_eq!( + std::fs::canonicalize(parts[2]).unwrap(), + std::fs::canonicalize(&local).unwrap(), + "CODEMUX_ROOT_PATH" + ); + assert_eq!( + std::fs::canonicalize(parts[3]).unwrap(), + std::fs::canonicalize(&wt).unwrap(), + "CODEMUX_WORKSPACE_PATH" + ); + + fx.stop(); +} + /// Drive `codemux-remote mcp` over stdio: initialize → tools/list → /// tools/call workspace_create → tools/call workspace_list. This is /// the actual code path a CLI agent (Claude Code, Codex) takes.