diff --git a/docs/features/remote-hosts.md b/docs/features/remote-hosts.md index 86fa0ac5..1dd6a1af 100644 --- a/docs/features/remote-hosts.md +++ b/docs/features/remote-hosts.md @@ -86,6 +86,21 @@ codemux-remote mcp [--state-dir ] daemon's endpoint + secret, then bridges agent CLI tool calls to the daemon's HTTP API. Configure your CLI agent with: {"command": "codemux-remote", "args": ["mcp"]} + +codemux-remote workspace list [--state-dir ] + → Print {"host_id":"","workspaces":[...]} + to stdout. Reads the daemon's SQLite registry directly — works + even when the daemon isn't running. Invoked over SSH by the + desktop's host-inventory poller every ~60 s so workspaces + created on the host (e.g. by an agent via the MCP + `workspace_create` tool) auto-publish to the user's cloud + registry without a manual push. See + docs/features/workspaces-sync.md § "Asymmetric publish model". + +codemux-remote workspace register --path [--name ] + [--branch ] [--project-root

] + → POSTs `workspace_create` to the running daemon and prints the + new workspace JSON. Used by the desktop's push flow over SSH. ``` ### `serve` mode overview @@ -215,6 +230,20 @@ Safety contract: won't have `~/.claude.json`; we don't create directories the user never opted into. +### Background host-inventory poller (`hosts_inventory.rs`) + +Asymmetric companion to the upgrade poller. Where `hosts_upgrade` keeps each host's `codemux-remote` binary at the desktop's version, `hosts_inventory` keeps each host's *workspace registry* visible to the user's account — without an explicit push from any dev device. + +Run cadence: 60 s, starting ~12 s after app setup (let `hosts_upgrade` finish first so the registry shape is consistent). Per host with a `server_id`: + +1. `ssh::probe::probe_host` — skip offline / not-installed hosts. +2. SSH and run `codemux-remote workspace list` (with the same `~/.local/bin/codemux-remote` PATH fallback the probe uses — non-interactive SSH on Arch/Ubuntu/Fedora doesn't source `~/.profile`). +3. `parse_inventory_json` + `reconcile_host_inventory` → insert/update/soft-delete sibling-only rows in `workspaces_sync` keyed by `(host_server_id, origin_uid)`. + +The next `workspaces_sync::push` tick (30 s) uploads dirty rows to the cloud, so other devices see new host-side workspaces within ~90 s. See `docs/features/workspaces-sync.md` § *Asymmetric publish model* for the full design rationale, identity contract, and known limitations. + +The `codemux-remote workspace list` CLI subcommand the desktop invokes lives at `src-tauri/src/bin/codemux_remote.rs::run_workspace_list`. It reads the daemon's SQLite directly so the inventory poll works even when the daemon isn't running. + ### Background host-upgrade poller (`hosts_upgrade.rs`) Users don't think about "upgrading a helper binary on a remote diff --git a/docs/features/workspaces-sync.md b/docs/features/workspaces-sync.md index c54aac08..a544600e 100644 --- a/docs/features/workspaces-sync.md +++ b/docs/features/workspaces-sync.md @@ -126,6 +126,52 @@ Bucketing is by `host_server_id`, so the same host shows up under the same bucke - Local sync: pull, push, reconcile, soft-delete tombstones, idempotent server-side upsert. - UI: synced rows render in the Workspaces overview under the correct device bucket. The "Pending sync" pill surfaces dirty state. Sibling-device rows render with a distinct dashed border and a "lives on another device" pill. Phase 4c divergence chip flags HEAD divergence on every affected row. - Cadence: 30-second background loop. `workspaces_sync_now` for forced sync. +- **Asymmetric auto-publish from `codemux-remote` hosts**: every configured host's workspace registry is polled over SSH every ~60 s and reconciled into `workspaces_sync` as sibling-only rows (workspace_id NULL, dirty=1). The existing push tick then uploads them, so a workspace an agent creates on a host via the MCP `workspace_create` tool surfaces in every dev device's overview within ~90 s — no explicit push from the laptop required. Pull-conflict guard refuses to clobber a local workspace already on the same branch of the same project. Detailed model and design rationale in the **Asymmetric publish model** section below. + +## Asymmetric publish model (auto-publish from remote hosts) + +The synced field set above is symmetric — every device publishes its own workspaces and pulls everyone else's. That symmetry breaks down for **headless hosts** running `codemux-remote serve`: they have a workspace registry (the daemon's SQLite at `/codemux.db`), but no logged-in Codemux account, no Better Auth token, and no path to call `/api/workspaces` themselves. Workspaces created directly on the host (e.g. by an agent calling the `workspace_create` MCP tool overnight) used to be invisible to every dev device until someone explicitly pushed them — defeating the point of asking the agent to start work in the first place. + +The fix is a deliberately asymmetric model: + +| Role | Behaviour | Why | +|---|---|---| +| Codemux **app** (laptop/desktop) | Manual push/pull stays exactly as it was. Nothing about the workspace lifecycle changes. | Preserves the "close laptop, continue in cloud" flow + user agency over what leaves their dev device. | +| Codemux **remote** daemon (server) | Every dev device polls the host's inventory and republishes new workspaces to the cloud on its behalf. | Servers are always-on, SSH-reachable, and exist to be used as hosts — broadcasting their registry is the unsurprising default. Dev devices remain in charge of polling so the daemon never needs an auth credential. | + +### Mechanics + +1. **CLI exposed by the host.** `codemux-remote workspace list [--state-dir ]` reads the daemon's SQLite registry directly (no running daemon required) and prints `{"host_id": "", "workspaces": [...]}` on stdout. Implementation in `src-tauri/src/bin/codemux_remote.rs::run_workspace_list`. Stable contract — the desktop's parser depends on the shape. +2. **Desktop poller.** `src-tauri/src/hosts_inventory.rs::spawn` starts a background task ~12 s after app setup and runs forever on a 60 s cadence. For each configured host with a `server_id`: + - Probe via `ssh::probe::probe_host` (re-uses the same `~/.local/bin/codemux-remote` PATH fallback the test-connection flow uses). + - SSH and run `codemux-remote workspace list` (also with the PATH fallback — see `build_inventory_argv`). + - Parse the JSON envelope. + - Reconcile: per inventory row, INSERT a sibling-only sync row (workspace_id NULL, `host_server_id = host.server_id`, `origin_uid = workspace.id` (UUID), dirty=1) or UPDATE in place if `(host_server_id, origin_uid)` already exists. Disappeared rows get soft-deleted. +3. **Cloud push** (existing). The 30 s `workspaces_sync::push` tick walks every dirty row including these sibling-only ones, POSTs to `/api/workspaces`, and stamps the assigned `server_id`. Other devices pull and render them under the host's bucket. Same code path as a manual push — no new API surface. + +### Identity contract for remote-discovered rows + +| Column | Value | Purpose | +|---|---|---| +| `workspace_id` | `NULL` | Marker for "lives elsewhere, not adopted here." Sibling-row rendering. | +| `host_server_id` | host's `server_id` | Bucket the row under the right host in the overview. | +| `origin_uid` | remote daemon's UUID | Dedupes repeated polls — re-discovering the same UUID UPDATEs in place instead of inserting again. **Local-only column**: not in the cloud schema. | +| `server_id` | assigned by first `push()` | Cross-device identity. After this exists, other devices see the row via cloud pull. | +| `dirty` | `1` on insert / on field change | Triggers the next push tick. | + +The `origin_uid` column is additive (`ALTER TABLE workspaces_sync ADD COLUMN origin_uid TEXT`) and only ever set by the inventory reconcile path. Local-pushed workspaces and cloud-pulled sibling rows leave it null. + +### Pull-conflict guard + +The Pull-to-this-device dialog now refuses to clobber a workspace the user's already working on. `AdoptionPreview.same_branch_project_exists_at` is set to the local workspace_id when **another local workspace** on this device matches `(basename(project_path), git_branch)` with the previewed remote row. The dialog renders `SameBranchProjectBlock` with an "Open the existing workspace" CTA and hides the Pull button. + +Why basename and not full path: across two devices the same repo will almost always be checked out at different absolute paths (`~/projects/foo` here, `/home/deus/projects/foo` there). Full-path comparison would miss the conflict every time. False positives are bounded — the user would need two distinct projects with the same basename AND the same branch on the same device. + +### Known limitations (v1) + +- **Cross-device race for the same host workspace.** `origin_uid` is local-only. If Device A and Device B both poll Pandora between cloud pulls, both will publish a row for the same physical workspace and the overview will briefly show two entries. They converge on the next pull tick because the second device sees the first device's cloud row (with matching `host_server_id`, `project_remote`, `git_branch`). Single-device users — the common case today — never hit this. Permanent fix is a server-side `origin_uid` column with a unique partial index; tracked but not in scope. +- **`project_remote` not available.** The remote daemon's schema doesn't carry the originating git remote URL, so remote-discovered rows have `project_remote = null`. Other devices can still adopt via the host-backed (rsync) path; the clone fallback isn't available for these rows. +- **Hosts without a `server_id` are skipped silently.** Until the host record itself has synced (`hosts_sync` pushes it and gets a cloud id), the poller can't tag inventory rows with a stable cross-device host identity. The first cycle after the host syncs picks them up. ## Current Constraints @@ -152,7 +198,10 @@ Wired at three call sites: ## Important Touch Points ### Local (Codemux desktop) -- `src-tauri/src/database.rs` — `workspaces_sync` table (additive `git_head_sha TEXT` migration added in Phase 4c) + `WorkspaceSyncRecord` struct + CRUD impls (`insert_workspace_sync`, `update_workspace_sync_by_workspace_id`, `soft_delete_workspace_sync_by_workspace_id`, `list_workspaces_sync`, `list_workspaces_sync_for_sync`, `list_dirty_workspaces_sync`, `mark_workspace_sync_synced`, `upsert_workspace_sync_from_server`, `purge_acknowledged_workspace_sync_deletes`, `link_workspace_sync_to_local`). +- `src-tauri/src/database.rs` — `workspaces_sync` table (additive `git_head_sha TEXT` migration in Phase 4c, additive `origin_uid TEXT` migration for the asymmetric publish flow) + `WorkspaceSyncRecord` struct + CRUD impls (`insert_workspace_sync`, `update_workspace_sync_by_workspace_id`, `soft_delete_workspace_sync_by_workspace_id`, `list_workspaces_sync`, `list_workspaces_sync_for_sync`, `list_dirty_workspaces_sync`, `mark_workspace_sync_synced`, `upsert_workspace_sync_from_server`, `purge_acknowledged_workspace_sync_deletes`, `link_workspace_sync_to_local`). Remote-discovered helpers: `insert_remote_discovered_workspace_sync`, `update_remote_discovered_workspace_sync`, `find_workspace_sync_by_host_and_origin_uid`, `list_remote_discovered_for_host`, `soft_delete_remote_discovered_workspace_sync_by_id`. +- `src-tauri/src/hosts_inventory.rs` — background poller for the asymmetric publish flow. `spawn(app)` wired from `lib.rs` setup; per-host cycle runs `probe_host` + `fetch_inventory` + `reconcile_host_inventory`. `build_inventory_argv` locks in the SSH flags + `~/.local/bin/codemux-remote` fallback. `PollStats` captures inserted/updated/soft_deleted per cycle. +- `src-tauri/src/bin/codemux_remote.rs::run_workspace_list` — `workspace list` CLI subcommand the desktop invokes over SSH. Reads the daemon's SQLite directly, no HTTP, no running daemon required. +- `src-tauri/src/commands/workspaces_sync.rs::detect_same_branch_project_conflict` — pull-conflict guard; populates `AdoptionPreview.same_branch_project_exists_at`. - `src-tauri/src/workspaces_sync.rs` — sync module: `pull`, `push`, `try_sync_with_app`, `sync_workspaces`, `reconcile_from_snapshot`, `ServerWorkspace` wire type. - `src-tauri/src/commands/workspaces_sync.rs` — Tauri command surface: `workspaces_sync_list`, `workspaces_sync_now`, `workspaces_adoption_preview`, `workspaces_adopt_synced`. - `src-tauri/src/commands/hosts.rs` — `workspace_pull_back_impl` (extracted from the `#[tauri::command]` wrapper so the adoption flow can call the rsync machinery without going back through Tauri IPC). diff --git a/src-tauri/src/bin/codemux_remote.rs b/src-tauri/src/bin/codemux_remote.rs index 8a26ada9..2543c385 100644 --- a/src-tauri/src/bin/codemux_remote.rs +++ b/src-tauri/src/bin/codemux_remote.rs @@ -130,6 +130,24 @@ enum WorkspaceSubcommand { #[arg(long, default_value = "10")] connect_timeout_secs: u64, }, + /// Print every workspace in the daemon's SQLite registry as JSON + /// on stdout. Reads the database directly — no running daemon is + /// required. The desktop's host-inventory poller invokes this + /// over SSH so workspaces created on the host (via MCP tools or + /// the desktop's push flow) become visible across the user's + /// account without an explicit push from each device. + /// + /// Stable contract: stdout is exactly one JSON object of shape + /// `{"host_id":"","workspaces":[,...]}`, + /// where each Workspace matches `remote::workspace::Workspace`. + /// Stderr is unused on success; non-zero exit means the registry + /// could not be opened. + List { + /// State directory of the daemon. Defaults to the same path + /// `serve` defaults to. + #[arg(long)] + state_dir: Option, + }, } #[cfg(unix)] @@ -207,6 +225,7 @@ fn main() -> ExitCode { state_dir, connect_timeout_secs, ), + WorkspaceSubcommand::List { state_dir } => run_workspace_list(state_dir), }, } } @@ -679,3 +698,50 @@ fn run_workspace_register( println!("{}", workspace); ExitCode::SUCCESS } + +/// Implementation for `codemux-remote workspace list`. Opens the +/// daemon's SQLite registry directly (no HTTP, no running daemon +/// required) and prints `{"host_id":"...","workspaces":[...]}` to +/// stdout. +/// +/// Used by the desktop's host-inventory poller: every ~60 seconds the +/// desktop SSHes into every configured host and runs this command, then +/// reconciles the result into its own `workspaces_sync` table so the +/// account-wide overview surfaces host-side workspaces without each +/// device having to push from itself. +/// +/// We open the store read-only as far as workspaces go — we never call +/// `create`, so the `host_id` and `workspaces_root` args to +/// `WorkspaceStore::open` are only used to materialise the schema on +/// first run (and to create the workspaces root, which is harmless). +#[cfg(unix)] +fn run_workspace_list(state_dir_arg: Option) -> ExitCode { + use codemux_lib::remote::{config, manifest, workspace::WorkspaceStore}; + + let state_dir = resolve_state_dir(state_dir_arg); + let host_id = manifest::current_host_id(); + let store = match WorkspaceStore::open( + &config::database_path(&state_dir), + host_id.clone(), + config::workspaces_root(&state_dir), + ) { + Ok(s) => s, + Err(e) => { + eprintln!("[codemux-remote] open workspace store at {}: {e}", state_dir.display()); + return ExitCode::from(1); + } + }; + let workspaces = match store.list() { + Ok(w) => w, + Err(e) => { + eprintln!("[codemux-remote] list workspaces: {e}"); + return ExitCode::from(1); + } + }; + let payload = serde_json::json!({ + "host_id": host_id, + "workspaces": workspaces, + }); + println!("{}", payload); + ExitCode::SUCCESS +} diff --git a/src-tauri/src/commands/workspaces_sync.rs b/src-tauri/src/commands/workspaces_sync.rs index 4122b1e0..2224f5dd 100644 --- a/src-tauri/src/commands/workspaces_sync.rs +++ b/src-tauri/src/commands/workspaces_sync.rs @@ -154,6 +154,16 @@ pub async fn workspaces_sync_now( /// - `already_adopted_workspace_id`: short-circuit — if the user /// clicks Pull on a row they've already adopted, the dialog can /// skip itself and just open the existing workspace. +/// - `same_branch_project_exists_at`: stronger conflict guard than +/// `is_path_in_use`. Set to the local `workspace_id` of any other +/// workspace this device has open whose `(basename(project_path), +/// git_branch)` matches the row being previewed. That tuple is the +/// cross-device identity for "same branch of the same project," +/// even when the two devices store the repo at different absolute +/// paths. Pulling on top of such a row would silently create a +/// parallel copy of work the user is already doing — so the dialog +/// disables Pull and points at the existing workspace instead. Null +/// when no conflict is detected. #[derive(Debug, Clone, Serialize)] pub struct AdoptionPreview { pub can_host_adopt: bool, @@ -164,6 +174,7 @@ pub struct AdoptionPreview { pub suggested_path: String, pub is_path_in_use: bool, pub already_adopted_workspace_id: Option, + pub same_branch_project_exists_at: Option, } /// Successful adoption result. The frontend uses `workspace_id` to @@ -249,6 +260,25 @@ pub fn workspaces_adoption_preview( let already_adopted_workspace_id = row.workspace_id.clone(); + // Cross-machine same-branch-same-project guard. The dialog uses + // this to disable Pull and point the user at the existing local + // workspace instead of silently creating a parallel copy of work + // they're already doing on this device. + // + // We identify "same project" by the project_path basename (the + // closest cross-machine identity we have without git remote URL + // round-tripping — both devices may store the repo at different + // absolute paths). "Same branch" is straightforward via + // git_branch. + // + // Skip when the previewed row is itself the local one (already + // adopted) — that's the `already_adopted_workspace_id` short- + // circuit's job, not this guard's. + let same_branch_project_exists_at = detect_same_branch_project_conflict( + &db, + &row, + ); + Ok(AdoptionPreview { can_host_adopt: host_configured && local_host_id.is_some(), can_clone_adopt, @@ -258,9 +288,62 @@ pub fn workspaces_adoption_preview( suggested_path, is_path_in_use, already_adopted_workspace_id, + same_branch_project_exists_at, }) } +/// Walk every local sync row this device has a `workspace_id` for +/// and find one whose `(basename(project_path), git_branch)` matches +/// the previewed remote row. Returns the matching local workspace_id, +/// or None. +/// +/// The basename match is deliberate — across two devices the same git +/// repo will almost always be checked out at paths that share a final +/// segment (`~/projects/foo` here, `/home/deus/projects/foo` there). +/// Comparing the full path would miss this almost every time. False +/// positives are bounded: the user would have to maintain two +/// repositories on this device with the same basename and the same +/// branch, both with `project_path` recorded — extremely rare in +/// practice. +fn detect_same_branch_project_conflict( + db: &DatabaseStore, + previewed: &WorkspaceSyncRecord, +) -> Option { + let previewed_branch = previewed.git_branch.as_deref()?; + let previewed_basename = previewed + .project_path + .as_deref() + .and_then(|p| std::path::Path::new(p).file_name()) + .and_then(|n| n.to_str())?; + + for r in db.list_workspaces_sync() { + // Only compare against rows that DO correspond to a local + // workspace (workspace_id IS NOT NULL). Sibling-device rows + // are not "this device has it already" — skip them. + let Some(local_wid) = r.workspace_id.as_deref() else { + continue; + }; + // Don't flag the previewed row's own local copy. + if previewed.workspace_id.as_deref() == Some(local_wid) { + continue; + } + // Branch + project basename must both match. + if r.git_branch.as_deref() != Some(previewed_branch) { + continue; + } + let local_basename = r + .project_path + .as_deref() + .and_then(|p| std::path::Path::new(p).file_name()) + .and_then(|n| n.to_str()); + if local_basename != Some(previewed_basename) { + continue; + } + return Some(local_wid.to_string()); + } + None +} + /// Adopt a sibling-device workspace into this device via the /// host-backed rsync path. /// @@ -637,6 +720,198 @@ pub async fn workspaces_adopt_via_clone( }) } +#[cfg(test)] +mod tests { + use super::*; + use crate::database::DatabaseStore; + use serial_test::serial; + + // ── detect_same_branch_project_conflict ──────────────────── + // + // Guards the cross-machine "Pull would clobber work I'm already + // doing locally on the same branch" check. The detection is + // deliberately basename-based because two devices almost never + // store the same repo at the same absolute path. + + fn insert_local( + db: &DatabaseStore, + workspace_id: &str, + project_path: Option<&str>, + branch: Option<&str>, + ) { + db.insert_workspace_sync( + workspace_id, + "local-side", + None, + project_path, + None, + branch, + None, + ) + .unwrap(); + } + + fn make_remote_row( + project_path: Option<&str>, + branch: Option<&str>, + adopted_as: Option<&str>, + ) -> WorkspaceSyncRecord { + WorkspaceSyncRecord { + id: 0, + server_id: Some("srv-x".into()), + workspace_id: adopted_as.map(|s| s.into()), + title: "remote-side".into(), + host_server_id: Some("pandora".into()), + project_path: project_path.map(|s| s.into()), + project_remote: None, + git_branch: branch.map(|s| s.into()), + git_head_sha: None, + origin_uid: Some("uuid-1".into()), + created_at: "2026-01-01 00:00:00".into(), + updated_at: "2026-01-01 00:00:00".into(), + deleted_at: None, + dirty: false, + } + } + + #[test] + #[serial] + fn conflict_detection_matches_on_basename_and_branch() { + let db = DatabaseStore::new_in_memory(); + // Same project name as the remote, same branch, different + // absolute path (the realistic cross-device case). + insert_local( + &db, + "workspace-local", + Some("/home/zeus/projects/my-repo"), + Some("feature/x"), + ); + + let remote = make_remote_row( + Some("/home/deus/projects/my-repo"), + Some("feature/x"), + None, + ); + let hit = detect_same_branch_project_conflict(&db, &remote); + assert_eq!( + hit.as_deref(), + Some("workspace-local"), + "matching basename + matching branch must surface the local workspace id" + ); + } + + #[test] + #[serial] + fn conflict_detection_ignores_mismatched_branch() { + let db = DatabaseStore::new_in_memory(); + insert_local( + &db, + "workspace-local", + Some("/home/zeus/projects/my-repo"), + Some("main"), + ); + // Same project, different branch → not a conflict; two + // branches can legitimately exist as separate worktrees. + let remote = make_remote_row( + Some("/home/deus/projects/my-repo"), + Some("feature/x"), + None, + ); + assert!(detect_same_branch_project_conflict(&db, &remote).is_none()); + } + + #[test] + #[serial] + fn conflict_detection_ignores_mismatched_basename() { + let db = DatabaseStore::new_in_memory(); + insert_local( + &db, + "workspace-local", + Some("/home/zeus/projects/different-repo"), + Some("feature/x"), + ); + let remote = make_remote_row( + Some("/home/deus/projects/my-repo"), + Some("feature/x"), + None, + ); + assert!(detect_same_branch_project_conflict(&db, &remote).is_none()); + } + + #[test] + #[serial] + fn conflict_detection_ignores_the_rows_own_local_copy() { + // If the remote row was already adopted on this device, + // there's a local row that matches (basename, branch). + // That match is not a conflict — the `already_adopted_*` + // short-circuit is the right surface for it. + let db = DatabaseStore::new_in_memory(); + insert_local( + &db, + "workspace-local", + Some("/home/zeus/projects/my-repo"), + Some("feature/x"), + ); + let remote = make_remote_row( + Some("/home/deus/projects/my-repo"), + Some("feature/x"), + Some("workspace-local"), + ); + assert!( + detect_same_branch_project_conflict(&db, &remote).is_none(), + "the row's own adopted copy must not flag as a conflict" + ); + } + + #[test] + #[serial] + fn conflict_detection_ignores_sibling_only_rows() { + // Another sibling-device row with the same basename + branch + // is NOT a "this device already has it" conflict — it lives + // somewhere else. Skip rows without a local workspace_id. + let db = DatabaseStore::new_in_memory(); + // A sibling-only row pulled from the cloud. + db.upsert_workspace_sync_from_server( + "srv-other-sibling", + "another-sibling", + Some("other-host"), + Some("/home/eve/projects/my-repo"), + None, + Some("feature/x"), + None, + "2026-01-01 00:00:00", + "2026-01-01 00:00:00", + None, + ) + .unwrap(); + let remote = make_remote_row( + Some("/home/deus/projects/my-repo"), + Some("feature/x"), + None, + ); + assert!(detect_same_branch_project_conflict(&db, &remote).is_none()); + } + + #[test] + #[serial] + fn conflict_detection_returns_none_when_previewed_row_lacks_branch_or_path() { + let db = DatabaseStore::new_in_memory(); + insert_local( + &db, + "workspace-local", + Some("/home/zeus/projects/my-repo"), + Some("feature/x"), + ); + // Missing branch. + let no_branch = + make_remote_row(Some("/home/deus/projects/my-repo"), None, None); + assert!(detect_same_branch_project_conflict(&db, &no_branch).is_none()); + // Missing path. + let no_path = make_remote_row(None, Some("feature/x"), None); + assert!(detect_same_branch_project_conflict(&db, &no_path).is_none()); + } +} + /// Best-effort: extract a project name from a git remote URL when /// the synced row doesn't carry an explicit `project_path`. Handles /// the common cases: diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 455fc84d..61e1506e 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -352,6 +352,14 @@ fn create_schema(conn: &Connection) -> Result<(), String> { // the overview can flag when the same project+branch // exists on multiple devices with different HEADs. "ALTER TABLE workspaces_sync ADD COLUMN git_head_sha TEXT", + // Auto-publish: stable UUID assigned by the remote daemon + // (`remote::workspace::Workspace.id`) when the desktop + // discovers a workspace by polling a host. Lets the + // host-inventory reconcile pass dedupe across repeated polls + // — i.e. "did I already insert a sync row for this host's + // workspace UUID?" Always null for rows that originated on + // this device or arrived purely via cloud pull. + "ALTER TABLE workspaces_sync ADD COLUMN origin_uid TEXT", ] { if let Err(e) = conn.execute(stmt, []) { let msg = e.to_string(); @@ -907,6 +915,15 @@ pub struct WorkspaceSyncRecord { /// chip. None for new rows that haven't been reconciled yet, /// or for rows whose worktree had no commits. pub git_head_sha: Option, + /// Stable UUID assigned by the remote daemon + /// (`remote::workspace::Workspace.id`) when the desktop's + /// host-inventory poller discovered this row. Lets the poller's + /// reconcile step recognise the same remote workspace across + /// repeated polls — without it, every poll would create a fresh + /// sync row. Always `None` on rows that originated on this device + /// or arrived purely via cloud pull (the cloud schema does not + /// carry `origin_uid` today). + pub origin_uid: Option, pub created_at: String, pub updated_at: String, pub deleted_at: Option, @@ -948,7 +965,7 @@ impl DatabaseStore { conn.query_row( "SELECT id, server_id, workspace_id, title, host_server_id, project_path, project_remote, git_branch, git_head_sha, - created_at, updated_at, deleted_at, dirty + created_at, updated_at, deleted_at, dirty, origin_uid FROM workspaces_sync WHERE id = ?1", params![id], row_to_workspace_sync, @@ -1020,7 +1037,7 @@ impl DatabaseStore { let mut stmt = match conn.prepare( "SELECT id, server_id, workspace_id, title, host_server_id, project_path, project_remote, git_branch, git_head_sha, - created_at, updated_at, deleted_at, dirty + created_at, updated_at, deleted_at, dirty, origin_uid FROM workspaces_sync WHERE user_id = 'local' AND deleted_at IS NULL ORDER BY updated_at DESC", @@ -1040,7 +1057,7 @@ impl DatabaseStore { let mut stmt = match conn.prepare( "SELECT id, server_id, workspace_id, title, host_server_id, project_path, project_remote, git_branch, git_head_sha, - created_at, updated_at, deleted_at, dirty + created_at, updated_at, deleted_at, dirty, origin_uid FROM workspaces_sync WHERE user_id = 'local'", ) { @@ -1058,7 +1075,7 @@ impl DatabaseStore { let mut stmt = match conn.prepare( "SELECT id, server_id, workspace_id, title, host_server_id, project_path, project_remote, git_branch, git_head_sha, - created_at, updated_at, deleted_at, dirty + created_at, updated_at, deleted_at, dirty, origin_uid FROM workspaces_sync WHERE user_id = 'local' AND dirty = 1", ) { @@ -1187,6 +1204,189 @@ impl DatabaseStore { .map_err(|e| format!("Failed to link workspace sync row to local: {e}"))?; Ok(()) } + + // ── Host-inventory auto-publish helpers ───────────────────────── + // + // The desktop's host-inventory poller (see `hosts_inventory.rs`) + // periodically SSHes every configured host, fetches the remote + // daemon's workspace list, and reconciles it into the local + // `workspaces_sync` table as sibling-only rows (no local + // `workspace_id`, dirty=1) so the next `push()` tick uploads them + // to the cloud and other devices see them in the overview. + // + // Identity contract for these rows: + // + // - `host_server_id` = the configured host's `server_id` (the + // stable cross-device host identity). Required. + // - `origin_uid` = `remote::workspace::Workspace.id` (a UUID + // assigned by the host's daemon at workspace-create time). + // Required. Lets repeated polls update-in-place instead of + // creating duplicate rows. + // - `workspace_id` = NULL until the user adopts the row via + // "Pull to this device". + // + // The cloud schema does not carry `origin_uid` today; it stays + // local-only. Cross-device dedupe (two laptops both polling the + // same host) is best-effort by `find_remote_discovered_by_origin` + // alone — if Device B has not yet pulled the row Device A + // published, B may briefly create a parallel row that converges + // on the next pull cycle. Acceptable for v1; tracked in the docs. + + /// Find a remote-discovered sync row by `(host_server_id, + /// origin_uid)`. Used by the inventory reconcile to decide + /// insert-vs-update-in-place per poll tick. + /// + /// Returns `None` if no row with that pair exists, including the + /// case where a row exists with the same `origin_uid` but a + /// different `host_server_id` (the same UUID on two different + /// hosts must be treated as two distinct workspaces — UUIDs are + /// only unique within one host's registry, never assumed unique + /// across hosts). + pub fn find_workspace_sync_by_host_and_origin_uid( + &self, + host_server_id: &str, + origin_uid: &str, + ) -> Option { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn + .prepare( + "SELECT id, server_id, workspace_id, title, host_server_id, + project_path, project_remote, git_branch, git_head_sha, + created_at, updated_at, deleted_at, dirty, origin_uid + FROM workspaces_sync + WHERE user_id = 'local' + AND host_server_id = ?1 + AND origin_uid = ?2 + LIMIT 1", + ) + .ok()?; + stmt.query_row(params![host_server_id, origin_uid], row_to_workspace_sync) + .ok() + } + + /// Insert a sibling-only sync row discovered by polling a host's + /// inventory. `workspace_id` is intentionally NULL — the row is + /// only adopted as a local workspace when the user clicks "Pull + /// to this device". `dirty=1` so the next `push()` tick uploads + /// it to the cloud and other devices of the same account see it. + /// + /// `host_server_id` and `origin_uid` together identify the row + /// uniquely on this device (see `find_workspace_sync_by_host_and_origin_uid`). + pub fn insert_remote_discovered_workspace_sync( + &self, + host_server_id: &str, + origin_uid: &str, + title: &str, + project_path: Option<&str>, + project_remote: Option<&str>, + git_branch: Option<&str>, + ) -> Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO workspaces_sync + (user_id, workspace_id, title, host_server_id, + project_path, project_remote, git_branch, origin_uid, dirty) + VALUES ('local', NULL, ?1, ?2, ?3, ?4, ?5, ?6, 1)", + params![ + title, + host_server_id, + project_path, + project_remote, + git_branch, + origin_uid, + ], + ) + .map_err(|e| format!("Failed to insert remote-discovered workspace sync row: {e}"))?; + let id = conn.last_insert_rowid(); + conn.query_row( + "SELECT id, server_id, workspace_id, title, host_server_id, + project_path, project_remote, git_branch, git_head_sha, + created_at, updated_at, deleted_at, dirty, origin_uid + FROM workspaces_sync WHERE id = ?1", + params![id], + row_to_workspace_sync, + ) + .map_err(|e| format!("Failed to re-read inserted remote-discovered row: {e}")) + } + + /// Update mutable fields of a remote-discovered row (matched by + /// the row's primary `id`). Bumps `updated_at` and marks + /// `dirty=1` so the next push propagates the change. No-op on + /// soft-deleted rows, matching `update_workspace_sync_by_workspace_id`. + /// + /// Note we deliberately do NOT touch `host_server_id` or + /// `origin_uid` — those define the row's identity and must be + /// stable across reconciles. + pub fn update_remote_discovered_workspace_sync( + &self, + id: i64, + title: &str, + project_path: Option<&str>, + project_remote: Option<&str>, + git_branch: Option<&str>, + ) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE workspaces_sync + SET title = ?1, project_path = ?2, + project_remote = ?3, git_branch = ?4, + updated_at = datetime('now'), dirty = 1 + WHERE id = ?5 AND deleted_at IS NULL", + params![title, project_path, project_remote, git_branch, id], + ) + .map_err(|e| format!("Failed to update remote-discovered row: {e}"))?; + Ok(()) + } + + /// List every non-deleted remote-discovered row for a host. Used + /// by the inventory reconcile pass to compute the + /// "disappeared from the host" set: any row in this list whose + /// `origin_uid` is no longer in the host's current inventory must + /// be soft-deleted so the cloud row goes away on the next push. + pub fn list_remote_discovered_for_host( + &self, + host_server_id: &str, + ) -> Vec { + let conn = self.conn.lock().unwrap(); + let mut stmt = match conn.prepare( + "SELECT id, server_id, workspace_id, title, host_server_id, + project_path, project_remote, git_branch, git_head_sha, + created_at, updated_at, deleted_at, dirty, origin_uid + FROM workspaces_sync + WHERE user_id = 'local' + AND host_server_id = ?1 + AND origin_uid IS NOT NULL + AND deleted_at IS NULL", + ) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + stmt.query_map(params![host_server_id], row_to_workspace_sync) + .map(|rows| rows.filter_map(|r| r.ok()).collect()) + .unwrap_or_default() + } + + /// Soft-delete a remote-discovered row by primary `id`. Sets + /// `deleted_at` + `dirty=1` so the next push DELETEs the cloud + /// row. Unlike `soft_delete_workspace_sync_by_workspace_id`, + /// this targets the row's primary key directly because + /// remote-discovered rows have `workspace_id IS NULL`. + pub fn soft_delete_remote_discovered_workspace_sync_by_id( + &self, + id: i64, + ) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE workspaces_sync + SET deleted_at = datetime('now'), + updated_at = datetime('now'), + dirty = 1 + WHERE id = ?1 AND deleted_at IS NULL", + params![id], + ) + .map_err(|e| format!("Failed to soft-delete remote-discovered row: {e}"))?; + Ok(()) + } } fn row_to_workspace_sync( @@ -1207,6 +1407,7 @@ fn row_to_workspace_sync( updated_at: row.get(10)?, deleted_at: row.get(11)?, dirty: dirty_int != 0, + origin_uid: row.get(13)?, }) } diff --git a/src-tauri/src/hosts_inventory.rs b/src-tauri/src/hosts_inventory.rs new file mode 100644 index 00000000..af2b27d0 --- /dev/null +++ b/src-tauri/src/hosts_inventory.rs @@ -0,0 +1,691 @@ +//! Background host-inventory poller. +//! +//! Sister task to `hosts_upgrade.rs`. Where the upgrade poller keeps +//! every host's `codemux-remote` binary at the same version as the +//! desktop app, this poller keeps every host's *workspace inventory* +//! visible to the user's account. +//! +//! ## Why this exists +//! +//! Before this poller, a workspace only showed up in the cross-device +//! overview if some device explicitly pushed it to the host. That works +//! for the "I'm closing my laptop, continue on my server" flow, but it +//! misses the "I asked my agent on pandora to start a project last +//! night" flow: the agent created the workspace on the host via the +//! `workspace_create` MCP tool, the host's daemon recorded it, but no +//! desktop ever knew about it — so no desktop ever published it to the +//! cloud, so no other device ever saw it. +//! +//! This poller closes that gap. On a 60-second cadence, for every +//! configured host: +//! +//! 1. Probe the host is reachable AND has the right `codemux-remote` +//! binary installed (re-using `ssh::probe::probe_host`). +//! 2. SSH and run `codemux-remote workspace list` — a thin CLI we +//! added that reads the daemon's SQLite registry and prints +//! `{"host_id":"…","workspaces":[…]}` on stdout. The same +//! `~/.local/bin/codemux-remote` PATH fallback the probe uses +//! applies here, because non-interactive SSH on Arch/Ubuntu/etc. +//! doesn't source `~/.profile`. +//! 3. Reconcile the result into `workspaces_sync`: +//! - Each remote workspace gets a sibling-only row keyed by +//! `(host_server_id, origin_uid=remote_workspace.id)`. +//! - Repeated polls UPDATE in place (the row's cloud `server_id` +//! survives). +//! - Disappeared rows (origin_uid no longer in the inventory) are +//! soft-deleted so the next push DELETEs the cloud row. +//! 4. The existing `workspaces_sync::push` tick (every 30s) uploads +//! every dirty row, so other devices of the same account see the +//! workspace within ~30-90 seconds of it appearing on the host. +//! +//! ## Failure model +//! +//! Best-effort. Per-host budget caps SSH stalls. Any of: +//! +//! - host offline / SSH refused → log, continue +//! - host reachable but binary missing → log, continue (we don't try +//! to install it; that's the user's explicit consent in Settings +//! → Hosts) +//! - host has no `server_id` yet (host record hasn't synced to the +//! account) → skip silently — we'd have no stable identity to tag +//! the rows with, and the host_sync loop is the one in charge of +//! fixing that +//! - JSON parse failure → log the host + the first 200 chars of +//! stdout, continue +//! +//! The task never fails the app. +//! +//! ## Cross-device dedupe caveat (v1) +//! +//! `origin_uid` is local-only. The cloud schema doesn't carry it. So +//! if two of the user's devices both poll the same host before either +//! has pulled the other's cloud row, both will publish a sync row for +//! the same physical workspace and the user will briefly see two +//! entries in the overview. They converge on the next pull tick +//! because the cloud row Device A published shows up on Device B with +//! the same `host_server_id`, `project_remote`, and `git_branch`. +//! Single-device users (the common case) never hit this. +//! +//! Tracked as a known limitation in `docs/features/workspaces-sync.md`. + +#![cfg(unix)] + +use std::collections::HashSet; +use std::process::Stdio; +use std::time::Duration; + +use serde::Deserialize; +use tauri::{AppHandle, Manager}; +use tokio::process::Command; +use tokio::time::timeout; + +use crate::database::DatabaseStore; +use crate::ssh::probe::{probe_host, ProbeOptions, ProbeOutcome}; + +/// How often the poller runs after the first 5-second warm-up. +/// 60 seconds is the floor: combined with the 30-second push loop, +/// the worst-case "agent created a workspace on a host → seen on +/// another device" latency is ~90 seconds. Bumping this lower +/// burns SSH connections (one per host per tick) without a +/// proportional UX win. +const POLL_INTERVAL: Duration = Duration::from_secs(60); + +/// Hard ceiling on how long any single host's poll can take — +/// probe + workspace-list combined. Hosts that exceed this are +/// logged and skipped for the cycle. +const PER_HOST_BUDGET: Duration = Duration::from_secs(20); + +/// Spawn the background inventory poller. Must be called once during +/// app setup. Like `hosts_upgrade`, it delays a few seconds so the +/// app's initial paint isn't competing with us for resources. +pub fn spawn(app: AppHandle) { + tauri::async_runtime::spawn(async move { + // Warm-up: let the UI settle, and give `hosts_upgrade::spawn` + // (which fires at +5s) a head start so any host whose binary + // is mid-upgrade has already become consistent before we + // start polling its registry. Otherwise we'd race against + // the upgrade and might briefly see an empty/half-migrated + // inventory. + tokio::time::sleep(Duration::from_secs(12)).await; + loop { + run_once(&app).await; + tokio::time::sleep(POLL_INTERVAL).await; + } + }); +} + +/// One pass over every configured host. Public so tests / debug +/// surfaces can drive a single cycle without the loop. +pub async fn run_once(app: &AppHandle) { + let hosts = match app.try_state::() { + Some(state) => state.list_hosts(), + None => { + eprintln!( + "[hosts_inventory] database state unavailable; skipping inventory poll" + ); + return; + } + }; + if hosts.is_empty() { + return; + } + + let db = app.state::(); + for host in hosts { + // Skip hosts that haven't synced their identity yet. Without a + // `server_id` we can't tag the inventory rows with a stable + // cross-device host identity, and any rows we created would + // never match up against `WorkspaceSnapshot.host_id` on this + // device or any other. + let host_server_id = match &host.server_id { + Some(sid) => sid.clone(), + None => continue, + }; + + let outcome = timeout( + PER_HOST_BUDGET, + poll_one_host(&host.ssh_target, &host_server_id, &db), + ) + .await; + match outcome { + Ok(Ok(stats)) => { + if stats.changed() { + eprintln!( + "[hosts_inventory] {} synced ({} discovered, {} updated, {} disappeared)", + host.name, stats.inserted, stats.updated, stats.soft_deleted + ); + } + } + Ok(Err(error)) => { + eprintln!("[hosts_inventory] {} skipped: {error}", host.name); + } + Err(_) => { + eprintln!( + "[hosts_inventory] {} timed out (>{}s) — host slow or offline", + host.name, + PER_HOST_BUDGET.as_secs() + ); + } + } + } +} + +/// Per-host counters, returned by `poll_one_host` so the run-loop +/// can decide whether the host did any meaningful work this tick +/// (we only log when something changed, to keep steady-state logs +/// quiet). +#[derive(Default, Debug, Clone, Copy)] +pub struct PollStats { + pub inserted: usize, + pub updated: usize, + pub soft_deleted: usize, +} + +impl PollStats { + fn changed(&self) -> bool { + self.inserted > 0 || self.updated > 0 || self.soft_deleted > 0 + } +} + +async fn poll_one_host( + ssh_target: &str, + host_server_id: &str, + db: &DatabaseStore, +) -> Result { + // Step 1: probe so we don't spend the inventory budget on a + // host that's offline or doesn't have the binary. Re-uses the + // same fallback-aware command the test-connection flow uses. + match probe_host(ProbeOptions::new(ssh_target)).await { + ProbeOutcome::Reachable { + codemux_remote_version: Some(_), + .. + } => {} + ProbeOutcome::Reachable { + codemux_remote_version: None, + .. + } => { + return Err( + "codemux-remote not installed (use Settings → Hosts → Install)".into() + ); + } + ProbeOutcome::Unreachable { reason } => { + return Err(format!("unreachable: {reason}")); + } + } + + // Step 2: fetch the inventory. + let inventory = fetch_inventory(ssh_target).await?; + let parsed = parse_inventory_json(&inventory) + .map_err(|e| format!("parse inventory: {e}"))?; + + // Step 3: reconcile. + Ok(reconcile_host_inventory(db, host_server_id, &parsed)) +} + +/// SSH into the host and capture `codemux-remote workspace list` +/// stdout. Same SSH flags as the probe (BatchMode, ConnectTimeout, +/// StrictHostKeyChecking) but a different remote command. +async fn fetch_inventory(ssh_target: &str) -> Result { + let argv = build_inventory_argv(ssh_target, PER_HOST_BUDGET.as_secs()); + let mut cmd = Command::new("ssh"); + for arg in &argv { + cmd.arg(arg); + } + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let result = timeout(PER_HOST_BUDGET, async { cmd.output().await }).await; + let output = match result { + Ok(Ok(o)) => o, + Ok(Err(e)) => return Err(format!("ssh: {e}")), + Err(_) => return Err("ssh inventory fetch timed out".into()), + }; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("ssh exited with status {}", output.status) + } else { + stderr + }); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +/// Build the argv for the inventory SSH call. Extracted so unit +/// tests can lock in the exact flags + remote command (especially +/// the `$HOME/.local/bin/codemux-remote` fallback — losing that on +/// Arch/Ubuntu/etc. would silently break the poller for every user +/// who installed via Settings → Hosts → Install, because +/// non-interactive SSH shells don't put `~/.local/bin` on PATH). +pub fn build_inventory_argv(ssh_target: &str, timeout_secs: u64) -> Vec { + vec![ + "-o".into(), + "BatchMode=yes".into(), + "-o".into(), + format!("ConnectTimeout={timeout_secs}"), + "-o".into(), + "StrictHostKeyChecking=accept-new".into(), + ssh_target.into(), + // Same PATH-fallback story as the probe (see ssh/probe.rs): + // bootstrap installs to ~/.local/bin, but non-interactive + // SSH typically doesn't have that dir on PATH. Without the + // absolute-path fallback the poller silently degrades to + // "no inventory" the moment a user installs via the desktop's + // Install button. + "if command -v codemux-remote >/dev/null 2>&1 ; then \ + codemux-remote workspace list ; \ + elif [ -x \"$HOME/.local/bin/codemux-remote\" ] ; then \ + \"$HOME/.local/bin/codemux-remote\" workspace list ; \ + else \ + echo 'CMR_MISSING' >&2 ; exit 1 ; \ + fi" + .into(), + ] +} + +/// Wire shape produced by `codemux-remote workspace list` on the +/// host. Matches `bin/codemux_remote.rs::run_workspace_list` — +/// any change there must be mirrored here. +#[derive(Debug, Clone, Deserialize)] +pub struct InventoryEnvelope { + #[allow(dead_code)] // recorded for debug logs only + pub host_id: String, + pub workspaces: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RemoteWorkspace { + pub id: String, + pub name: String, + pub path: String, + pub branch: Option, + pub project_root: Option, + // `owner_id`, `origin_host_id`, `notes`, `created_at`, + // `updated_at` are accepted by serde but not used by the + // reconcile — they live on the remote daemon and have no + // mapping on this side today. Leaving them off the struct + // (with serde's default of ignoring unknown fields) keeps + // the parser robust to future remote-side schema additions. +} + +/// Parse the daemon's `workspace list` stdout. Public for tests. +pub fn parse_inventory_json(stdout: &str) -> Result { + serde_json::from_str(stdout.trim()).map_err(|e| { + let preview: String = stdout.chars().take(200).collect(); + format!("not valid inventory JSON: {e}; first 200 chars: {preview:?}") + }) +} + +/// Apply a freshly-fetched inventory snapshot to the local +/// `workspaces_sync` table for one host. Returns counts the run +/// loop uses to decide whether to log this tick. +/// +/// Algorithm: +/// +/// 1. Index local "remote-discovered for this host" rows by +/// `origin_uid` so we can tell INSERT from UPDATE in one pass. +/// 2. For each inventory row, INSERT (if no match) or UPDATE in +/// place (if matched — `server_id` survives, `dirty` is set). +/// 3. Any local rows whose `origin_uid` is no longer in the +/// inventory get soft-deleted so the next push DELETEs the +/// cloud row. +/// +/// Public so tests can drive it directly without an SSH stub. +pub fn reconcile_host_inventory( + db: &DatabaseStore, + host_server_id: &str, + inventory: &InventoryEnvelope, +) -> PollStats { + let mut stats = PollStats::default(); + + // Index local rows by origin_uid for O(1) lookup during the walk. + let local_rows = db.list_remote_discovered_for_host(host_server_id); + let local_by_uid: std::collections::HashMap = + local_rows + .iter() + .filter_map(|r| r.origin_uid.clone().map(|uid| (uid, r))) + .collect(); + + let mut seen_uids: HashSet = HashSet::new(); + for ws in &inventory.workspaces { + seen_uids.insert(ws.id.clone()); + + // We use `project_root` (the originating repo root on the + // host) as a `project_path` analog — it's the closest the + // remote daemon's schema has to "where this workspace + // came from." Better than nothing for the UI's "open in + // file manager" affordance. + let project_path = ws.project_root.as_deref(); + let project_remote: Option<&str> = None; // not in the remote schema today + let branch = ws.branch.as_deref(); + + if let Some(existing) = local_by_uid.get(&ws.id) { + // Only push an UPDATE when something actually changed — + // otherwise we'd flip `dirty=1` on every tick and waste + // a cloud PATCH per workspace per minute. + let changed = existing.title != ws.name + || existing.project_path.as_deref() != project_path + || existing.project_remote.as_deref() != project_remote + || existing.git_branch.as_deref() != branch; + if changed { + if let Err(e) = db.update_remote_discovered_workspace_sync( + existing.id, + &ws.name, + project_path, + project_remote, + branch, + ) { + eprintln!( + "[hosts_inventory] update failed for {host_server_id}/{}: {e}", + ws.id + ); + continue; + } + stats.updated += 1; + } + } else if let Err(e) = db.insert_remote_discovered_workspace_sync( + host_server_id, + &ws.id, + &ws.name, + project_path, + project_remote, + branch, + ) { + eprintln!( + "[hosts_inventory] insert failed for {host_server_id}/{}: {e}", + ws.id + ); + continue; + } else { + stats.inserted += 1; + } + } + + // Soft-delete any local row whose origin_uid is no longer in + // the inventory. The next push DELETEs the cloud row so other + // devices learn of the disappearance. + for local in &local_rows { + if let Some(uid) = &local.origin_uid { + if !seen_uids.contains(uid) { + if let Err(e) = + db.soft_delete_remote_discovered_workspace_sync_by_id(local.id) + { + eprintln!( + "[hosts_inventory] soft-delete failed for id={}: {e}", + local.id + ); + continue; + } + stats.soft_deleted += 1; + } + } + } + + stats +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::database::DatabaseStore; + use serial_test::serial; + + fn fresh_db() -> DatabaseStore { + DatabaseStore::new_in_memory() + } + + fn make_remote(id: &str, name: &str, branch: Option<&str>) -> RemoteWorkspace { + RemoteWorkspace { + id: id.into(), + name: name.into(), + path: format!("/srv/{name}"), + branch: branch.map(|s| s.into()), + project_root: Some("/srv/origin".into()), + } + } + + fn make_envelope(workspaces: Vec) -> InventoryEnvelope { + InventoryEnvelope { + host_id: "test-host".into(), + workspaces, + } + } + + // ── argv lock-in ──────────────────────────────────────────── + + #[test] + fn build_inventory_argv_has_path_fallback_and_batch_mode() { + // The PATH + ~/.local/bin/codemux-remote fallback is the + // entire reason this poller works on a freshly-installed + // host — losing it would silently break the feature for + // every user who installed via Settings → Hosts → Install. + let argv = build_inventory_argv("user@10.0.0.7", 15); + assert!(argv.iter().any(|a| a == "BatchMode=yes")); + assert!(argv.iter().any(|a| a == "ConnectTimeout=15")); + assert!(argv.iter().any(|a| a == "StrictHostKeyChecking=accept-new")); + assert!(argv.iter().any(|a| a == "user@10.0.0.7")); + let cmd = argv.last().unwrap(); + assert!( + cmd.contains("command -v codemux-remote"), + "fast-path PATH lookup must remain" + ); + assert!( + cmd.contains("$HOME/.local/bin/codemux-remote"), + "absolute-path fallback must remain (Arch/Ubuntu non-interactive SSH \ + doesn't have ~/.local/bin on PATH)" + ); + assert!( + cmd.contains("workspace list"), + "remote command must be the workspace list subcommand" + ); + } + + // ── parse ────────────────────────────────────────────────── + + #[test] + fn parse_inventory_json_round_trip() { + let stdout = r#"{ + "host_id": "pandora", + "workspaces": [ + { + "id": "11111111-2222-3333-4444-555555555555", + "name": "alpha", + "path": "/srv/alpha", + "branch": "main", + "project_root": "/srv/alpha-origin", + "created_at": "2026-05-01T00:00:00Z", + "updated_at": "2026-05-01T00:00:00Z", + "owner_id": null, + "origin_host_id": "pandora", + "notes": null + } + ] + }"#; + let parsed = parse_inventory_json(stdout).unwrap(); + assert_eq!(parsed.host_id, "pandora"); + assert_eq!(parsed.workspaces.len(), 1); + let w = &parsed.workspaces[0]; + assert_eq!(w.id, "11111111-2222-3333-4444-555555555555"); + assert_eq!(w.name, "alpha"); + assert_eq!(w.branch.as_deref(), Some("main")); + assert_eq!(w.project_root.as_deref(), Some("/srv/alpha-origin")); + } + + #[test] + fn parse_inventory_json_rejects_garbage_with_useful_message() { + let result = parse_inventory_json("not even close to JSON"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + err.contains("first 200 chars"), + "error must include a stdout preview so we can debug what the host \ + actually returned: got {err}" + ); + } + + // ── reconcile ─────────────────────────────────────────────── + + #[test] + #[serial] + fn reconcile_inserts_new_remote_workspaces() { + let db = fresh_db(); + let envelope = make_envelope(vec![ + make_remote("uid-1", "alpha", Some("main")), + make_remote("uid-2", "beta", Some("dev")), + ]); + let stats = reconcile_host_inventory(&db, "host-99", &envelope); + assert_eq!(stats.inserted, 2); + assert_eq!(stats.updated, 0); + assert_eq!(stats.soft_deleted, 0); + + let rows = db.list_remote_discovered_for_host("host-99"); + assert_eq!(rows.len(), 2); + for r in &rows { + assert!(r.workspace_id.is_none(), "sibling-only rows"); + assert!(r.server_id.is_none(), "no cloud id until first push"); + assert!(r.dirty); + assert_eq!(r.host_server_id.as_deref(), Some("host-99")); + } + } + + #[test] + #[serial] + fn reconcile_is_idempotent_when_nothing_changed() { + // Two identical polls back-to-back must NOT mark every row + // dirty again — otherwise steady-state syncing would burn + // one PATCH per workspace per poll for no reason. + let db = fresh_db(); + let envelope = make_envelope(vec![make_remote("uid-1", "alpha", Some("main"))]); + let first = reconcile_host_inventory(&db, "host-99", &envelope); + assert_eq!(first.inserted, 1); + // Simulate the first push clearing dirty. + let row = db + .find_workspace_sync_by_host_and_origin_uid("host-99", "uid-1") + .unwrap(); + db.mark_workspace_sync_synced(row.id, Some("cloud-1")).unwrap(); + assert!(!db + .find_workspace_sync_by_host_and_origin_uid("host-99", "uid-1") + .unwrap() + .dirty); + + // Re-poll with identical inventory. + let second = reconcile_host_inventory(&db, "host-99", &envelope); + assert_eq!(second.inserted, 0, "no inserts on identical re-poll"); + assert_eq!(second.updated, 0, "no updates on identical re-poll"); + assert!( + !db.find_workspace_sync_by_host_and_origin_uid("host-99", "uid-1") + .unwrap() + .dirty, + "identical re-poll must not mark the row dirty again" + ); + } + + #[test] + #[serial] + fn reconcile_updates_in_place_when_remote_renames() { + let db = fresh_db(); + let initial = make_envelope(vec![make_remote("uid-1", "original-name", Some("main"))]); + reconcile_host_inventory(&db, "host-99", &initial); + // Pretend the first push assigned a server_id. + let row = db + .find_workspace_sync_by_host_and_origin_uid("host-99", "uid-1") + .unwrap(); + db.mark_workspace_sync_synced(row.id, Some("cloud-7")).unwrap(); + + // The host renamed the workspace (e.g. via the `workspace_update` + // MCP tool). Same UUID, new title. + let renamed = + make_envelope(vec![make_remote("uid-1", "renamed-on-host", Some("main"))]); + let stats = reconcile_host_inventory(&db, "host-99", &renamed); + assert_eq!(stats.inserted, 0); + assert_eq!(stats.updated, 1); + assert_eq!(stats.soft_deleted, 0); + + let after = db + .find_workspace_sync_by_host_and_origin_uid("host-99", "uid-1") + .unwrap(); + assert_eq!(after.title, "renamed-on-host"); + assert_eq!( + after.server_id.as_deref(), + Some("cloud-7"), + "in-place update must preserve the cloud server_id" + ); + assert!(after.dirty, "title change must mark the row dirty so push propagates"); + } + + #[test] + #[serial] + fn reconcile_soft_deletes_when_remote_workspace_disappears() { + let db = fresh_db(); + let initial = make_envelope(vec![ + make_remote("uid-1", "kept", Some("main")), + make_remote("uid-2", "doomed", Some("dev")), + ]); + reconcile_host_inventory(&db, "host-99", &initial); + assert_eq!(db.list_remote_discovered_for_host("host-99").len(), 2); + + // Re-poll with uid-2 missing — e.g. the host's daemon + // received `workspace_close uid-2`. + let after = make_envelope(vec![make_remote("uid-1", "kept", Some("main"))]); + let stats = reconcile_host_inventory(&db, "host-99", &after); + assert_eq!(stats.inserted, 0); + assert_eq!(stats.updated, 0); + assert_eq!(stats.soft_deleted, 1); + + // The remaining live row is uid-1. + let live = db.list_remote_discovered_for_host("host-99"); + assert_eq!(live.len(), 1); + assert_eq!(live[0].origin_uid.as_deref(), Some("uid-1")); + + // The doomed row is a tombstone visible to the sync-loop list. + let tombstone = db + .list_workspaces_sync_for_sync() + .into_iter() + .find(|r| r.origin_uid.as_deref() == Some("uid-2")) + .expect("tombstone must remain in *_for_sync until push acknowledges"); + assert!(tombstone.deleted_at.is_some()); + assert!( + tombstone.dirty, + "tombstone must be dirty so push DELETEs the cloud row" + ); + } + + #[test] + #[serial] + fn reconcile_scope_is_per_host() { + // Two hosts can legitimately have the same UUID in their + // registries (UUIDs are unique within a registry, never + // assumed unique across hosts). The reconcile must never + // confuse Host A's workspace with Host B's because they + // happen to share an `id`. + let db = fresh_db(); + let env_a = make_envelope(vec![make_remote("uid-shared", "from-A", Some("main"))]); + let env_b = make_envelope(vec![make_remote("uid-shared", "from-B", Some("main"))]); + + reconcile_host_inventory(&db, "host-A", &env_a); + reconcile_host_inventory(&db, "host-B", &env_b); + + let row_a = db + .find_workspace_sync_by_host_and_origin_uid("host-A", "uid-shared") + .expect("host-A row"); + let row_b = db + .find_workspace_sync_by_host_and_origin_uid("host-B", "uid-shared") + .expect("host-B row"); + assert_ne!(row_a.id, row_b.id, "rows must be distinct per host"); + assert_eq!(row_a.title, "from-A"); + assert_eq!(row_b.title, "from-B"); + + // And a poll for host-A with the workspace gone must NOT + // soft-delete host-B's row. + let empty = make_envelope(Vec::new()); + let stats = reconcile_host_inventory(&db, "host-A", &empty); + assert_eq!(stats.soft_deleted, 1); + assert_eq!(db.list_remote_discovered_for_host("host-A").len(), 0); + assert_eq!( + db.list_remote_discovered_for_host("host-B").len(), + 1, + "polling host-A must not touch host-B's rows" + ); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 87000285..06f77696 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -64,6 +64,7 @@ pub mod settings_sync; pub mod hosts_sync; #[cfg(unix)] pub mod hosts_upgrade; +pub mod hosts_inventory; pub mod workspace_paths; pub mod workspaces_sync; pub mod state; @@ -612,6 +613,20 @@ pub fn run() { #[cfg(unix)] crate::hosts_upgrade::spawn(app.handle().clone()); + // Background host-inventory poller. Asymmetric companion + // to the upgrade loop: where the upgrade loop keeps the + // remote `codemux-remote` binary at the right version, + // this loop keeps the remote daemon's *workspace registry* + // visible to the user's account. Without it, a workspace + // an agent creates on a host (via the MCP `workspace_create` + // tool) never appears in any dev device's overview because + // no laptop ever pushed it. See `docs/features/workspaces-sync.md` + // for the "asymmetric publish" model: codemux-remote hosts + // auto-publish, codemux-app dev devices keep their existing + // explicit push/pull. + #[cfg(unix)] + crate::hosts_inventory::spawn(app.handle().clone()); + // Resolve the bundled claude-agent sidecar from Tauri's resource // dir and pin the path via env var so the adapter (which has no // AppHandle access at construction time) can find it. Only one diff --git a/src-tauri/src/workspaces_sync.rs b/src-tauri/src/workspaces_sync.rs index 0c8f912a..4e36eae2 100644 --- a/src-tauri/src/workspaces_sync.rs +++ b/src-tauri/src/workspaces_sync.rs @@ -884,6 +884,196 @@ mod tests { assert_eq!(after[0].title, "from-sibling"); } + // ── Host-inventory auto-publish helpers ───────────────────── + // + // These guard the contract that the inventory reconcile relies + // on: `(host_server_id, origin_uid)` is a stable identity that + // dedupes across repeated polls; the rest of the fields can + // change in place without losing the cloud `server_id` once it's + // been assigned by the first push. + + #[test] + #[serial] + fn insert_remote_discovered_row_is_dirty_and_carries_origin_uid() { + let db = fresh_db(); + let row = db + .insert_remote_discovered_workspace_sync( + "host-1", + "uuid-abc", + "discovered-on-host", + Some("/srv/discovered"), + Some("git@github.com:user/repo.git"), + Some("main"), + ) + .expect("insert remote-discovered"); + assert!(row.workspace_id.is_none(), "remote-discovered rows must have NULL workspace_id"); + assert!(row.server_id.is_none(), "no cloud server_id until first push"); + assert!(row.dirty, "row must be dirty so push uploads it"); + assert_eq!(row.host_server_id.as_deref(), Some("host-1")); + assert_eq!(row.origin_uid.as_deref(), Some("uuid-abc")); + assert_eq!(row.title, "discovered-on-host"); + + // The lookup function returns this row when queried by + // (host, uid) and rejects mismatching pairs. + let found = db.find_workspace_sync_by_host_and_origin_uid("host-1", "uuid-abc"); + assert!(found.is_some()); + assert_eq!(found.unwrap().id, row.id); + + assert!( + db.find_workspace_sync_by_host_and_origin_uid("host-1", "uuid-other").is_none(), + "wrong uid on the same host must not match" + ); + assert!( + db.find_workspace_sync_by_host_and_origin_uid("host-other", "uuid-abc").is_none(), + "same uid on a different host must not match — UUIDs are only \ + unique within a single host's registry" + ); + } + + #[test] + #[serial] + fn update_remote_discovered_keeps_server_id_and_marks_dirty() { + // The poll reconcile must update mutable fields in place + // without dropping the cloud `server_id` the first push + // assigned — otherwise other devices would see a brand-new + // workspace appear every poll cycle instead of seeing the + // existing one update. + let db = fresh_db(); + let row = db + .insert_remote_discovered_workspace_sync( + "host-7", + "uuid-zeta", + "first-title", + None, + None, + Some("main"), + ) + .unwrap(); + // Simulate the first push assigning a cloud server_id. + db.mark_workspace_sync_synced(row.id, Some("9001")).unwrap(); + let after_push = db + .find_workspace_sync_by_host_and_origin_uid("host-7", "uuid-zeta") + .unwrap(); + assert_eq!(after_push.server_id.as_deref(), Some("9001")); + assert!(!after_push.dirty); + + db.update_remote_discovered_workspace_sync( + row.id, + "renamed-on-host", + Some("/srv/new"), + Some("git@github.com:user/repo.git"), + Some("feature/x"), + ) + .unwrap(); + + let after_update = db + .find_workspace_sync_by_host_and_origin_uid("host-7", "uuid-zeta") + .unwrap(); + assert_eq!(after_update.title, "renamed-on-host"); + assert_eq!(after_update.git_branch.as_deref(), Some("feature/x")); + assert_eq!( + after_update.server_id.as_deref(), + Some("9001"), + "cloud server_id must survive a remote-discovered update" + ); + assert!( + after_update.dirty, + "update must mark the row dirty so push propagates the change" + ); + assert_eq!( + after_update.host_server_id.as_deref(), + Some("host-7"), + "host_server_id is identity — must not move on update" + ); + assert_eq!( + after_update.origin_uid.as_deref(), + Some("uuid-zeta"), + "origin_uid is identity — must not move on update" + ); + } + + #[test] + #[serial] + fn list_remote_discovered_for_host_is_scoped() { + let db = fresh_db(); + db.insert_remote_discovered_workspace_sync("host-A", "uid-1", "a1", None, None, None) + .unwrap(); + db.insert_remote_discovered_workspace_sync("host-A", "uid-2", "a2", None, None, None) + .unwrap(); + db.insert_remote_discovered_workspace_sync("host-B", "uid-3", "b1", None, None, None) + .unwrap(); + // A local-on-this-device row with no origin_uid must not + // appear in the host scope, even if it carries the same + // host_server_id (e.g. a workspace pushed from this device). + db.insert_workspace_sync( + "workspace-local", + "local-pushed", + Some("host-A"), + None, + None, + None, + None, + ) + .unwrap(); + + let a = db.list_remote_discovered_for_host("host-A"); + assert_eq!(a.len(), 2, "scoped to host A and only origin_uid IS NOT NULL"); + let titles: Vec<&str> = a.iter().map(|r| r.title.as_str()).collect(); + assert!(titles.contains(&"a1") && titles.contains(&"a2")); + + let b = db.list_remote_discovered_for_host("host-B"); + assert_eq!(b.len(), 1); + assert_eq!(b[0].title, "b1"); + + let empty = db.list_remote_discovered_for_host("host-nonexistent"); + assert!(empty.is_empty()); + } + + #[test] + #[serial] + fn soft_delete_remote_discovered_marks_dirty_and_hides_from_overview() { + let db = fresh_db(); + let row = db + .insert_remote_discovered_workspace_sync( + "host-Z", + "uid-doomed", + "to-be-deleted", + None, + None, + None, + ) + .unwrap(); + db.mark_workspace_sync_synced(row.id, Some("5555")).unwrap(); + assert_eq!(db.list_remote_discovered_for_host("host-Z").len(), 1); + assert_eq!(db.list_workspaces_sync().len(), 1); + + db.soft_delete_remote_discovered_workspace_sync_by_id(row.id) + .unwrap(); + + assert_eq!( + db.list_remote_discovered_for_host("host-Z").len(), + 0, + "soft-deleted rows must drop out of the per-host live list" + ); + assert_eq!( + db.list_workspaces_sync().len(), + 0, + "overview must hide soft-deleted remote-discovered rows immediately" + ); + let tombstone = db + .list_workspaces_sync_for_sync() + .into_iter() + .find(|r| r.id == row.id) + .expect("tombstone row still present in *_for_sync"); + assert!(tombstone.deleted_at.is_some()); + assert!(tombstone.dirty, "tombstone must be dirty so push DELETEs the cloud row"); + assert_eq!( + tombstone.server_id.as_deref(), + Some("5555"), + "cloud server_id must survive the soft-delete so push knows the target" + ); + } + #[test] #[serial] fn delete_a_row_that_never_synced_is_a_local_no_op() { diff --git a/src-tauri/tests/codemux_remote_binary.rs b/src-tauri/tests/codemux_remote_binary.rs index dfbfabf5..6de80db8 100644 --- a/src-tauri/tests/codemux_remote_binary.rs +++ b/src-tauri/tests/codemux_remote_binary.rs @@ -161,3 +161,117 @@ async fn daemon_subcommand_accepts_client_connections() { let _ = child.kill(); let _ = child.wait(); } + +/// `codemux-remote workspace list` reads the daemon's SQLite registry +/// directly and prints a stable JSON envelope on stdout. The desktop's +/// host-inventory poller invokes this over SSH on a recurring cadence, +/// so the shape is a wire contract: keep this test in lockstep with +/// any change to the JSON envelope. +/// +/// We exercise three properties: +/// 1. An empty registry produces `{"host_id":"…","workspaces":[]}` +/// (no panic, no error exit, no extra noise on stdout). +/// 2. A non-empty registry round-trips every documented field of +/// `remote::workspace::Workspace` (id, name, path, branch, +/// project_root, origin_host_id, owner_id null, notes null, +/// created_at, updated_at) — these are exactly the fields the +/// desktop reconcile pass consumes, and a silent omission would +/// surface as missing data in the overview. +/// 3. The implementation works against an arbitrary `--state-dir` so +/// tests + SSH calls into per-user state dirs don't collide. +#[test] +fn workspace_list_subcommand_prints_inventory_json() { + let bin = binary_path(); + if !bin.exists() { + return; + } + let tmp = TempDir::new().unwrap(); + let state_dir = tmp.path().join("state"); + + // 1. Empty state-dir → empty workspaces array but valid envelope. + let empty = Command::new(&bin) + .args([ + "workspace", + "list", + "--state-dir", + ]) + .arg(&state_dir) + .output() + .expect("invoke binary"); + assert!( + empty.status.success(), + "workspace list (empty) failed: stderr={}", + String::from_utf8_lossy(&empty.stderr) + ); + let stdout = String::from_utf8(empty.stdout).expect("utf-8 stdout"); + let parsed: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("valid JSON envelope"); + assert!(parsed["host_id"].is_string(), "host_id must be a string"); + assert!(parsed["workspaces"].is_array(), "workspaces must be an array"); + assert_eq!( + parsed["workspaces"].as_array().unwrap().len(), + 0, + "fresh state dir must return an empty workspaces array" + ); + + // 2. Seed the registry by opening WorkspaceStore directly and + // creating one workspace, then re-run the CLI and assert the + // round-trip. + { + use codemux_lib::remote::{config, workspace::WorkspaceStore}; + let store = WorkspaceStore::open( + &config::database_path(&state_dir), + "fixture-host".into(), + config::workspaces_root(&state_dir), + ) + .expect("open store"); + let ws = store + .create( + Some("inventory-test".into()), + "/srv/inventory-test".into(), + Some("feature/inventory".into()), + Some("/srv/origin".into()), + ) + .expect("create workspace"); + assert!(!ws.id.is_empty()); + } + + let populated = Command::new(&bin) + .args([ + "workspace", + "list", + "--state-dir", + ]) + .arg(&state_dir) + .output() + .expect("invoke binary"); + assert!( + populated.status.success(), + "workspace list (populated) failed: stderr={}", + String::from_utf8_lossy(&populated.stderr) + ); + let stdout = String::from_utf8(populated.stdout).expect("utf-8 stdout"); + let parsed: serde_json::Value = + serde_json::from_str(stdout.trim()).expect("valid JSON envelope"); + let workspaces = parsed["workspaces"] + .as_array() + .expect("workspaces is an array"); + assert_eq!(workspaces.len(), 1, "exactly the workspace we just created"); + let w = &workspaces[0]; + assert!(w["id"].is_string(), "id must be a string (UUID)"); + assert_eq!(w["name"], "inventory-test"); + assert_eq!(w["path"], "/srv/inventory-test"); + assert_eq!(w["branch"], "feature/inventory"); + assert_eq!(w["project_root"], "/srv/origin"); + assert_eq!( + w["origin_host_id"], "fixture-host", + "origin_host_id round-trips through the registry" + ); + assert!( + w["owner_id"].is_null(), + "owner_id is null in v1 (reserved for cloud relay)" + ); + assert!(w["notes"].is_null(), "notes is null until the desktop attaches some"); + assert!(w["created_at"].is_string()); + assert!(w["updated_at"].is_string()); +} diff --git a/src-tauri/tests/codemux_remote_inventory.rs b/src-tauri/tests/codemux_remote_inventory.rs new file mode 100644 index 00000000..72a2440e --- /dev/null +++ b/src-tauri/tests/codemux_remote_inventory.rs @@ -0,0 +1,342 @@ +//! End-to-end integration test for the host-inventory auto-publish +//! flow. +//! +//! Exercises the real CLI → real parser → real reconciler pipeline, +//! without needing SSH: the desktop's poller, at runtime, runs +//! `codemux-remote workspace list` over SSH and feeds the stdout into +//! `parse_inventory_json` + `reconcile_host_inventory`. We invoke the +//! same binary locally and drive the same parser + reconciler, so a +//! regression in any of those three layers fails this test. +//! +//! What this covers that the per-layer unit tests don't: +//! +//! - The exact JSON shape `codemux-remote workspace list` writes is +//! what `parse_inventory_json` accepts. (Both are stable contracts +//! but they're owned by different files; this test guards the +//! handoff.) +//! - The reconcile pass, fed the real CLI output, produces exactly +//! the sync rows we expect — `dirty=1`, `workspace_id=NULL`, +//! `host_server_id` + `origin_uid` set, no extras. +//! - Re-running the CLI + reconcile after a no-op change is +//! idempotent: no spurious dirty flips, no duplicate rows. +//! +//! Unix-only — `codemux-remote` itself is Unix-only. + +#![cfg(unix)] + +use std::path::PathBuf; +use std::process::Command; + +use codemux_lib::database::DatabaseStore; +use codemux_lib::hosts_inventory::{parse_inventory_json, reconcile_host_inventory}; +use codemux_lib::remote::{config, workspace::WorkspaceStore}; +use tempfile::TempDir; + +fn binary_path() -> PathBuf { + if let Ok(path) = std::env::var("CARGO_BIN_EXE_codemux-remote") { + return PathBuf::from(path); + } + PathBuf::from("target/debug/codemux-remote") +} + +/// Helper: invoke `codemux-remote workspace list --state-dir

` +/// and return its stdout, asserting the exit was clean. +fn run_workspace_list(state_dir: &std::path::Path) -> String { + let bin = binary_path(); + let output = Command::new(&bin) + .arg("workspace") + .arg("list") + .arg("--state-dir") + .arg(state_dir) + .output() + .expect("spawn codemux-remote workspace list"); + assert!( + output.status.success(), + "workspace list exited {}: stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("utf-8 stdout") +} + +#[test] +fn cli_to_reconcile_round_trip_publishes_remote_workspaces() { + let bin = binary_path(); + if !bin.exists() { + eprintln!( + "[test] codemux-remote binary at {:?} not built — \ + run `cargo build --bin codemux-remote` first", + bin + ); + return; + } + + // Seed the host-side registry with two workspaces, matching the + // shape the `workspace_create` MCP tool would produce on a real + // host. + let state_tmp = TempDir::new().unwrap(); + let state_dir = state_tmp.path(); + let (alpha_uid, beta_uid) = { + let store = WorkspaceStore::open( + &config::database_path(state_dir), + "test-pandora".into(), + config::workspaces_root(state_dir), + ) + .expect("open WorkspaceStore"); + let alpha = store + .create( + Some("alpha".into()), + "/srv/alpha".into(), + Some("main".into()), + Some("/srv/alpha-origin".into()), + ) + .expect("create alpha"); + let beta = store + .create( + Some("beta".into()), + "/srv/beta".into(), + Some("dev".into()), + Some("/srv/beta-origin".into()), + ) + .expect("create beta"); + (alpha.id, beta.id) + }; + + // Drive the real CLI against that state dir. + let stdout = run_workspace_list(state_dir); + let parsed = parse_inventory_json(&stdout) + .expect("workspace list stdout must parse via parse_inventory_json — \ + a mismatch between the CLI's printed shape and the parser \ + means every poll tick on every host will silently fail"); + assert_eq!(parsed.workspaces.len(), 2); + let parsed_uids: Vec<&str> = + parsed.workspaces.iter().map(|w| w.id.as_str()).collect(); + assert!(parsed_uids.contains(&alpha_uid.as_str())); + assert!(parsed_uids.contains(&beta_uid.as_str())); + + // Reconcile into a fresh desktop DB. Mimics what the + // hosts_inventory poller does after fetching the host's + // inventory over SSH. + let desktop_db = DatabaseStore::new_in_memory(); + let stats = reconcile_host_inventory(&desktop_db, "host-pandora", &parsed); + assert_eq!(stats.inserted, 2, "every remote workspace must insert once"); + assert_eq!(stats.updated, 0); + assert_eq!(stats.soft_deleted, 0); + + // Inspect the rows the reconcile created — they must look + // exactly like sibling-only sync rows: no local workspace_id, + // no cloud server_id yet, dirty=1 so the next push uploads. + let rows = desktop_db.list_remote_discovered_for_host("host-pandora"); + assert_eq!(rows.len(), 2); + for r in &rows { + assert!( + r.workspace_id.is_none(), + "sibling-only rows must not stamp a local workspace_id" + ); + assert!( + r.server_id.is_none(), + "cloud server_id is assigned by push, not by reconcile" + ); + assert!(r.dirty, "fresh row must be dirty so push uploads it"); + assert_eq!(r.host_server_id.as_deref(), Some("host-pandora")); + assert!( + r.origin_uid.is_some(), + "remote-discovered rows must carry an origin_uid for dedupe" + ); + } + + // Same titles + branches surfaced through the entire pipeline. + let alpha_row = rows + .iter() + .find(|r| r.origin_uid.as_deref() == Some(&alpha_uid)) + .expect("alpha must be present in the desktop DB"); + assert_eq!(alpha_row.title, "alpha"); + assert_eq!(alpha_row.git_branch.as_deref(), Some("main")); + let beta_row = rows + .iter() + .find(|r| r.origin_uid.as_deref() == Some(&beta_uid)) + .expect("beta must be present in the desktop DB"); + assert_eq!(beta_row.title, "beta"); + assert_eq!(beta_row.git_branch.as_deref(), Some("dev")); +} + +#[test] +fn cli_to_reconcile_is_idempotent_across_polls() { + // Steady-state property: if the host's registry didn't change, + // a second reconcile must produce zero inserts/updates/soft- + // deletes and must NOT re-mark already-synced rows as dirty. + // Otherwise the cloud sync layer would PATCH every workspace on + // every poll for the rest of time, which is both wasteful and + // would defeat the "dirty == there's something to push" signal + // the rest of the system depends on. + let bin = binary_path(); + if !bin.exists() { + return; + } + + let state_tmp = TempDir::new().unwrap(); + let state_dir = state_tmp.path(); + let uid = { + let store = WorkspaceStore::open( + &config::database_path(state_dir), + "test-host".into(), + config::workspaces_root(state_dir), + ) + .unwrap(); + store + .create( + Some("only-one".into()), + "/srv/only".into(), + Some("main".into()), + None, + ) + .unwrap() + .id + }; + + let desktop_db = DatabaseStore::new_in_memory(); + + // First poll cycle. + let stdout1 = run_workspace_list(state_dir); + let parsed1 = parse_inventory_json(&stdout1).unwrap(); + let stats1 = reconcile_host_inventory(&desktop_db, "host-1", &parsed1); + assert_eq!(stats1.inserted, 1); + + // Simulate the first push assigning a cloud server_id and + // clearing dirty (the steady state of any successfully-synced + // row). + let row = desktop_db + .find_workspace_sync_by_host_and_origin_uid("host-1", &uid) + .unwrap(); + desktop_db + .mark_workspace_sync_synced(row.id, Some("cloud-7")) + .unwrap(); + assert!( + !desktop_db + .find_workspace_sync_by_host_and_origin_uid("host-1", &uid) + .unwrap() + .dirty + ); + + // Second poll cycle — identical inventory. Must be a no-op all + // the way through. + let stdout2 = run_workspace_list(state_dir); + let parsed2 = parse_inventory_json(&stdout2).unwrap(); + let stats2 = reconcile_host_inventory(&desktop_db, "host-1", &parsed2); + assert_eq!(stats2.inserted, 0, "no inserts on identical re-poll"); + assert_eq!(stats2.updated, 0, "no updates on identical re-poll"); + assert_eq!(stats2.soft_deleted, 0); + assert!( + !desktop_db + .find_workspace_sync_by_host_and_origin_uid("host-1", &uid) + .unwrap() + .dirty, + "identical re-poll MUST NOT mark the row dirty — otherwise every \ + poll burns a cloud PATCH for no reason" + ); +} + +#[test] +fn cli_to_reconcile_propagates_host_side_renames_and_closes() { + // Real-world flow: agent renames a workspace via the daemon's + // `workspace_update` tool, and closes another via + // `workspace_close`. The desktop must learn both on the next + // poll cycle — UPDATE in place + soft-delete respectively. + let bin = binary_path(); + if !bin.exists() { + return; + } + + let state_tmp = TempDir::new().unwrap(); + let state_dir = state_tmp.path(); + + let (rename_uid, doomed_uid) = { + let store = WorkspaceStore::open( + &config::database_path(state_dir), + "test-host".into(), + config::workspaces_root(state_dir), + ) + .unwrap(); + let rn = store + .create(Some("original".into()), "/srv/rn".into(), Some("main".into()), None) + .unwrap(); + let dm = store + .create(Some("doomed".into()), "/srv/dm".into(), Some("main".into()), None) + .unwrap(); + (rn.id, dm.id) + }; + + let desktop_db = DatabaseStore::new_in_memory(); + let stats = reconcile_host_inventory( + &desktop_db, + "host-1", + &parse_inventory_json(&run_workspace_list(state_dir)).unwrap(), + ); + assert_eq!(stats.inserted, 2); + // Pretend both got pushed and assigned cloud ids. + for uid in [&rename_uid, &doomed_uid] { + let r = desktop_db + .find_workspace_sync_by_host_and_origin_uid("host-1", uid) + .unwrap(); + desktop_db + .mark_workspace_sync_synced(r.id, Some(&format!("cloud-{uid}"))) + .unwrap(); + } + + // Mutate the host side: rename one, close the other. + { + let store = WorkspaceStore::open( + &config::database_path(state_dir), + "test-host".into(), + config::workspaces_root(state_dir), + ) + .unwrap(); + store + .update(&rename_uid, Some("renamed-on-host".into()), None, None) + .unwrap(); + store.close(&doomed_uid).unwrap(); + } + + let stats2 = reconcile_host_inventory( + &desktop_db, + "host-1", + &parse_inventory_json(&run_workspace_list(state_dir)).unwrap(), + ); + assert_eq!(stats2.inserted, 0); + assert_eq!(stats2.updated, 1, "renamed workspace must UPDATE in place"); + assert_eq!( + stats2.soft_deleted, 1, + "closed workspace must be soft-deleted so push DELETEs the cloud row" + ); + + // The renamed row carries the new title AND keeps its + // cloud server_id — losing the server_id here would cause the + // next push to POST a duplicate and orphan the original row. + let after_rename = desktop_db + .find_workspace_sync_by_host_and_origin_uid("host-1", &rename_uid) + .unwrap(); + assert_eq!(after_rename.title, "renamed-on-host"); + assert_eq!( + after_rename.server_id.as_deref(), + Some(format!("cloud-{rename_uid}").as_str()), + "in-place update must preserve the cloud server_id" + ); + assert!( + after_rename.dirty, + "a real field change must mark the row dirty so push propagates it" + ); + + // The doomed row is a tombstone visible only to the sync-loop + // list, with deleted_at + dirty=1 + the cloud server_id intact. + let tombstone = desktop_db + .list_workspaces_sync_for_sync() + .into_iter() + .find(|r| r.origin_uid.as_deref() == Some(&doomed_uid)) + .expect("tombstone must still be present pre-push"); + assert!(tombstone.deleted_at.is_some()); + assert!(tombstone.dirty); + assert_eq!( + tombstone.server_id.as_deref(), + Some(format!("cloud-{doomed_uid}").as_str()) + ); +} diff --git a/src/components/workspaces-overview/pull-to-device-dialog.test.tsx b/src/components/workspaces-overview/pull-to-device-dialog.test.tsx index 75297f04..9ea3b1cd 100644 --- a/src/components/workspaces-overview/pull-to-device-dialog.test.tsx +++ b/src/components/workspaces-overview/pull-to-device-dialog.test.tsx @@ -107,6 +107,7 @@ function makePreview( suggested_path: "/home/zeus/.codemux/worktrees/codemux/feature-x", is_path_in_use: false, already_adopted_workspace_id: null, + same_branch_project_exists_at: null, ...overrides, }; } @@ -208,6 +209,40 @@ describe("PullToDeviceDialog", () => { expect(screen.queryByText("Pull workspace")).toBeNull(); }); + it("blocks Pull and points at the existing workspace when same branch+project is already open here", async () => { + // The cross-machine conflict guard: another local workspace on + // this device is already on the same branch of (heuristically) + // the same project. Pulling would silently create a parallel + // copy of work — so the dialog must NOT show the Pull button and + // must offer to open the existing local workspace instead. + mockPreview.mockResolvedValue( + makePreview({ + same_branch_project_exists_at: "workspace-local-77", + }), + ); + render( + {}} + />, + ); + await waitFor(() => + expect( + screen.getByText(/already have this branch open on this device/i), + ).toBeInTheDocument(), + ); + // The "create parallel copy" warning must be present — that's the + // user-facing explanation for why Pull is gone. + expect( + screen.getByText(/create a parallel copy of work/i), + ).toBeInTheDocument(); + // Pull button is hidden. + expect(screen.queryByText("Pull workspace")).toBeNull(); + // Clicking "Open the existing workspace" activates the local one. + fireEvent.click(screen.getByText("Open the existing workspace")); + expect(mockActivate).toHaveBeenCalledWith("workspace-local-77"); + }); + it("offers 'Open it' when the row is already adopted on this device", async () => { mockPreview.mockResolvedValue( makePreview({ already_adopted_workspace_id: "workspace-42" }), diff --git a/src/components/workspaces-overview/pull-to-device-dialog.tsx b/src/components/workspaces-overview/pull-to-device-dialog.tsx index 4f3fd50b..3571711e 100644 --- a/src/components/workspaces-overview/pull-to-device-dialog.tsx +++ b/src/components/workspaces-overview/pull-to-device-dialog.tsx @@ -276,6 +276,26 @@ export function PullToDeviceDialog({ syncRow, onOpenChange }: Props) { void activateWorkspace(alreadyAdopted); }, [alreadyAdopted, onOpenChange, setShowWorkspacesOverview]); + // ── Cross-machine same-branch-same-project guard ────────────── + // + // The user clicked Pull on a row whose `(project basename, + // git_branch)` matches a workspace already open on this device. + // That's the strong "you're already doing this work" signal — show + // the existing workspace and let them open it, rather than silently + // creating a parallel copy. Layered AFTER `alreadyAdopted` so an + // already-adopted row gets the simpler "Open it" copy; this block + // is for "you have a DIFFERENT local workspace that's logically + // the same." + const sameBranchConflict = + preview?.same_branch_project_exists_at ?? null; + + const handleOpenSameBranchConflict = useCallback(() => { + if (!sameBranchConflict) return; + onOpenChange(false); + setShowWorkspacesOverview(false); + void activateWorkspace(sameBranchConflict); + }, [sameBranchConflict, onOpenChange, setShowWorkspacesOverview]); + if (!syncRow) return null; return ( @@ -307,6 +327,10 @@ export function PullToDeviceDialog({ syncRow, onOpenChange }: Props) { ) : alreadyAdopted ? ( + ) : sameBranchConflict ? ( + ) : preview.can_host_adopt && !preview.is_path_in_use ? ( {preview && !alreadyAdopted && + !sameBranchConflict && !preview.is_path_in_use && (preview.can_host_adopt || isCloneMode) && ( + + ); +} + function AlreadyAdoptedBlock({ onOpen }: { onOpen: () => void }) { return (
diff --git a/src/tauri/commands.ts b/src/tauri/commands.ts index eae5f11e..7107070c 100644 --- a/src/tauri/commands.ts +++ b/src/tauri/commands.ts @@ -1796,6 +1796,14 @@ export interface AdoptionPreview { * carries that local id so the UI can offer "Open existing" instead * of re-running the adoption flow. */ already_adopted_workspace_id: string | null; + /** Cross-machine "same branch of the same project" conflict guard. + * When set, another local workspace on THIS device is already on + * the same branch of (heuristically) the same project — matched by + * `(basename(project_path), git_branch)`. Pulling would silently + * create a parallel copy of work the user's already doing, so the + * dialog disables Pull and points at the existing local workspace. + * Null when no conflict was detected. */ + same_branch_project_exists_at: string | null; } export interface AdoptOutcome {