You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
236
+
237
+
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`:
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`).
241
+
3.`parse_inventory_json` + `reconcile_host_inventory` → insert/update/soft-delete sibling-only rows in `workspaces_sync` keyed by `(host_server_id, origin_uid)`.
242
+
243
+
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.
244
+
245
+
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.
- 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.
128
128
- Cadence: 30-second background loop. `workspaces_sync_now` for forced sync.
129
+
-**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.
130
+
131
+
## Asymmetric publish model (auto-publish from remote hosts)
132
+
133
+
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.
134
+
135
+
The fix is a deliberately asymmetric model:
136
+
137
+
| Role | Behaviour | Why |
138
+
|---|---|---|
139
+
| 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. |
140
+
| 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. |
141
+
142
+
### Mechanics
143
+
144
+
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.
145
+
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`:
146
+
- Probe via `ssh::probe::probe_host` (re-uses the same `~/.local/bin/codemux-remote` PATH fallback the test-connection flow uses).
147
+
- SSH and run `codemux-remote workspace list` (also with the PATH fallback — see `build_inventory_argv`).
148
+
- Parse the JSON envelope.
149
+
- 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.
150
+
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.
151
+
152
+
### Identity contract for remote-discovered rows
153
+
154
+
| Column | Value | Purpose |
155
+
|---|---|---|
156
+
|`workspace_id`|`NULL`| Marker for "lives elsewhere, not adopted here." Sibling-row rendering. |
157
+
|`host_server_id`| host's `server_id`| Bucket the row under the right host in the overview. |
158
+
|`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. |
159
+
|`server_id`| assigned by first `push()`| Cross-device identity. After this exists, other devices see the row via cloud pull. |
160
+
|`dirty`|`1` on insert / on field change | Triggers the next push tick. |
161
+
162
+
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.
163
+
164
+
### Pull-conflict guard
165
+
166
+
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.
167
+
168
+
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.
169
+
170
+
### Known limitations (v1)
171
+
172
+
-**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.
173
+
-**`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.
174
+
-**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.
-`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.
203
+
-`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/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).
0 commit comments