Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/features/remote-hosts.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,21 @@ codemux-remote mcp [--state-dir <path>]
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 <path>]
→ Print {"host_id":"<gethostname>","workspaces":[<Workspace>...]}
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 <path> [--name <n>]
[--branch <b>] [--project-root <p>]
→ 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
Expand Down Expand Up @@ -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
Expand Down
51 changes: 50 additions & 1 deletion docs/features/workspaces-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<state_dir>/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 <path>]` reads the daemon's SQLite registry directly (no running daemon required) and prints `{"host_id": "<gethostname>", "workspaces": [<Workspace>...]}` 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

Expand All @@ -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).
Expand Down
66 changes: 66 additions & 0 deletions src-tauri/src/bin/codemux_remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<gethostname>","workspaces":[<Workspace>,...]}`,
/// 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<PathBuf>,
},
}

#[cfg(unix)]
Expand Down Expand Up @@ -207,6 +225,7 @@ fn main() -> ExitCode {
state_dir,
connect_timeout_secs,
),
WorkspaceSubcommand::List { state_dir } => run_workspace_list(state_dir),
},
}
}
Expand Down Expand Up @@ -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<PathBuf>) -> 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
}
Loading