@venturewild/workspace 0.1.2 → 0.1.4
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 -112
- package/package.json +75 -75
- package/server/bin/wild-workspace.mjs +725 -725
- package/server/src/agent.mjs +356 -356
- package/server/src/config.mjs +314 -302
- package/server/src/daemon-supervisor.mjs +216 -216
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +1330 -1330
- package/server/src/service.mjs +202 -32
- package/server/src/sync.mjs +248 -248
package/server/src/sync.mjs
CHANGED
|
@@ -1,248 +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
|
-
// ── 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
|
-
}
|
|
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
|
+
}
|