Skip to content

Commit 6a3f9e3

Browse files
authored
Merge pull request #46 from Zeus-Deus/debug-remote-device-sync
feat(workspaces-sync): auto-publish from codemux-remote hosts
2 parents d14993e + 96c670a commit 6a3f9e3

13 files changed

Lines changed: 2077 additions & 5 deletions

File tree

docs/features/remote-hosts.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,21 @@ codemux-remote mcp [--state-dir <path>]
8686
daemon's endpoint + secret, then bridges agent CLI tool calls to
8787
the daemon's HTTP API. Configure your CLI agent with:
8888
{"command": "codemux-remote", "args": ["mcp"]}
89+
90+
codemux-remote workspace list [--state-dir <path>]
91+
→ Print {"host_id":"<gethostname>","workspaces":[<Workspace>...]}
92+
to stdout. Reads the daemon's SQLite registry directly — works
93+
even when the daemon isn't running. Invoked over SSH by the
94+
desktop's host-inventory poller every ~60 s so workspaces
95+
created on the host (e.g. by an agent via the MCP
96+
`workspace_create` tool) auto-publish to the user's cloud
97+
registry without a manual push. See
98+
docs/features/workspaces-sync.md § "Asymmetric publish model".
99+
100+
codemux-remote workspace register --path <path> [--name <n>]
101+
[--branch <b>] [--project-root <p>]
102+
→ POSTs `workspace_create` to the running daemon and prints the
103+
new workspace JSON. Used by the desktop's push flow over SSH.
89104
```
90105

91106
### `serve` mode overview
@@ -215,6 +230,20 @@ Safety contract:
215230
won't have `~/.claude.json`; we don't create directories the
216231
user never opted into.
217232

233+
### Background host-inventory poller (`hosts_inventory.rs`)
234+
235+
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`:
238+
239+
1. `ssh::probe::probe_host` — skip offline / not-installed hosts.
240+
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.
246+
218247
### Background host-upgrade poller (`hosts_upgrade.rs`)
219248

220249
Users don't think about "upgrading a helper binary on a remote

docs/features/workspaces-sync.md

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,52 @@ Bucketing is by `host_server_id`, so the same host shows up under the same bucke
126126
- Local sync: pull, push, reconcile, soft-delete tombstones, idempotent server-side upsert.
127127
- 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.
128128
- 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.
129175

130176
## Current Constraints
131177

@@ -152,7 +198,10 @@ Wired at three call sites:
152198
## Important Touch Points
153199

154200
### Local (Codemux desktop)
155-
- `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`).
201+
- `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`.
202+
- `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.
204+
- `src-tauri/src/commands/workspaces_sync.rs::detect_same_branch_project_conflict` — pull-conflict guard; populates `AdoptionPreview.same_branch_project_exists_at`.
156205
- `src-tauri/src/workspaces_sync.rs` — sync module: `pull`, `push`, `try_sync_with_app`, `sync_workspaces`, `reconcile_from_snapshot`, `ServerWorkspace` wire type.
157206
- `src-tauri/src/commands/workspaces_sync.rs` — Tauri command surface: `workspaces_sync_list`, `workspaces_sync_now`, `workspaces_adoption_preview`, `workspaces_adopt_synced`.
158207
- `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).

src-tauri/src/bin/codemux_remote.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,24 @@ enum WorkspaceSubcommand {
130130
#[arg(long, default_value = "10")]
131131
connect_timeout_secs: u64,
132132
},
133+
/// Print every workspace in the daemon's SQLite registry as JSON
134+
/// on stdout. Reads the database directly — no running daemon is
135+
/// required. The desktop's host-inventory poller invokes this
136+
/// over SSH so workspaces created on the host (via MCP tools or
137+
/// the desktop's push flow) become visible across the user's
138+
/// account without an explicit push from each device.
139+
///
140+
/// Stable contract: stdout is exactly one JSON object of shape
141+
/// `{"host_id":"<gethostname>","workspaces":[<Workspace>,...]}`,
142+
/// where each Workspace matches `remote::workspace::Workspace`.
143+
/// Stderr is unused on success; non-zero exit means the registry
144+
/// could not be opened.
145+
List {
146+
/// State directory of the daemon. Defaults to the same path
147+
/// `serve` defaults to.
148+
#[arg(long)]
149+
state_dir: Option<PathBuf>,
150+
},
133151
}
134152

135153
#[cfg(unix)]
@@ -207,6 +225,7 @@ fn main() -> ExitCode {
207225
state_dir,
208226
connect_timeout_secs,
209227
),
228+
WorkspaceSubcommand::List { state_dir } => run_workspace_list(state_dir),
210229
},
211230
}
212231
}
@@ -679,3 +698,50 @@ fn run_workspace_register(
679698
println!("{}", workspace);
680699
ExitCode::SUCCESS
681700
}
701+
702+
/// Implementation for `codemux-remote workspace list`. Opens the
703+
/// daemon's SQLite registry directly (no HTTP, no running daemon
704+
/// required) and prints `{"host_id":"...","workspaces":[...]}` to
705+
/// stdout.
706+
///
707+
/// Used by the desktop's host-inventory poller: every ~60 seconds the
708+
/// desktop SSHes into every configured host and runs this command, then
709+
/// reconciles the result into its own `workspaces_sync` table so the
710+
/// account-wide overview surfaces host-side workspaces without each
711+
/// device having to push from itself.
712+
///
713+
/// We open the store read-only as far as workspaces go — we never call
714+
/// `create`, so the `host_id` and `workspaces_root` args to
715+
/// `WorkspaceStore::open` are only used to materialise the schema on
716+
/// first run (and to create the workspaces root, which is harmless).
717+
#[cfg(unix)]
718+
fn run_workspace_list(state_dir_arg: Option<PathBuf>) -> ExitCode {
719+
use codemux_lib::remote::{config, manifest, workspace::WorkspaceStore};
720+
721+
let state_dir = resolve_state_dir(state_dir_arg);
722+
let host_id = manifest::current_host_id();
723+
let store = match WorkspaceStore::open(
724+
&config::database_path(&state_dir),
725+
host_id.clone(),
726+
config::workspaces_root(&state_dir),
727+
) {
728+
Ok(s) => s,
729+
Err(e) => {
730+
eprintln!("[codemux-remote] open workspace store at {}: {e}", state_dir.display());
731+
return ExitCode::from(1);
732+
}
733+
};
734+
let workspaces = match store.list() {
735+
Ok(w) => w,
736+
Err(e) => {
737+
eprintln!("[codemux-remote] list workspaces: {e}");
738+
return ExitCode::from(1);
739+
}
740+
};
741+
let payload = serde_json::json!({
742+
"host_id": host_id,
743+
"workspaces": workspaces,
744+
});
745+
println!("{}", payload);
746+
ExitCode::SUCCESS
747+
}

0 commit comments

Comments
 (0)