@venturewild/workspace 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +112 -73
- package/package.json +75 -69
- package/server/bin/wild-workspace.mjs +725 -95
- package/server/src/account.mjs +114 -0
- package/server/src/agent-identity.mjs +65 -0
- package/server/src/agent-readiness.mjs +200 -0
- package/server/src/agent.mjs +356 -335
- package/server/src/config.mjs +302 -236
- package/server/src/daemon-bin.mjs +6 -2
- package/server/src/daemon-supervisor.mjs +216 -0
- package/server/src/daemon.mjs +6 -0
- package/server/src/doctor.mjs +246 -0
- package/server/src/error-reporter.mjs +86 -0
- package/server/src/inbox.mjs +86 -81
- package/server/src/index.mjs +1330 -635
- package/server/src/logpaths.mjs +97 -0
- package/server/src/observability.mjs +45 -0
- package/server/src/operator.mjs +65 -0
- package/server/src/service.mjs +127 -0
- package/server/src/session-reporter.mjs +201 -0
- package/server/src/supervisor.mjs +217 -0
- package/server/src/sync.mjs +248 -176
- package/server/src/transcript.mjs +121 -0
- package/web/dist/assets/index-Bj-mdLGj.css +1 -0
- package/web/dist/assets/index-DLRgyr9j.js +89 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DOwej8U4.js +0 -89
- package/web/dist/assets/index-DZkyDo10.css +0 -1
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// WorkspaceSupervisor — keeps the wild-workspace server alive in the background.
|
|
2
|
+
//
|
|
3
|
+
// The server itself auto-starts the bmo-sync daemon on boot (DaemonSupervisor),
|
|
4
|
+
// so keeping the server up brings the whole local stack — public URL included —
|
|
5
|
+
// back to life. This is the watchdog half of the always-on feature
|
|
6
|
+
// (docs/always-on-design.md); `service.mjs` is the per-OS autostart half that
|
|
7
|
+
// launches this hidden at login via `wild-workspace service run`.
|
|
8
|
+
//
|
|
9
|
+
// Design (all proven on Windows incl. a real reboot, 2026-05-30):
|
|
10
|
+
// - Health-driven: polls GET /api/health and (re)spawns the server only when
|
|
11
|
+
// it is down — so it never fights a server someone else started and handles
|
|
12
|
+
// crash recovery naturally.
|
|
13
|
+
// - Singleton: an exclusive lockfile in the machine-global dir
|
|
14
|
+
// (~/.wild-workspace, NEVER the synced workspace — locked principle #1).
|
|
15
|
+
// A stale lock whose pid is dead is taken over.
|
|
16
|
+
// - Exponential backoff (capped) so a crash-looping server can't spin the CPU.
|
|
17
|
+
// - Everything is logged — silent death is the #1 un-debuggable failure mode.
|
|
18
|
+
//
|
|
19
|
+
// Every external touch-point (spawn, health probe, clock) is an injected seam
|
|
20
|
+
// so the suite never spawns a real process.
|
|
21
|
+
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
23
|
+
import http from 'node:http';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
30
|
+
const DEFAULT_SERVER_ENTRY = path.join(__dirname, 'index.mjs');
|
|
31
|
+
|
|
32
|
+
/** Resolve true iff the local server answers /api/health. Never throws. */
|
|
33
|
+
export function probeHealth(port, timeoutMs = 2500) {
|
|
34
|
+
return new Promise((resolve) => {
|
|
35
|
+
const req = http.get(
|
|
36
|
+
{ host: '127.0.0.1', port, path: '/api/health', timeout: timeoutMs },
|
|
37
|
+
(res) => { res.resume(); resolve(res.statusCode > 0); },
|
|
38
|
+
);
|
|
39
|
+
req.on('error', () => resolve(false));
|
|
40
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class WorkspaceSupervisor {
|
|
45
|
+
constructor({
|
|
46
|
+
serverEntry = DEFAULT_SERVER_ENTRY,
|
|
47
|
+
workspaceDir = process.cwd(),
|
|
48
|
+
port = Number(process.env.WILD_WORKSPACE_PORT || 5173),
|
|
49
|
+
globalDir = path.join(os.homedir(), '.wild-workspace'),
|
|
50
|
+
node = process.execPath,
|
|
51
|
+
pollMs = 3000,
|
|
52
|
+
backoffStartMs = 1000,
|
|
53
|
+
backoffMaxMs = 30000,
|
|
54
|
+
probeTimeoutMs = 2500,
|
|
55
|
+
spawnImpl = spawn,
|
|
56
|
+
probeImpl = probeHealth,
|
|
57
|
+
nowImpl = () => Date.now(),
|
|
58
|
+
env = process.env,
|
|
59
|
+
crashLoopThreshold = 3,
|
|
60
|
+
diagnosticsImpl = null,
|
|
61
|
+
} = {}) {
|
|
62
|
+
Object.assign(this, {
|
|
63
|
+
serverEntry, workspaceDir, port, globalDir, node, pollMs,
|
|
64
|
+
backoffStartMs, backoffMaxMs, probeTimeoutMs, spawnImpl, probeImpl, nowImpl, env,
|
|
65
|
+
crashLoopThreshold, diagnosticsImpl,
|
|
66
|
+
});
|
|
67
|
+
this.logFile = path.join(globalDir, 'supervisor.log');
|
|
68
|
+
this.serverLogFile = path.join(globalDir, 'server.out.log');
|
|
69
|
+
this.lockFile = path.join(globalDir, 'supervisor.lock');
|
|
70
|
+
this.child = null;
|
|
71
|
+
this.backoff = backoffStartMs;
|
|
72
|
+
this.lastSpawn = 0;
|
|
73
|
+
this.timer = null;
|
|
74
|
+
this.spawnCount = 0; // consecutive spawns without becoming healthy
|
|
75
|
+
this.pushedThisEpisode = false; // crash-loop diagnostics pushed once per episode
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
log(msg) {
|
|
79
|
+
try { fs.appendFileSync(this.logFile, `[${new Date().toISOString()}] ${msg}\n`); } catch { /* best-effort */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Is a pid alive? EPERM means "exists, not ours" → still alive. */
|
|
83
|
+
pidAlive(pid) {
|
|
84
|
+
try { process.kill(pid, 0); return true; } catch (e) { return !!(e && e.code === 'EPERM'); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Exclusive lock; take over ONLY a stale lock (recorded pid no longer alive). */
|
|
88
|
+
acquireLock() {
|
|
89
|
+
try { fs.mkdirSync(this.globalDir, { recursive: true }); } catch { /* surfaced below */ }
|
|
90
|
+
try {
|
|
91
|
+
const fd = fs.openSync(this.lockFile, 'wx');
|
|
92
|
+
fs.writeSync(fd, String(process.pid));
|
|
93
|
+
fs.closeSync(fd);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
let old = null;
|
|
97
|
+
try { old = Number(fs.readFileSync(this.lockFile, 'utf8').trim()); } catch { /* unreadable */ }
|
|
98
|
+
if (old && this.pidAlive(old)) {
|
|
99
|
+
this.log(`live supervisor pid=${old} already running; exiting`);
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
try { fs.writeFileSync(this.lockFile, String(process.pid)); this.log('took over stale lock'); return true; }
|
|
103
|
+
catch { return false; }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
releaseLock() {
|
|
108
|
+
try {
|
|
109
|
+
if (Number(fs.readFileSync(this.lockFile, 'utf8').trim()) === process.pid) fs.unlinkSync(this.lockFile);
|
|
110
|
+
} catch { /* already gone */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
spawnServer() {
|
|
114
|
+
let out = 'ignore';
|
|
115
|
+
try { out = fs.openSync(this.serverLogFile, 'a'); } catch { /* output discarded */ }
|
|
116
|
+
this.child = this.spawnImpl(this.node, [this.serverEntry], {
|
|
117
|
+
cwd: this.workspaceDir,
|
|
118
|
+
windowsHide: true,
|
|
119
|
+
stdio: ['ignore', out, out],
|
|
120
|
+
env: { ...this.env, WILD_WORKSPACE_NO_OPEN: '1', WILD_WORKSPACE_DIR: this.workspaceDir },
|
|
121
|
+
});
|
|
122
|
+
if (typeof out === 'number') { try { fs.closeSync(out); } catch { /* parent fd */ } }
|
|
123
|
+
this.lastSpawn = this.nowImpl();
|
|
124
|
+
const pid = this.child && this.child.pid;
|
|
125
|
+
this.log(`spawned server pid=${pid} (backoff=${this.backoff}ms)`);
|
|
126
|
+
if (this.child && this.child.on) {
|
|
127
|
+
this.child.on('exit', (code, sig) => { this.log(`server pid=${pid} exited code=${code} sig=${sig}`); this.child = null; });
|
|
128
|
+
}
|
|
129
|
+
return this.child;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** One supervision step. Returns its decision (exposed for tests). */
|
|
133
|
+
async tick() {
|
|
134
|
+
if (await this.probeImpl(this.port, this.probeTimeoutMs)) {
|
|
135
|
+
this.backoff = this.backoffStartMs; // healthy → reset backoff
|
|
136
|
+
this.spawnCount = 0; // healthy → not a crash loop
|
|
137
|
+
this.pushedThisEpisode = false;
|
|
138
|
+
return 'healthy';
|
|
139
|
+
}
|
|
140
|
+
if (this.child) return 'booting'; // spawned, still coming up
|
|
141
|
+
if (this.nowImpl() - this.lastSpawn < this.backoff) return 'backoff';
|
|
142
|
+
this.spawnServer();
|
|
143
|
+
this.backoff = Math.min(this.backoff * 2, this.backoffMaxMs);
|
|
144
|
+
this.spawnCount += 1;
|
|
145
|
+
// Crash loop: the server won't stay up, so the operator channel (which rides
|
|
146
|
+
// the :5173 server) can't reach this machine at all. Push an install-down
|
|
147
|
+
// `doctor` bundle to bmo-sync ONCE per episode so support sees it anyway —
|
|
148
|
+
// the install-failed-before-server-up case (docs/user-experience.md §5).
|
|
149
|
+
if (this.spawnCount >= this.crashLoopThreshold && !this.pushedThisEpisode) {
|
|
150
|
+
this.pushedThisEpisode = true;
|
|
151
|
+
Promise.resolve(this.pushDiagnostics()).catch((e) => this.log(`diag push error: ${e?.message || e}`));
|
|
152
|
+
}
|
|
153
|
+
return 'spawned';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Push an install-down diagnostic bundle to bmo-sync. Injected (`diagnosticsImpl`)
|
|
158
|
+
* in tests; the real path is consent- + token-gated and never runs under the
|
|
159
|
+
* test runner. Best-effort, never throws into the supervision loop.
|
|
160
|
+
*/
|
|
161
|
+
async pushDiagnostics() {
|
|
162
|
+
if (this.diagnosticsImpl) return this.diagnosticsImpl(this);
|
|
163
|
+
if (process.env.VITEST || process.env.NODE_ENV === 'test') return;
|
|
164
|
+
try {
|
|
165
|
+
const [{ buildConfig }, { runDoctor }, { loadObservabilityConsent }] = await Promise.all([
|
|
166
|
+
import('./config.mjs'),
|
|
167
|
+
import('./doctor.mjs'),
|
|
168
|
+
import('./observability.mjs'),
|
|
169
|
+
]);
|
|
170
|
+
const config = buildConfig({ workspaceDir: this.workspaceDir, port: this.port });
|
|
171
|
+
if (!config.accountToken) return; // can't key it to a user
|
|
172
|
+
if (process.env.WILD_WORKSPACE_NO_TELEMETRY === '1') return; // kill switch
|
|
173
|
+
if (!loadObservabilityConsent(config.dataDir).enabled) return; // consent
|
|
174
|
+
const report = await runDoctor({ config });
|
|
175
|
+
const url = `${config.bmoSyncServerUrl.replace(/\/$/, '')}/api/telemetry`;
|
|
176
|
+
const ctrl = new AbortController();
|
|
177
|
+
const t = setTimeout(() => ctrl.abort(), 5000);
|
|
178
|
+
try {
|
|
179
|
+
await fetch(url, {
|
|
180
|
+
method: 'POST',
|
|
181
|
+
headers: { 'content-type': 'application/json' },
|
|
182
|
+
body: JSON.stringify({
|
|
183
|
+
account_token: config.accountToken,
|
|
184
|
+
slug: config.account?.slug || null,
|
|
185
|
+
workspace_id: config.workspaceId,
|
|
186
|
+
kind: 'install-down',
|
|
187
|
+
doctor: report,
|
|
188
|
+
sent_at: Math.floor(Date.now() / 1000),
|
|
189
|
+
}),
|
|
190
|
+
signal: ctrl.signal,
|
|
191
|
+
});
|
|
192
|
+
this.log(`pushed install-down diagnostics (fail=${report.summary?.fail})`);
|
|
193
|
+
} finally {
|
|
194
|
+
clearTimeout(t);
|
|
195
|
+
}
|
|
196
|
+
} catch (e) {
|
|
197
|
+
this.log(`diagnostics push failed: ${e?.message || e}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Acquire the lock and start the supervision loop. Idempotent across processes. */
|
|
202
|
+
start() {
|
|
203
|
+
if (!this.acquireLock()) return { started: false, reason: 'already-running' };
|
|
204
|
+
process.on('exit', () => this.releaseLock());
|
|
205
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
206
|
+
process.on('SIGINT', () => process.exit(0));
|
|
207
|
+
this.log(`supervisor start pid=${process.pid} watching http://127.0.0.1:${this.port}/api/health (workspace=${this.workspaceDir})`);
|
|
208
|
+
this.timer = setInterval(() => { this.tick().catch((e) => this.log(`tick error: ${e?.message || e}`)); }, this.pollMs);
|
|
209
|
+
this.tick().catch((e) => this.log(`tick error: ${e?.message || e}`));
|
|
210
|
+
return { started: true };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
stop() {
|
|
214
|
+
if (this.timer) { clearInterval(this.timer); this.timer = null; }
|
|
215
|
+
this.releaseLock();
|
|
216
|
+
}
|
|
217
|
+
}
|
package/server/src/sync.mjs
CHANGED
|
@@ -1,176 +1,248 @@
|
|
|
1
|
-
// bmo-sync folder sharing — wild-workspace's control plane for the daemon.
|
|
2
|
-
//
|
|
3
|
-
// wild-workspace does not run the sync engine; the bmo-sync daemon does
|
|
4
|
-
// (a separate local process — see bmo-sync/daemon/README.md). This module is
|
|
5
|
-
// the thin HTTP client wild-workspace uses to drive it:
|
|
6
|
-
//
|
|
7
|
-
// - pair / detach / list a workspace folder against the local daemon
|
|
8
|
-
// (http://127.0.0.1:8320);
|
|
9
|
-
// - mint an invite against the central server's admin API — but only when
|
|
10
|
-
// an admin key is configured. Most installs only ever *redeem* invites,
|
|
11
|
-
// and that path runs entirely through the daemon and needs no secret.
|
|
12
|
-
//
|
|
13
|
-
// The daemon may not be running. Read paths (health / listWorkspaces /
|
|
14
|
-
// status) degrade to an "offline" result instead of throwing; explicit
|
|
15
|
-
// actions (pair / detach / createInvite) throw a readable error for the UI.
|
|
16
|
-
|
|
17
|
-
const DAEMON_TIMEOUT_MS = 4000;
|
|
18
|
-
// Pairing and invite creation reach the central server on Fly.io, which can
|
|
19
|
-
// cold-start (~1s+) when idle — give those calls a longer leash.
|
|
20
|
-
const SERVER_TIMEOUT_MS = 12000;
|
|
21
|
-
|
|
22
|
-
export class SyncControl {
|
|
23
|
-
/**
|
|
24
|
-
* @param {object} opts
|
|
25
|
-
* @param {string} opts.daemonHttpUrl daemon HTTP origin, e.g. http://127.0.0.1:8320
|
|
26
|
-
* @param {string} opts.bmoSyncServerUrl central server, e.g. https://sync.venturewild.llc
|
|
27
|
-
* @param {string|null} [opts.adminKey] central-server X-Admin-Key; null = redeem-only
|
|
28
|
-
* @param {typeof fetch} [opts.fetchImpl] injectable fetch (tests)
|
|
29
|
-
*/
|
|
30
|
-
constructor({ daemonHttpUrl, bmoSyncServerUrl, adminKey = null, fetchImpl } = {}) {
|
|
31
|
-
this.daemonBase = trimSlash(daemonHttpUrl) || 'http://127.0.0.1:8320';
|
|
32
|
-
this.serverBase = trimSlash(bmoSyncServerUrl) || 'https://sync.venturewild.llc';
|
|
33
|
-
this.adminKey = adminKey || null;
|
|
34
|
-
this._fetch = fetchImpl || globalThis.fetch;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/** True when this install can mint invites (an admin key is configured). */
|
|
38
|
-
get canInvite() {
|
|
39
|
-
return Boolean(this.adminKey);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Liveness of the local daemon. Never throws. */
|
|
43
|
-
async health() {
|
|
44
|
-
try {
|
|
45
|
-
const res = await this._fetch(`${this.daemonBase}/health`, {
|
|
46
|
-
signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
|
|
47
|
-
});
|
|
48
|
-
if (!res.ok) return { running: false };
|
|
49
|
-
const body = await res.json().catch(() => ({}));
|
|
50
|
-
return { running: body?.status === 'ok' };
|
|
51
|
-
} catch {
|
|
52
|
-
return { running: false };
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Paired workspaces the daemon is syncing. [] when the daemon is down. */
|
|
57
|
-
async listWorkspaces() {
|
|
58
|
-
try {
|
|
59
|
-
const res = await this._fetch(`${this.daemonBase}/api/workspaces`, {
|
|
60
|
-
signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
|
|
61
|
-
});
|
|
62
|
-
if (!res.ok) return [];
|
|
63
|
-
const body = await res.json().catch(() => ({}));
|
|
64
|
-
return Array.isArray(body?.workspaces) ? body.workspaces : [];
|
|
65
|
-
} catch {
|
|
66
|
-
return [];
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Snapshot for the Sync panel: daemon liveness, paired workspaces, and
|
|
72
|
-
* whether this install can mint invites. Never throws — a missing daemon
|
|
73
|
-
* is a normal, displayable state.
|
|
74
|
-
*/
|
|
75
|
-
async status() {
|
|
76
|
-
const [health, workspaces] = await Promise.all([
|
|
77
|
-
this.health(),
|
|
78
|
-
this.listWorkspaces(),
|
|
79
|
-
]);
|
|
80
|
-
return {
|
|
81
|
-
daemon: health,
|
|
82
|
-
workspaces,
|
|
83
|
-
paired: workspaces.length > 0,
|
|
84
|
-
canInvite: this.canInvite,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Pair this workspace folder with a shared project by redeeming an invite.
|
|
90
|
-
* The daemon redeems against the central server, persists the pairing, and
|
|
91
|
-
* starts syncing `rootPath`. Throws a readable error on a bad invite or an
|
|
92
|
-
* unreachable daemon.
|
|
93
|
-
*/
|
|
94
|
-
async pair(inviteCode, rootPath) {
|
|
95
|
-
const code = String(inviteCode || '').trim();
|
|
96
|
-
if (!code) throw new Error('An invite code is required.');
|
|
97
|
-
if (!rootPath) throw new Error('No workspace folder to pair.');
|
|
98
|
-
const res = await this._daemonFetch(`${this.daemonBase}/api/pair`, {
|
|
99
|
-
method: 'POST',
|
|
100
|
-
headers: { 'content-type': 'application/json' },
|
|
101
|
-
body: JSON.stringify({ inviteCode: code, rootPath }),
|
|
102
|
-
// Pairing redeems against the central server — allow for a cold start.
|
|
103
|
-
signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
|
|
104
|
-
});
|
|
105
|
-
const body = await res.json().catch(() => ({}));
|
|
106
|
-
if (!res.ok) {
|
|
107
|
-
throw new Error(body?.error || `Pairing failed (HTTP ${res.status}).`);
|
|
108
|
-
}
|
|
109
|
-
return body.workspace || body;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Stop syncing a workspace and forget the pairing. */
|
|
113
|
-
async detach(workspaceId) {
|
|
114
|
-
const id = String(workspaceId || '').trim();
|
|
115
|
-
if (!id) throw new Error('A workspace id is required.');
|
|
116
|
-
const res = await this._daemonFetch(
|
|
117
|
-
`${this.daemonBase}/api/workspaces/${encodeURIComponent(id)}`,
|
|
118
|
-
{ method: 'DELETE', signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS) },
|
|
119
|
-
);
|
|
120
|
-
const body = await res.json().catch(() => ({}));
|
|
121
|
-
if (!res.ok) {
|
|
122
|
-
throw new Error(body?.error || `Disconnect failed (HTTP ${res.status}).`);
|
|
123
|
-
}
|
|
124
|
-
return { detached: Boolean(body.detached) };
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Mint an invite code for a project on the central server. Requires an
|
|
129
|
-
* admin key; callers should check `canInvite` first and fall back to the
|
|
130
|
-
* paste-a-code flow when it is false.
|
|
131
|
-
*/
|
|
132
|
-
async createInvite({ projectCode, displayName, expiresHours = 168 } = {}) {
|
|
133
|
-
if (!this.adminKey) {
|
|
134
|
-
throw new Error(
|
|
135
|
-
'Creating invites needs a bmo-sync admin key (set BMO_SYNC_ADMIN_KEY). ' +
|
|
136
|
-
'Without one, ask whoever owns the shared folder to send you a code.',
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
if (!projectCode) throw new Error('A paired project is required to invite into.');
|
|
140
|
-
let res;
|
|
141
|
-
try {
|
|
142
|
-
res = await this._fetch(`${this.serverBase}/api/admin/invites`, {
|
|
143
|
-
method: 'POST',
|
|
144
|
-
headers: { 'content-type': 'application/json', 'x-admin-key': this.adminKey },
|
|
145
|
-
body: JSON.stringify({
|
|
146
|
-
project_code: projectCode,
|
|
147
|
-
display_name: displayName || 'Collaborator',
|
|
148
|
-
expires_hours: Number(expiresHours) || 168,
|
|
149
|
-
}),
|
|
150
|
-
signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
|
|
151
|
-
});
|
|
152
|
-
} catch {
|
|
153
|
-
throw new Error('Could not reach the bmo-sync server. Check your connection.');
|
|
154
|
-
}
|
|
155
|
-
const body = await res.json().catch(() => ({}));
|
|
156
|
-
if (!res.ok) {
|
|
157
|
-
throw new Error(
|
|
158
|
-
body?.message || body?.error || `Invite creation failed (HTTP ${res.status}).`,
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
return { code: body.code, projectCode: body.project_code, expiresAt: body.expires_at };
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** A daemon fetch that turns a connection failure into a readable error. */
|
|
165
|
-
async _daemonFetch(url, init) {
|
|
166
|
-
try {
|
|
167
|
-
return await this._fetch(url, init);
|
|
168
|
-
} catch {
|
|
169
|
-
throw new Error("The bmo-sync daemon isn't running. Start it, then try again.");
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
1
|
+
// bmo-sync folder sharing — wild-workspace's control plane for the daemon.
|
|
2
|
+
//
|
|
3
|
+
// wild-workspace does not run the sync engine; the bmo-sync daemon does
|
|
4
|
+
// (a separate local process — see bmo-sync/daemon/README.md). This module is
|
|
5
|
+
// the thin HTTP client wild-workspace uses to drive it:
|
|
6
|
+
//
|
|
7
|
+
// - pair / detach / list a workspace folder against the local daemon
|
|
8
|
+
// (http://127.0.0.1:8320);
|
|
9
|
+
// - mint an invite against the central server's admin API — but only when
|
|
10
|
+
// an admin key is configured. Most installs only ever *redeem* invites,
|
|
11
|
+
// and that path runs entirely through the daemon and needs no secret.
|
|
12
|
+
//
|
|
13
|
+
// The daemon may not be running. Read paths (health / listWorkspaces /
|
|
14
|
+
// status) degrade to an "offline" result instead of throwing; explicit
|
|
15
|
+
// actions (pair / detach / createInvite) throw a readable error for the UI.
|
|
16
|
+
|
|
17
|
+
const DAEMON_TIMEOUT_MS = 4000;
|
|
18
|
+
// Pairing and invite creation reach the central server on Fly.io, which can
|
|
19
|
+
// cold-start (~1s+) when idle — give those calls a longer leash.
|
|
20
|
+
const SERVER_TIMEOUT_MS = 12000;
|
|
21
|
+
|
|
22
|
+
export class SyncControl {
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} opts
|
|
25
|
+
* @param {string} opts.daemonHttpUrl daemon HTTP origin, e.g. http://127.0.0.1:8320
|
|
26
|
+
* @param {string} opts.bmoSyncServerUrl central server, e.g. https://sync.venturewild.llc
|
|
27
|
+
* @param {string|null} [opts.adminKey] central-server X-Admin-Key; null = redeem-only
|
|
28
|
+
* @param {typeof fetch} [opts.fetchImpl] injectable fetch (tests)
|
|
29
|
+
*/
|
|
30
|
+
constructor({ daemonHttpUrl, bmoSyncServerUrl, adminKey = null, fetchImpl } = {}) {
|
|
31
|
+
this.daemonBase = trimSlash(daemonHttpUrl) || 'http://127.0.0.1:8320';
|
|
32
|
+
this.serverBase = trimSlash(bmoSyncServerUrl) || 'https://sync.venturewild.llc';
|
|
33
|
+
this.adminKey = adminKey || null;
|
|
34
|
+
this._fetch = fetchImpl || globalThis.fetch;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when this install can mint invites (an admin key is configured). */
|
|
38
|
+
get canInvite() {
|
|
39
|
+
return Boolean(this.adminKey);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Liveness of the local daemon. Never throws. */
|
|
43
|
+
async health() {
|
|
44
|
+
try {
|
|
45
|
+
const res = await this._fetch(`${this.daemonBase}/health`, {
|
|
46
|
+
signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok) return { running: false };
|
|
49
|
+
const body = await res.json().catch(() => ({}));
|
|
50
|
+
return { running: body?.status === 'ok' };
|
|
51
|
+
} catch {
|
|
52
|
+
return { running: false };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Paired workspaces the daemon is syncing. [] when the daemon is down. */
|
|
57
|
+
async listWorkspaces() {
|
|
58
|
+
try {
|
|
59
|
+
const res = await this._fetch(`${this.daemonBase}/api/workspaces`, {
|
|
60
|
+
signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok) return [];
|
|
63
|
+
const body = await res.json().catch(() => ({}));
|
|
64
|
+
return Array.isArray(body?.workspaces) ? body.workspaces : [];
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Snapshot for the Sync panel: daemon liveness, paired workspaces, and
|
|
72
|
+
* whether this install can mint invites. Never throws — a missing daemon
|
|
73
|
+
* is a normal, displayable state.
|
|
74
|
+
*/
|
|
75
|
+
async status() {
|
|
76
|
+
const [health, workspaces] = await Promise.all([
|
|
77
|
+
this.health(),
|
|
78
|
+
this.listWorkspaces(),
|
|
79
|
+
]);
|
|
80
|
+
return {
|
|
81
|
+
daemon: health,
|
|
82
|
+
workspaces,
|
|
83
|
+
paired: workspaces.length > 0,
|
|
84
|
+
canInvite: this.canInvite,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Pair this workspace folder with a shared project by redeeming an invite.
|
|
90
|
+
* The daemon redeems against the central server, persists the pairing, and
|
|
91
|
+
* starts syncing `rootPath`. Throws a readable error on a bad invite or an
|
|
92
|
+
* unreachable daemon.
|
|
93
|
+
*/
|
|
94
|
+
async pair(inviteCode, rootPath) {
|
|
95
|
+
const code = String(inviteCode || '').trim();
|
|
96
|
+
if (!code) throw new Error('An invite code is required.');
|
|
97
|
+
if (!rootPath) throw new Error('No workspace folder to pair.');
|
|
98
|
+
const res = await this._daemonFetch(`${this.daemonBase}/api/pair`, {
|
|
99
|
+
method: 'POST',
|
|
100
|
+
headers: { 'content-type': 'application/json' },
|
|
101
|
+
body: JSON.stringify({ inviteCode: code, rootPath }),
|
|
102
|
+
// Pairing redeems against the central server — allow for a cold start.
|
|
103
|
+
signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
|
|
104
|
+
});
|
|
105
|
+
const body = await res.json().catch(() => ({}));
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
throw new Error(body?.error || `Pairing failed (HTTP ${res.status}).`);
|
|
108
|
+
}
|
|
109
|
+
return body.workspace || body;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Stop syncing a workspace and forget the pairing. */
|
|
113
|
+
async detach(workspaceId) {
|
|
114
|
+
const id = String(workspaceId || '').trim();
|
|
115
|
+
if (!id) throw new Error('A workspace id is required.');
|
|
116
|
+
const res = await this._daemonFetch(
|
|
117
|
+
`${this.daemonBase}/api/workspaces/${encodeURIComponent(id)}`,
|
|
118
|
+
{ method: 'DELETE', signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS) },
|
|
119
|
+
);
|
|
120
|
+
const body = await res.json().catch(() => ({}));
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
throw new Error(body?.error || `Disconnect failed (HTTP ${res.status}).`);
|
|
123
|
+
}
|
|
124
|
+
return { detached: Boolean(body.detached) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Mint an invite code for a project on the central server. Requires an
|
|
129
|
+
* admin key; callers should check `canInvite` first and fall back to the
|
|
130
|
+
* paste-a-code flow when it is false.
|
|
131
|
+
*/
|
|
132
|
+
async createInvite({ projectCode, displayName, expiresHours = 168 } = {}) {
|
|
133
|
+
if (!this.adminKey) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
'Creating invites needs a bmo-sync admin key (set BMO_SYNC_ADMIN_KEY). ' +
|
|
136
|
+
'Without one, ask whoever owns the shared folder to send you a code.',
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (!projectCode) throw new Error('A paired project is required to invite into.');
|
|
140
|
+
let res;
|
|
141
|
+
try {
|
|
142
|
+
res = await this._fetch(`${this.serverBase}/api/admin/invites`, {
|
|
143
|
+
method: 'POST',
|
|
144
|
+
headers: { 'content-type': 'application/json', 'x-admin-key': this.adminKey },
|
|
145
|
+
body: JSON.stringify({
|
|
146
|
+
project_code: projectCode,
|
|
147
|
+
display_name: displayName || 'Collaborator',
|
|
148
|
+
expires_hours: Number(expiresHours) || 168,
|
|
149
|
+
}),
|
|
150
|
+
signal: AbortSignal.timeout(SERVER_TIMEOUT_MS),
|
|
151
|
+
});
|
|
152
|
+
} catch {
|
|
153
|
+
throw new Error('Could not reach the bmo-sync server. Check your connection.');
|
|
154
|
+
}
|
|
155
|
+
const body = await res.json().catch(() => ({}));
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
body?.message || body?.error || `Invite creation failed (HTTP ${res.status}).`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return { code: body.code, projectCode: body.project_code, expiresAt: body.expires_at };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A daemon fetch that turns a connection failure into a readable error. */
|
|
165
|
+
async _daemonFetch(url, init) {
|
|
166
|
+
try {
|
|
167
|
+
return await this._fetch(url, init);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new Error("The bmo-sync daemon isn't running. Start it, then try again.");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── C12-e: conflict surface ──────────────────────────────────────────
|
|
174
|
+
// The daemon emits SyncEvent::Conflict via /api/events (already piped
|
|
175
|
+
// into the wild-workspace ActivityBus by DaemonBridge); these methods
|
|
176
|
+
// are the explicit "give me / resolve" operations behind the badge
|
|
177
|
+
// and CLI.
|
|
178
|
+
|
|
179
|
+
/** All open conflicts across every paired workspace. [] when daemon down. */
|
|
180
|
+
async listConflicts() {
|
|
181
|
+
try {
|
|
182
|
+
const res = await this._fetch(`${this.daemonBase}/api/conflicts`, {
|
|
183
|
+
signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
|
|
184
|
+
});
|
|
185
|
+
if (!res.ok) return [];
|
|
186
|
+
const body = await res.json().catch(() => ({}));
|
|
187
|
+
return Array.isArray(body?.conflicts) ? body.conflicts : [];
|
|
188
|
+
} catch {
|
|
189
|
+
return [];
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Fetch one conflict's mine/theirs bytes (base64) plus the row. Used by
|
|
195
|
+
* the human-fallback diff panel + the `wild_conflicts_view` agent tool.
|
|
196
|
+
*/
|
|
197
|
+
async viewConflict(workspaceId, path) {
|
|
198
|
+
if (!workspaceId) throw new Error('workspaceId is required');
|
|
199
|
+
if (!path) throw new Error('path is required');
|
|
200
|
+
const url =
|
|
201
|
+
`${this.daemonBase}/api/conflicts/` +
|
|
202
|
+
`${encodeURIComponent(workspaceId)}/` +
|
|
203
|
+
path.split('/').map(encodeURIComponent).join('/');
|
|
204
|
+
const res = await this._daemonFetch(url, {
|
|
205
|
+
signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
|
|
206
|
+
});
|
|
207
|
+
const body = await res.json().catch(() => ({}));
|
|
208
|
+
if (res.status === 404) return null;
|
|
209
|
+
if (!res.ok) {
|
|
210
|
+
throw new Error(body?.error || `Conflict view failed (HTTP ${res.status}).`);
|
|
211
|
+
}
|
|
212
|
+
return body;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Resolve a conflict. `action` is `keep_mine` or `take_theirs`. The
|
|
217
|
+
* server-mediated multi-peer broadcast is a V1.1 follow-up; for V1
|
|
218
|
+
* this is local-only.
|
|
219
|
+
*/
|
|
220
|
+
async resolveConflict(workspaceId, path, action) {
|
|
221
|
+
const verb = String(action || '').trim();
|
|
222
|
+
if (!['keep_mine', 'take_theirs'].includes(verb)) {
|
|
223
|
+
throw new Error('action must be "keep_mine" or "take_theirs".');
|
|
224
|
+
}
|
|
225
|
+
// GET = view, POST = resolve on the same URL — the daemon's route was
|
|
226
|
+
// collapsed because axum 0.8 disallows a literal segment after a
|
|
227
|
+
// `{*path}` catchall.
|
|
228
|
+
const url =
|
|
229
|
+
`${this.daemonBase}/api/conflicts/` +
|
|
230
|
+
`${encodeURIComponent(workspaceId)}/` +
|
|
231
|
+
path.split('/').map(encodeURIComponent).join('/');
|
|
232
|
+
const res = await this._daemonFetch(url, {
|
|
233
|
+
method: 'POST',
|
|
234
|
+
headers: { 'content-type': 'application/json' },
|
|
235
|
+
body: JSON.stringify({ action: verb }),
|
|
236
|
+
signal: AbortSignal.timeout(DAEMON_TIMEOUT_MS),
|
|
237
|
+
});
|
|
238
|
+
const body = await res.json().catch(() => ({}));
|
|
239
|
+
if (!res.ok) {
|
|
240
|
+
throw new Error(body?.error || `Resolve failed (HTTP ${res.status}).`);
|
|
241
|
+
}
|
|
242
|
+
return { ok: true };
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function trimSlash(u) {
|
|
247
|
+
return typeof u === 'string' ? u.replace(/\/+$/, '') : '';
|
|
248
|
+
}
|