@rsktash/beads-ui 0.1.0
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/.github/workflows/publish.yml +28 -0
- package/app/protocol.js +216 -0
- package/bin/bdui +19 -0
- package/client/index.html +12 -0
- package/client/postcss.config.js +11 -0
- package/client/src/App.tsx +35 -0
- package/client/src/components/IssueCard.tsx +73 -0
- package/client/src/components/Layout.tsx +175 -0
- package/client/src/components/Markdown.tsx +77 -0
- package/client/src/components/PriorityBadge.tsx +26 -0
- package/client/src/components/SearchDialog.tsx +137 -0
- package/client/src/components/SectionEditor.tsx +212 -0
- package/client/src/components/StatusBadge.tsx +64 -0
- package/client/src/components/TypeBadge.tsx +26 -0
- package/client/src/hooks/use-mutation.ts +55 -0
- package/client/src/hooks/use-search.ts +19 -0
- package/client/src/hooks/use-subscription.ts +187 -0
- package/client/src/index.css +133 -0
- package/client/src/lib/avatar.ts +17 -0
- package/client/src/lib/types.ts +115 -0
- package/client/src/lib/ws-client.ts +214 -0
- package/client/src/lib/ws-context.tsx +28 -0
- package/client/src/main.tsx +10 -0
- package/client/src/views/Board.tsx +200 -0
- package/client/src/views/Detail.tsx +398 -0
- package/client/src/views/List.tsx +461 -0
- package/client/tailwind.config.ts +68 -0
- package/client/tsconfig.json +16 -0
- package/client/vite.config.ts +20 -0
- package/package.json +43 -0
- package/server/app.js +120 -0
- package/server/app.test.js +30 -0
- package/server/bd.js +227 -0
- package/server/bd.test.js +194 -0
- package/server/cli/cli.test.js +207 -0
- package/server/cli/commands.integration.test.js +148 -0
- package/server/cli/commands.js +285 -0
- package/server/cli/commands.unit.test.js +408 -0
- package/server/cli/daemon.js +340 -0
- package/server/cli/daemon.test.js +31 -0
- package/server/cli/index.js +135 -0
- package/server/cli/open.js +178 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +27 -0
- package/server/config.js +36 -0
- package/server/db.js +154 -0
- package/server/db.test.js +169 -0
- package/server/dolt-pool.js +257 -0
- package/server/dolt-queries.js +646 -0
- package/server/index.js +97 -0
- package/server/list-adapters.js +395 -0
- package/server/list-adapters.test.js +208 -0
- package/server/logging.js +23 -0
- package/server/registry-watcher.js +200 -0
- package/server/subscriptions.js +299 -0
- package/server/subscriptions.test.js +128 -0
- package/server/validators.js +124 -0
- package/server/watcher.js +139 -0
- package/server/watcher.test.js +120 -0
- package/server/ws.comments.test.js +262 -0
- package/server/ws.delete.test.js +119 -0
- package/server/ws.js +1309 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.list-refresh.coalesce.test.js +95 -0
- package/server/ws.list-subscriptions.test.js +403 -0
- package/server/ws.mutation-window.test.js +147 -0
- package/server/ws.mutations.test.js +389 -0
- package/server/ws.test.js +52 -0
package/server/ws.js
ADDED
|
@@ -0,0 +1,1309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Server } from 'node:http'
|
|
3
|
+
* @import { RawData, WebSocket } from 'ws'
|
|
4
|
+
* @import { MessageType } from '../app/protocol.js'
|
|
5
|
+
*/
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { WebSocketServer } from 'ws';
|
|
8
|
+
import { isRequest, makeError, makeOk } from '../app/protocol.js';
|
|
9
|
+
import { getGitUserName, runBd, runBdJson } from './bd.js';
|
|
10
|
+
import { resolveWorkspaceDatabase } from './db.js';
|
|
11
|
+
import {
|
|
12
|
+
addComment,
|
|
13
|
+
addDependency,
|
|
14
|
+
addLabel,
|
|
15
|
+
deleteIssue,
|
|
16
|
+
isDoltPoolReady,
|
|
17
|
+
queryComments,
|
|
18
|
+
queryIssueDetail,
|
|
19
|
+
removeDependency,
|
|
20
|
+
removeLabel,
|
|
21
|
+
updateIssueField
|
|
22
|
+
} from './dolt-queries.js';
|
|
23
|
+
import {
|
|
24
|
+
enrichIssueDetailParentContext,
|
|
25
|
+
fetchListForSubscription
|
|
26
|
+
} from './list-adapters.js';
|
|
27
|
+
import { debug } from './logging.js';
|
|
28
|
+
import { getAvailableWorkspaces } from './registry-watcher.js';
|
|
29
|
+
import { keyOf, registry } from './subscriptions.js';
|
|
30
|
+
import { validateSubscribeListPayload } from './validators.js';
|
|
31
|
+
|
|
32
|
+
const log = debug('ws');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Run a mutation via SQL (with detail refetch) or bd CLI fallback, send the
|
|
36
|
+
* result to the client, and trigger a subscription refresh.
|
|
37
|
+
*
|
|
38
|
+
* Covers the common pattern: mutate → fetch updated issue → respond → refresh.
|
|
39
|
+
*
|
|
40
|
+
* @param {WebSocket} ws
|
|
41
|
+
* @param {any} req
|
|
42
|
+
* @param {string} detailId - Issue ID to fetch after mutation
|
|
43
|
+
* @param {() => Promise<{ ok: boolean, error?: { code: string, message: string } }>} sqlMutateFn
|
|
44
|
+
* @param {string[]} bdArgs - Arguments for the bd CLI fallback
|
|
45
|
+
*/
|
|
46
|
+
async function mutateAndRespond(ws, req, detailId, sqlMutateFn, bdArgs) {
|
|
47
|
+
if (isDoltPoolReady()) {
|
|
48
|
+
const upd = await sqlMutateFn();
|
|
49
|
+
if (!upd.ok) {
|
|
50
|
+
ws.send(JSON.stringify(makeError(req, upd.error.code, upd.error.message)));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const detail = await queryIssueDetail(detailId);
|
|
54
|
+
if (!detail.ok) {
|
|
55
|
+
ws.send(JSON.stringify(makeError(req, detail.error.code, detail.error.message)));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
ws.send(JSON.stringify(makeOk(req, detail.item)));
|
|
59
|
+
} else {
|
|
60
|
+
const res = await runBd(bdArgs);
|
|
61
|
+
if (res.code !== 0) {
|
|
62
|
+
ws.send(JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed')));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const shown = await runBdJson(['show', detailId, '--json']);
|
|
66
|
+
if (shown.code !== 0) {
|
|
67
|
+
ws.send(JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed')));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const detail =
|
|
71
|
+
shown.stdoutJson && typeof shown.stdoutJson === 'object' && !Array.isArray(shown.stdoutJson)
|
|
72
|
+
? await enrichIssueDetailParentContext(
|
|
73
|
+
/** @type {Record<string, unknown>} */ (shown.stdoutJson),
|
|
74
|
+
{ cwd: CURRENT_WORKSPACE?.root_dir }
|
|
75
|
+
)
|
|
76
|
+
: shown.stdoutJson;
|
|
77
|
+
ws.send(JSON.stringify(makeOk(req, detail)));
|
|
78
|
+
}
|
|
79
|
+
try { triggerMutationRefreshOnce(); } catch { /* ignore */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Debounced refresh scheduling for active list subscriptions.
|
|
84
|
+
* A trailing window coalesces rapid change bursts into a single refresh run.
|
|
85
|
+
*/
|
|
86
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
87
|
+
let REFRESH_TIMER = null;
|
|
88
|
+
let REFRESH_DEBOUNCE_MS = 75;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Mutation refresh window gate. When active, watcher-driven list refresh
|
|
92
|
+
* scheduling is suppressed. The gate resolves either when a watcher event
|
|
93
|
+
* arrives (via scheduleListRefresh) or when a timeout elapses, at which
|
|
94
|
+
* point a single refresh pass over all active list subscriptions is run.
|
|
95
|
+
*/
|
|
96
|
+
/**
|
|
97
|
+
* @typedef {Object} MutationGate
|
|
98
|
+
* @property {boolean} resolved
|
|
99
|
+
* @property {(reason: 'watcher'|'timeout') => void} resolve
|
|
100
|
+
* @property {ReturnType<typeof setTimeout>} timer
|
|
101
|
+
*/
|
|
102
|
+
/** @type {MutationGate | null} */
|
|
103
|
+
let MUTATION_GATE = null;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Start a mutation window gate if not already active. The gate resolves on the
|
|
107
|
+
* next watcher event or after `timeout_ms`, then triggers a single refresh run
|
|
108
|
+
* across all active list subscriptions. Watcher-driven refresh scheduling is
|
|
109
|
+
* suppressed during the window.
|
|
110
|
+
*
|
|
111
|
+
* Fire-and-forget; callers should not await this.
|
|
112
|
+
*
|
|
113
|
+
* @param {number} [timeout_ms]
|
|
114
|
+
*/
|
|
115
|
+
function triggerMutationRefreshOnce(timeout_ms = 500) {
|
|
116
|
+
if (MUTATION_GATE) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
/** @type {(r: 'watcher'|'timeout') => void} */
|
|
120
|
+
let doResolve = () => {};
|
|
121
|
+
const p = new Promise((resolve) => {
|
|
122
|
+
doResolve = resolve;
|
|
123
|
+
});
|
|
124
|
+
MUTATION_GATE = {
|
|
125
|
+
resolved: false,
|
|
126
|
+
resolve: (reason) => {
|
|
127
|
+
if (!MUTATION_GATE || MUTATION_GATE.resolved) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
MUTATION_GATE.resolved = true;
|
|
131
|
+
try {
|
|
132
|
+
doResolve(reason);
|
|
133
|
+
} catch {
|
|
134
|
+
// ignore resolve errors
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
timer: setTimeout(() => {
|
|
138
|
+
try {
|
|
139
|
+
MUTATION_GATE?.resolve('timeout');
|
|
140
|
+
} catch {
|
|
141
|
+
// ignore
|
|
142
|
+
}
|
|
143
|
+
}, timeout_ms)
|
|
144
|
+
};
|
|
145
|
+
MUTATION_GATE.timer.unref?.();
|
|
146
|
+
|
|
147
|
+
// After resolution, run a single refresh across active subs and clear gate
|
|
148
|
+
void p.then(async () => {
|
|
149
|
+
log('mutation window resolved → refresh active subs');
|
|
150
|
+
try {
|
|
151
|
+
await refreshAllActiveListSubscriptions();
|
|
152
|
+
} catch {
|
|
153
|
+
// ignore refresh errors
|
|
154
|
+
} finally {
|
|
155
|
+
try {
|
|
156
|
+
if (MUTATION_GATE?.timer) {
|
|
157
|
+
clearTimeout(MUTATION_GATE.timer);
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
// ignore
|
|
161
|
+
}
|
|
162
|
+
MUTATION_GATE = null;
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Collect unique active list subscription specs across all connected clients.
|
|
169
|
+
*
|
|
170
|
+
* @returns {Array<{ type: string, params?: Record<string,string|number|boolean> }>}
|
|
171
|
+
*/
|
|
172
|
+
function collectActiveListSpecs() {
|
|
173
|
+
/** @type {Array<{ type: string, params?: Record<string,string|number|boolean> }>} */
|
|
174
|
+
const specs = [];
|
|
175
|
+
/** @type {Set<string>} */
|
|
176
|
+
const seen = new Set();
|
|
177
|
+
const wss = CURRENT_WSS;
|
|
178
|
+
if (!wss) {
|
|
179
|
+
return specs;
|
|
180
|
+
}
|
|
181
|
+
for (const ws of wss.clients) {
|
|
182
|
+
if (ws.readyState !== ws.OPEN) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const s = ensureSubs(/** @type {any} */ (ws));
|
|
186
|
+
if (!s.list_subs) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
for (const { key, spec } of s.list_subs.values()) {
|
|
190
|
+
if (!seen.has(key)) {
|
|
191
|
+
seen.add(key);
|
|
192
|
+
specs.push(spec);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return specs;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Run refresh for all active list subscription specs and publish deltas.
|
|
201
|
+
*/
|
|
202
|
+
async function refreshAllActiveListSubscriptions() {
|
|
203
|
+
const specs = collectActiveListSpecs();
|
|
204
|
+
// Run refreshes concurrently; locking is handled per key in the registry
|
|
205
|
+
await Promise.all(
|
|
206
|
+
specs.map(async (spec) => {
|
|
207
|
+
try {
|
|
208
|
+
await refreshAndPublish(spec);
|
|
209
|
+
} catch {
|
|
210
|
+
// ignore refresh errors per spec
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Schedule a coalesced refresh of all active list subscriptions.
|
|
218
|
+
*/
|
|
219
|
+
export function scheduleListRefresh() {
|
|
220
|
+
// Suppress watcher-driven refreshes during an active mutation gate; resolve gate once
|
|
221
|
+
if (MUTATION_GATE) {
|
|
222
|
+
try {
|
|
223
|
+
MUTATION_GATE.resolve('watcher');
|
|
224
|
+
} catch {
|
|
225
|
+
// ignore
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (REFRESH_TIMER) {
|
|
230
|
+
clearTimeout(REFRESH_TIMER);
|
|
231
|
+
}
|
|
232
|
+
REFRESH_TIMER = setTimeout(() => {
|
|
233
|
+
REFRESH_TIMER = null;
|
|
234
|
+
// Fire and forget; callers don't await scheduling
|
|
235
|
+
void refreshAllActiveListSubscriptions();
|
|
236
|
+
}, REFRESH_DEBOUNCE_MS);
|
|
237
|
+
REFRESH_TIMER.unref?.();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* @typedef {{
|
|
242
|
+
* show_id?: string | null,
|
|
243
|
+
* list_subs?: Map<string, { key: string, spec: { type: string, params?: Record<string, string | number | boolean> } }>,
|
|
244
|
+
* list_revisions?: Map<string, number>
|
|
245
|
+
* }} ConnectionSubs
|
|
246
|
+
*/
|
|
247
|
+
|
|
248
|
+
/** @type {WeakMap<WebSocket, any>} */
|
|
249
|
+
const SUBS = new WeakMap();
|
|
250
|
+
|
|
251
|
+
/** @type {WebSocketServer | null} */
|
|
252
|
+
let CURRENT_WSS = null;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Current workspace configuration.
|
|
256
|
+
*
|
|
257
|
+
* @type {{ root_dir: string, db_path: string } | null}
|
|
258
|
+
*/
|
|
259
|
+
let CURRENT_WORKSPACE = null;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Reference to the database watcher for rebinding on workspace change.
|
|
263
|
+
*
|
|
264
|
+
* @type {{ rebind: (opts?: { root_dir?: string }) => void, path: string } | null}
|
|
265
|
+
*/
|
|
266
|
+
let DB_WATCHER = null;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Get or initialize the subscription state for a socket.
|
|
270
|
+
*
|
|
271
|
+
* @param {WebSocket} ws
|
|
272
|
+
* @returns {any}
|
|
273
|
+
*/
|
|
274
|
+
function ensureSubs(ws) {
|
|
275
|
+
let s = SUBS.get(ws);
|
|
276
|
+
if (!s) {
|
|
277
|
+
s = {
|
|
278
|
+
show_id: null,
|
|
279
|
+
list_subs: new Map(),
|
|
280
|
+
list_revisions: new Map()
|
|
281
|
+
};
|
|
282
|
+
SUBS.set(ws, s);
|
|
283
|
+
}
|
|
284
|
+
return s;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get next monotonically increasing revision for a subscription key on this connection.
|
|
289
|
+
*
|
|
290
|
+
* @param {WebSocket} ws
|
|
291
|
+
* @param {string} key
|
|
292
|
+
*/
|
|
293
|
+
/**
|
|
294
|
+
* @param {WebSocket} ws
|
|
295
|
+
* @param {string} key
|
|
296
|
+
*/
|
|
297
|
+
function nextListRevision(ws, key) {
|
|
298
|
+
const s = ensureSubs(ws);
|
|
299
|
+
const m = s.list_revisions || new Map();
|
|
300
|
+
s.list_revisions = m;
|
|
301
|
+
const prev = m.get(key) || 0;
|
|
302
|
+
const next = prev + 1;
|
|
303
|
+
m.set(key, next);
|
|
304
|
+
return next;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Emit per-subscription envelopes to a specific client id on a socket.
|
|
309
|
+
* Helpers for snapshot / upsert / delete.
|
|
310
|
+
*/
|
|
311
|
+
/**
|
|
312
|
+
* @param {WebSocket} ws
|
|
313
|
+
* @param {string} client_id
|
|
314
|
+
* @param {string} key
|
|
315
|
+
* @param {Array<Record<string, unknown>>} issues
|
|
316
|
+
* @param {number} [total] - Total count for paginated queries
|
|
317
|
+
*/
|
|
318
|
+
function emitSubscriptionSnapshot(ws, client_id, key, issues, total) {
|
|
319
|
+
const revision = nextListRevision(ws, key);
|
|
320
|
+
const payload = {
|
|
321
|
+
type: /** @type {const} */ ('snapshot'),
|
|
322
|
+
id: client_id,
|
|
323
|
+
revision,
|
|
324
|
+
issues,
|
|
325
|
+
...(typeof total === 'number' ? { total } : {})
|
|
326
|
+
};
|
|
327
|
+
const msg = JSON.stringify({
|
|
328
|
+
id: `evt-${Date.now()}`,
|
|
329
|
+
ok: true,
|
|
330
|
+
type: /** @type {MessageType} */ ('snapshot'),
|
|
331
|
+
payload
|
|
332
|
+
});
|
|
333
|
+
try {
|
|
334
|
+
ws.send(msg);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
log('emit snapshot send failed key=%s id=%s: %o', key, client_id, err);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* @param {WebSocket} ws
|
|
342
|
+
* @param {string} client_id
|
|
343
|
+
* @param {string} key
|
|
344
|
+
* @param {Record<string, unknown>} issue
|
|
345
|
+
*/
|
|
346
|
+
function emitSubscriptionUpsert(ws, client_id, key, issue) {
|
|
347
|
+
const revision = nextListRevision(ws, key);
|
|
348
|
+
const payload = {
|
|
349
|
+
type: 'upsert',
|
|
350
|
+
id: client_id,
|
|
351
|
+
revision,
|
|
352
|
+
issue
|
|
353
|
+
};
|
|
354
|
+
const msg = JSON.stringify({
|
|
355
|
+
id: `evt-${Date.now()}`,
|
|
356
|
+
ok: true,
|
|
357
|
+
type: /** @type {MessageType} */ ('upsert'),
|
|
358
|
+
payload
|
|
359
|
+
});
|
|
360
|
+
try {
|
|
361
|
+
ws.send(msg);
|
|
362
|
+
} catch (err) {
|
|
363
|
+
log('emit upsert send failed key=%s id=%s: %o', key, client_id, err);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* @param {WebSocket} ws
|
|
369
|
+
* @param {string} client_id
|
|
370
|
+
* @param {string} key
|
|
371
|
+
* @param {string} issue_id
|
|
372
|
+
*/
|
|
373
|
+
function emitSubscriptionDelete(ws, client_id, key, issue_id) {
|
|
374
|
+
const revision = nextListRevision(ws, key);
|
|
375
|
+
const payload = {
|
|
376
|
+
type: 'delete',
|
|
377
|
+
id: client_id,
|
|
378
|
+
revision,
|
|
379
|
+
issue_id
|
|
380
|
+
};
|
|
381
|
+
const msg = JSON.stringify({
|
|
382
|
+
id: `evt-${Date.now()}`,
|
|
383
|
+
ok: true,
|
|
384
|
+
type: /** @type {MessageType} */ ('delete'),
|
|
385
|
+
payload
|
|
386
|
+
});
|
|
387
|
+
try {
|
|
388
|
+
ws.send(msg);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
log('emit delete send failed key=%s id=%s: %o', key, client_id, err);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// issues-changed removed in v2: detail and lists are pushed via subscriptions
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Refresh a subscription spec: fetch via adapter, apply to registry and emit
|
|
398
|
+
* per-subscription full-issue envelopes to subscribers. Serialized per key.
|
|
399
|
+
*
|
|
400
|
+
* @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
|
|
401
|
+
*/
|
|
402
|
+
async function refreshAndPublish(spec) {
|
|
403
|
+
const key = keyOf(spec);
|
|
404
|
+
await registry.withKeyLock(key, async () => {
|
|
405
|
+
const res = await fetchListForSubscription(spec, {
|
|
406
|
+
cwd: CURRENT_WORKSPACE?.root_dir
|
|
407
|
+
});
|
|
408
|
+
if (!res.ok) {
|
|
409
|
+
log('refresh failed for %s: %s %o', key, res.error.message, res.error);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const items = applyClosedIssuesFilter(spec, res.items);
|
|
413
|
+
const prev_size = registry.get(key)?.itemsById.size || 0;
|
|
414
|
+
const delta = registry.applyItems(key, items);
|
|
415
|
+
const entry = registry.get(key);
|
|
416
|
+
if (!entry || entry.subscribers.size === 0) {
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
/** @type {Map<string, any>} */
|
|
420
|
+
const by_id = new Map();
|
|
421
|
+
for (const it of items) {
|
|
422
|
+
if (it && typeof it.id === 'string') {
|
|
423
|
+
by_id.set(it.id, it);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
for (const ws of entry.subscribers) {
|
|
427
|
+
if (ws.readyState !== ws.OPEN) continue;
|
|
428
|
+
const s = ensureSubs(ws);
|
|
429
|
+
const subs = s.list_subs || new Map();
|
|
430
|
+
/** @type {string[]} */
|
|
431
|
+
const client_ids = [];
|
|
432
|
+
for (const [cid, v] of subs.entries()) {
|
|
433
|
+
if (v.key === key) client_ids.push(cid);
|
|
434
|
+
}
|
|
435
|
+
if (client_ids.length === 0) continue;
|
|
436
|
+
if (prev_size === 0) {
|
|
437
|
+
for (const cid of client_ids) {
|
|
438
|
+
emitSubscriptionSnapshot(ws, cid, key, items);
|
|
439
|
+
}
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
for (const cid of client_ids) {
|
|
443
|
+
for (const id of [...delta.added, ...delta.updated]) {
|
|
444
|
+
const issue = by_id.get(id);
|
|
445
|
+
if (issue) {
|
|
446
|
+
emitSubscriptionUpsert(ws, cid, key, issue);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
for (const id of delta.removed) {
|
|
450
|
+
emitSubscriptionDelete(ws, cid, key, id);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Apply pre-diff filtering for closed-issues lists based on spec.params.since (epoch ms).
|
|
459
|
+
*
|
|
460
|
+
* @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
|
|
461
|
+
* @param {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
|
|
462
|
+
*/
|
|
463
|
+
function applyClosedIssuesFilter(spec, items) {
|
|
464
|
+
if (String(spec.type) !== 'closed-issues') {
|
|
465
|
+
return items;
|
|
466
|
+
}
|
|
467
|
+
const p = spec.params || {};
|
|
468
|
+
const since = typeof p.since === 'number' ? p.since : 0;
|
|
469
|
+
if (!Number.isFinite(since) || since <= 0) {
|
|
470
|
+
return items;
|
|
471
|
+
}
|
|
472
|
+
/** @type {typeof items} */
|
|
473
|
+
const out = [];
|
|
474
|
+
for (const it of items) {
|
|
475
|
+
const ca = it.closed_at;
|
|
476
|
+
if (typeof ca === 'number' && Number.isFinite(ca) && ca >= since) {
|
|
477
|
+
out.push(it);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Attach a WebSocket server to an existing HTTP server.
|
|
485
|
+
*
|
|
486
|
+
* @param {Server} http_server
|
|
487
|
+
* @param {{ path?: string, heartbeat_ms?: number, refresh_debounce_ms?: number, root_dir?: string, watcher?: { rebind: (opts?: { root_dir?: string }) => void, path: string } }} [options]
|
|
488
|
+
* @returns {{ wss: WebSocketServer, broadcast: (type: MessageType, payload?: unknown) => void, scheduleListRefresh: () => void, setWorkspace: (root_dir: string) => { changed: boolean, workspace: { root_dir: string, db_path: string } } }}
|
|
489
|
+
*/
|
|
490
|
+
export function attachWsServer(http_server, options = {}) {
|
|
491
|
+
const ws_path = options.path || '/ws';
|
|
492
|
+
|
|
493
|
+
// Initialize workspace state
|
|
494
|
+
const initial_root = options.root_dir || process.cwd();
|
|
495
|
+
const initial_db = resolveWorkspaceDatabase({ cwd: initial_root });
|
|
496
|
+
CURRENT_WORKSPACE = {
|
|
497
|
+
root_dir: initial_root,
|
|
498
|
+
db_path: initial_db.path
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
if (options.watcher) {
|
|
502
|
+
DB_WATCHER = options.watcher;
|
|
503
|
+
}
|
|
504
|
+
const heartbeat_ms = options.heartbeat_ms ?? 30000;
|
|
505
|
+
if (typeof options.refresh_debounce_ms === 'number') {
|
|
506
|
+
const n = options.refresh_debounce_ms;
|
|
507
|
+
if (Number.isFinite(n) && n >= 0) {
|
|
508
|
+
REFRESH_DEBOUNCE_MS = n;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const wss = new WebSocketServer({ server: http_server, path: ws_path });
|
|
513
|
+
CURRENT_WSS = wss;
|
|
514
|
+
|
|
515
|
+
// Heartbeat: track if client answered the last ping
|
|
516
|
+
wss.on('connection', (ws) => {
|
|
517
|
+
log('client connected');
|
|
518
|
+
// @ts-expect-error add marker property
|
|
519
|
+
ws.isAlive = true;
|
|
520
|
+
|
|
521
|
+
// Initialize subscription state for this connection
|
|
522
|
+
ensureSubs(ws);
|
|
523
|
+
|
|
524
|
+
ws.on('pong', () => {
|
|
525
|
+
// @ts-expect-error marker
|
|
526
|
+
ws.isAlive = true;
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
ws.on('message', (data) => {
|
|
530
|
+
handleMessage(ws, data);
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
ws.on('close', () => {
|
|
534
|
+
try {
|
|
535
|
+
registry.onDisconnect(ws);
|
|
536
|
+
} catch {
|
|
537
|
+
// ignore cleanup errors
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const interval = setInterval(() => {
|
|
543
|
+
for (const ws of wss.clients) {
|
|
544
|
+
// @ts-expect-error marker
|
|
545
|
+
if (ws.isAlive === false) {
|
|
546
|
+
ws.terminate();
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
// @ts-expect-error marker
|
|
550
|
+
ws.isAlive = false;
|
|
551
|
+
ws.ping();
|
|
552
|
+
}
|
|
553
|
+
}, heartbeat_ms);
|
|
554
|
+
|
|
555
|
+
interval.unref?.();
|
|
556
|
+
|
|
557
|
+
wss.on('close', () => {
|
|
558
|
+
clearInterval(interval);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Broadcast a server-initiated event to all open clients.
|
|
563
|
+
*
|
|
564
|
+
* @param {MessageType} type
|
|
565
|
+
* @param {unknown} [payload]
|
|
566
|
+
*/
|
|
567
|
+
function broadcast(type, payload) {
|
|
568
|
+
const msg = JSON.stringify({
|
|
569
|
+
id: `evt-${Date.now()}`,
|
|
570
|
+
ok: true,
|
|
571
|
+
type,
|
|
572
|
+
payload
|
|
573
|
+
});
|
|
574
|
+
for (const ws of wss.clients) {
|
|
575
|
+
if (ws.readyState === ws.OPEN) {
|
|
576
|
+
ws.send(msg);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Change the current workspace and rebind the database watcher.
|
|
583
|
+
*
|
|
584
|
+
* @param {string} new_root_dir - Absolute path to the new workspace root.
|
|
585
|
+
* @returns {{ changed: boolean, workspace: { root_dir: string, db_path: string } }}
|
|
586
|
+
*/
|
|
587
|
+
function setWorkspace(new_root_dir) {
|
|
588
|
+
const resolved_root = path.resolve(new_root_dir);
|
|
589
|
+
const new_db = resolveWorkspaceDatabase({ cwd: resolved_root });
|
|
590
|
+
const old_path = CURRENT_WORKSPACE?.db_path || '';
|
|
591
|
+
|
|
592
|
+
CURRENT_WORKSPACE = {
|
|
593
|
+
root_dir: resolved_root,
|
|
594
|
+
db_path: new_db.path
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const changed = new_db.path !== old_path;
|
|
598
|
+
|
|
599
|
+
if (changed) {
|
|
600
|
+
log('workspace changed: %s → %s', old_path, new_db.path);
|
|
601
|
+
|
|
602
|
+
// Rebind the database watcher to the new workspace
|
|
603
|
+
if (DB_WATCHER) {
|
|
604
|
+
DB_WATCHER.rebind({ root_dir: resolved_root });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Clear existing registry entries and refresh all subscriptions
|
|
608
|
+
registry.clear();
|
|
609
|
+
|
|
610
|
+
// Broadcast workspace-changed event to all clients
|
|
611
|
+
broadcast('workspace-changed', CURRENT_WORKSPACE);
|
|
612
|
+
|
|
613
|
+
// Schedule refresh of all active list subscriptions
|
|
614
|
+
scheduleListRefresh();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
return { changed, workspace: CURRENT_WORKSPACE };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
wss,
|
|
622
|
+
broadcast,
|
|
623
|
+
scheduleListRefresh,
|
|
624
|
+
setWorkspace
|
|
625
|
+
// v2: list subscription refresh handles updates
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Handle an incoming message frame and respond to the same socket.
|
|
631
|
+
*
|
|
632
|
+
* @param {WebSocket} ws
|
|
633
|
+
* @param {RawData} data
|
|
634
|
+
*/
|
|
635
|
+
export async function handleMessage(ws, data) {
|
|
636
|
+
/** @type {unknown} */
|
|
637
|
+
let json;
|
|
638
|
+
try {
|
|
639
|
+
json = JSON.parse(data.toString());
|
|
640
|
+
} catch {
|
|
641
|
+
const reply = {
|
|
642
|
+
id: 'unknown',
|
|
643
|
+
ok: false,
|
|
644
|
+
type: 'bad-json',
|
|
645
|
+
error: { code: 'bad_json', message: 'Invalid JSON' }
|
|
646
|
+
};
|
|
647
|
+
ws.send(JSON.stringify(reply));
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (!isRequest(json)) {
|
|
652
|
+
log('invalid request');
|
|
653
|
+
const reply = {
|
|
654
|
+
id: 'unknown',
|
|
655
|
+
ok: false,
|
|
656
|
+
type: 'bad-request',
|
|
657
|
+
error: { code: 'bad_request', message: 'Invalid request envelope' }
|
|
658
|
+
};
|
|
659
|
+
ws.send(JSON.stringify(reply));
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const req = json;
|
|
664
|
+
|
|
665
|
+
// Dispatch known types here as we implement them. For now, only a ping utility.
|
|
666
|
+
if (req.type === /** @type {MessageType} */ ('ping')) {
|
|
667
|
+
ws.send(JSON.stringify(makeOk(req, { ts: Date.now() })));
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// subscribe-list: payload { id: string, type: string, params?: object }
|
|
672
|
+
if (req.type === 'subscribe-list') {
|
|
673
|
+
const payload_id = /** @type {any} */ (req.payload)?.id || '';
|
|
674
|
+
log('subscribe-list %s', payload_id);
|
|
675
|
+
const validation = validateSubscribeListPayload(
|
|
676
|
+
/** @type {any} */ (req.payload || {})
|
|
677
|
+
);
|
|
678
|
+
if (!validation.ok) {
|
|
679
|
+
ws.send(
|
|
680
|
+
JSON.stringify(makeError(req, validation.code, validation.message))
|
|
681
|
+
);
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const client_id = validation.id;
|
|
685
|
+
const spec = validation.spec;
|
|
686
|
+
const key = keyOf(spec);
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Reply with an error and avoid attaching the subscription when
|
|
690
|
+
* initialization fails.
|
|
691
|
+
*
|
|
692
|
+
* @param {string} code
|
|
693
|
+
* @param {string} message
|
|
694
|
+
* @param {Record<string, unknown>|undefined} details
|
|
695
|
+
*/
|
|
696
|
+
const replyWithError = (code, message, details = undefined) => {
|
|
697
|
+
ws.send(JSON.stringify(makeError(req, code, message, details)));
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
/** @type {Awaited<ReturnType<typeof fetchListForSubscription>> | null} */
|
|
701
|
+
let initial = null;
|
|
702
|
+
try {
|
|
703
|
+
initial = await fetchListForSubscription(spec, {
|
|
704
|
+
cwd: CURRENT_WORKSPACE?.root_dir
|
|
705
|
+
});
|
|
706
|
+
} catch (err) {
|
|
707
|
+
log('subscribe-list snapshot error for %s: %o', key, err);
|
|
708
|
+
const message =
|
|
709
|
+
(err && /** @type {any} */ (err).message) || 'Failed to load list';
|
|
710
|
+
replyWithError('bd_error', String(message), { key });
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (!initial.ok) {
|
|
715
|
+
log(
|
|
716
|
+
'initial snapshot failed for %s: %s %o',
|
|
717
|
+
key,
|
|
718
|
+
initial.error.message,
|
|
719
|
+
initial.error
|
|
720
|
+
);
|
|
721
|
+
const details = { ...(initial.error.details || {}), key };
|
|
722
|
+
replyWithError(initial.error.code, initial.error.message, details);
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const s = ensureSubs(ws);
|
|
727
|
+
const { key: attached_key } = registry.attach(spec, ws);
|
|
728
|
+
s.list_subs?.set(client_id, { key: attached_key, spec });
|
|
729
|
+
|
|
730
|
+
try {
|
|
731
|
+
await registry.withKeyLock(attached_key, async () => {
|
|
732
|
+
const items = applyClosedIssuesFilter(
|
|
733
|
+
spec,
|
|
734
|
+
initial ? initial.items : []
|
|
735
|
+
);
|
|
736
|
+
void registry.applyItems(attached_key, items);
|
|
737
|
+
emitSubscriptionSnapshot(ws, client_id, attached_key, items, initial?.total);
|
|
738
|
+
});
|
|
739
|
+
} catch (err) {
|
|
740
|
+
log('subscribe-list snapshot error for %s: %o', attached_key, err);
|
|
741
|
+
s.list_subs?.delete(client_id);
|
|
742
|
+
try {
|
|
743
|
+
registry.detach(spec, ws);
|
|
744
|
+
} catch {
|
|
745
|
+
// ignore detach errors
|
|
746
|
+
}
|
|
747
|
+
replyWithError('bd_error', 'Failed to publish snapshot', { key });
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
ws.send(JSON.stringify(makeOk(req, { id: client_id, key: attached_key })));
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// unsubscribe-list: payload { id: string }
|
|
756
|
+
if (req.type === 'unsubscribe-list') {
|
|
757
|
+
log('unsubscribe-list %s', /** @type {any} */ (req.payload)?.id || '');
|
|
758
|
+
const { id: client_id } = /** @type {any} */ (req.payload || {});
|
|
759
|
+
if (typeof client_id !== 'string' || client_id.length === 0) {
|
|
760
|
+
ws.send(
|
|
761
|
+
JSON.stringify(
|
|
762
|
+
makeError(req, 'bad_request', 'payload.id must be a non-empty string')
|
|
763
|
+
)
|
|
764
|
+
);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const s = ensureSubs(ws);
|
|
768
|
+
const sub = s.list_subs?.get(client_id) || null;
|
|
769
|
+
let removed = false;
|
|
770
|
+
if (sub) {
|
|
771
|
+
try {
|
|
772
|
+
removed = registry.detach(sub.spec, ws);
|
|
773
|
+
} catch {
|
|
774
|
+
removed = false;
|
|
775
|
+
}
|
|
776
|
+
s.list_subs?.delete(client_id);
|
|
777
|
+
}
|
|
778
|
+
ws.send(
|
|
779
|
+
JSON.stringify(
|
|
780
|
+
makeOk(req, {
|
|
781
|
+
id: client_id,
|
|
782
|
+
unsubscribed: removed
|
|
783
|
+
})
|
|
784
|
+
)
|
|
785
|
+
);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Removed: subscribe-updates and subscribe-issues. No-ops in v2.
|
|
790
|
+
|
|
791
|
+
// list-issues and epic-status were removed in favor of push-only subscriptions
|
|
792
|
+
|
|
793
|
+
// Removed: show-issue. Details flow is push-only via `subscribe-list { type: 'issue-detail' }`.
|
|
794
|
+
|
|
795
|
+
// type updates are not exposed via UI; no handler
|
|
796
|
+
|
|
797
|
+
// update-assignee
|
|
798
|
+
if (req.type === 'update-assignee') {
|
|
799
|
+
const { id, assignee } = /** @type {any} */ (req.payload || {});
|
|
800
|
+
if (
|
|
801
|
+
typeof id !== 'string' ||
|
|
802
|
+
id.length === 0 ||
|
|
803
|
+
typeof assignee !== 'string'
|
|
804
|
+
) {
|
|
805
|
+
ws.send(
|
|
806
|
+
JSON.stringify(
|
|
807
|
+
makeError(
|
|
808
|
+
req,
|
|
809
|
+
'bad_request',
|
|
810
|
+
'payload requires { id: string, assignee: string }'
|
|
811
|
+
)
|
|
812
|
+
)
|
|
813
|
+
);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
await mutateAndRespond(
|
|
817
|
+
ws, req, id,
|
|
818
|
+
() => updateIssueField(id, 'assignee', assignee || null),
|
|
819
|
+
['update', id, '--assignee', assignee]
|
|
820
|
+
);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// update-status
|
|
825
|
+
if (req.type === 'update-status') {
|
|
826
|
+
log('update-status');
|
|
827
|
+
const { id, status } = /** @type {any} */ (req.payload);
|
|
828
|
+
const allowed = new Set(['open', 'in_progress', 'closed']);
|
|
829
|
+
if (
|
|
830
|
+
typeof id !== 'string' ||
|
|
831
|
+
id.length === 0 ||
|
|
832
|
+
typeof status !== 'string' ||
|
|
833
|
+
!allowed.has(status)
|
|
834
|
+
) {
|
|
835
|
+
ws.send(
|
|
836
|
+
JSON.stringify(
|
|
837
|
+
makeError(
|
|
838
|
+
req,
|
|
839
|
+
'bad_request',
|
|
840
|
+
"payload requires { id: string, status: 'open'|'in_progress'|'closed' }"
|
|
841
|
+
)
|
|
842
|
+
)
|
|
843
|
+
);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
await mutateAndRespond(
|
|
847
|
+
ws, req, id,
|
|
848
|
+
() => updateIssueField(id, 'status', status),
|
|
849
|
+
['update', id, '--status', status]
|
|
850
|
+
);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
// update-priority
|
|
855
|
+
if (req.type === 'update-priority') {
|
|
856
|
+
log('update-priority');
|
|
857
|
+
const { id, priority } = /** @type {any} */ (req.payload);
|
|
858
|
+
if (
|
|
859
|
+
typeof id !== 'string' ||
|
|
860
|
+
id.length === 0 ||
|
|
861
|
+
typeof priority !== 'number' ||
|
|
862
|
+
priority < 0 ||
|
|
863
|
+
priority > 4
|
|
864
|
+
) {
|
|
865
|
+
ws.send(
|
|
866
|
+
JSON.stringify(
|
|
867
|
+
makeError(
|
|
868
|
+
req,
|
|
869
|
+
'bad_request',
|
|
870
|
+
'payload requires { id: string, priority: 0..4 }'
|
|
871
|
+
)
|
|
872
|
+
)
|
|
873
|
+
);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
await mutateAndRespond(
|
|
877
|
+
ws, req, id,
|
|
878
|
+
() => updateIssueField(id, 'priority', priority),
|
|
879
|
+
['update', id, '--priority', String(priority)]
|
|
880
|
+
);
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// edit-text
|
|
885
|
+
if (req.type === 'edit-text') {
|
|
886
|
+
log('edit-text');
|
|
887
|
+
const { id, field, value } = /** @type {any} */ (req.payload);
|
|
888
|
+
if (
|
|
889
|
+
typeof id !== 'string' ||
|
|
890
|
+
id.length === 0 ||
|
|
891
|
+
(field !== 'title' &&
|
|
892
|
+
field !== 'description' &&
|
|
893
|
+
field !== 'acceptance' &&
|
|
894
|
+
field !== 'notes' &&
|
|
895
|
+
field !== 'design') ||
|
|
896
|
+
typeof value !== 'string'
|
|
897
|
+
) {
|
|
898
|
+
ws.send(
|
|
899
|
+
JSON.stringify(
|
|
900
|
+
makeError(
|
|
901
|
+
req,
|
|
902
|
+
'bad_request',
|
|
903
|
+
"payload requires { id: string, field: 'title'|'description'|'acceptance'|'notes'|'design', value: string }"
|
|
904
|
+
)
|
|
905
|
+
)
|
|
906
|
+
);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const SQL_FIELD_MAP = /** @type {Record<string, string>} */ ({
|
|
910
|
+
title: 'title',
|
|
911
|
+
description: 'description',
|
|
912
|
+
acceptance: 'acceptance_criteria',
|
|
913
|
+
notes: 'notes',
|
|
914
|
+
design: 'design'
|
|
915
|
+
});
|
|
916
|
+
const BD_FLAG_MAP = /** @type {Record<string, string>} */ ({
|
|
917
|
+
title: '--title',
|
|
918
|
+
description: '--description',
|
|
919
|
+
acceptance: '--acceptance-criteria',
|
|
920
|
+
notes: '--notes',
|
|
921
|
+
design: '--design'
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
await mutateAndRespond(
|
|
925
|
+
ws, req, id,
|
|
926
|
+
() => updateIssueField(id, SQL_FIELD_MAP[field], value),
|
|
927
|
+
['update', id, BD_FLAG_MAP[field], value]
|
|
928
|
+
);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
// create-issue
|
|
933
|
+
if (req.type === 'create-issue') {
|
|
934
|
+
log('create-issue');
|
|
935
|
+
const { title, type, priority, description } = /** @type {any} */ (
|
|
936
|
+
req.payload || {}
|
|
937
|
+
);
|
|
938
|
+
if (typeof title !== 'string' || title.length === 0) {
|
|
939
|
+
ws.send(
|
|
940
|
+
JSON.stringify(
|
|
941
|
+
makeError(
|
|
942
|
+
req,
|
|
943
|
+
'bad_request',
|
|
944
|
+
'payload requires { title: string, ... }'
|
|
945
|
+
)
|
|
946
|
+
)
|
|
947
|
+
);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const args = ['create', title];
|
|
951
|
+
if (
|
|
952
|
+
typeof type === 'string' &&
|
|
953
|
+
(type === 'bug' ||
|
|
954
|
+
type === 'feature' ||
|
|
955
|
+
type === 'task' ||
|
|
956
|
+
type === 'epic' ||
|
|
957
|
+
type === 'chore')
|
|
958
|
+
) {
|
|
959
|
+
args.push('-t', type);
|
|
960
|
+
}
|
|
961
|
+
if (typeof priority === 'number' && priority >= 0 && priority <= 4) {
|
|
962
|
+
args.push('-p', String(priority));
|
|
963
|
+
}
|
|
964
|
+
if (typeof description === 'string' && description.length > 0) {
|
|
965
|
+
args.push('-d', description);
|
|
966
|
+
}
|
|
967
|
+
const res = await runBd(args);
|
|
968
|
+
if (res.code !== 0) {
|
|
969
|
+
ws.send(
|
|
970
|
+
JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
|
|
971
|
+
);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
// Reply with a minimal ack
|
|
975
|
+
ws.send(JSON.stringify(makeOk(req, { created: true })));
|
|
976
|
+
// Refresh active subscriptions once (watcher or timeout)
|
|
977
|
+
try {
|
|
978
|
+
triggerMutationRefreshOnce();
|
|
979
|
+
} catch {
|
|
980
|
+
// ignore
|
|
981
|
+
}
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// dep-add: payload { a: string, b: string, view_id?: string }
|
|
986
|
+
if (req.type === 'dep-add') {
|
|
987
|
+
const { a, b, view_id } = /** @type {any} */ (req.payload || {});
|
|
988
|
+
if (
|
|
989
|
+
typeof a !== 'string' ||
|
|
990
|
+
a.length === 0 ||
|
|
991
|
+
typeof b !== 'string' ||
|
|
992
|
+
b.length === 0
|
|
993
|
+
) {
|
|
994
|
+
ws.send(
|
|
995
|
+
JSON.stringify(
|
|
996
|
+
makeError(
|
|
997
|
+
req,
|
|
998
|
+
'bad_request',
|
|
999
|
+
'payload requires { a: string, b: string }'
|
|
1000
|
+
)
|
|
1001
|
+
)
|
|
1002
|
+
);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
const id = typeof view_id === 'string' && view_id.length > 0 ? view_id : a;
|
|
1006
|
+
await mutateAndRespond(
|
|
1007
|
+
ws, req, id,
|
|
1008
|
+
() => addDependency(a, b),
|
|
1009
|
+
['dep', 'add', a, b]
|
|
1010
|
+
);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// dep-remove: payload { a: string, b: string, view_id?: string }
|
|
1015
|
+
if (req.type === 'dep-remove') {
|
|
1016
|
+
const { a, b, view_id } = /** @type {any} */ (req.payload || {});
|
|
1017
|
+
if (
|
|
1018
|
+
typeof a !== 'string' ||
|
|
1019
|
+
a.length === 0 ||
|
|
1020
|
+
typeof b !== 'string' ||
|
|
1021
|
+
b.length === 0
|
|
1022
|
+
) {
|
|
1023
|
+
ws.send(
|
|
1024
|
+
JSON.stringify(
|
|
1025
|
+
makeError(
|
|
1026
|
+
req,
|
|
1027
|
+
'bad_request',
|
|
1028
|
+
'payload requires { a: string, b: string }'
|
|
1029
|
+
)
|
|
1030
|
+
)
|
|
1031
|
+
);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const id = typeof view_id === 'string' && view_id.length > 0 ? view_id : a;
|
|
1035
|
+
await mutateAndRespond(
|
|
1036
|
+
ws, req, id,
|
|
1037
|
+
() => removeDependency(a, b),
|
|
1038
|
+
['dep', 'remove', a, b]
|
|
1039
|
+
);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
// label-add: payload { id: string, label: string }
|
|
1044
|
+
if (req.type === 'label-add') {
|
|
1045
|
+
const { id, label } = /** @type {any} */ (req.payload || {});
|
|
1046
|
+
if (
|
|
1047
|
+
typeof id !== 'string' ||
|
|
1048
|
+
id.length === 0 ||
|
|
1049
|
+
typeof label !== 'string' ||
|
|
1050
|
+
label.trim().length === 0
|
|
1051
|
+
) {
|
|
1052
|
+
ws.send(
|
|
1053
|
+
JSON.stringify(
|
|
1054
|
+
makeError(
|
|
1055
|
+
req,
|
|
1056
|
+
'bad_request',
|
|
1057
|
+
'payload requires { id: string, label: non-empty string }'
|
|
1058
|
+
)
|
|
1059
|
+
)
|
|
1060
|
+
);
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
await mutateAndRespond(
|
|
1064
|
+
ws, req, id,
|
|
1065
|
+
() => addLabel(id, label.trim()),
|
|
1066
|
+
['label', 'add', id, label.trim()]
|
|
1067
|
+
);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// label-remove: payload { id: string, label: string }
|
|
1072
|
+
if (req.type === 'label-remove') {
|
|
1073
|
+
const { id, label } = /** @type {any} */ (req.payload || {});
|
|
1074
|
+
if (
|
|
1075
|
+
typeof id !== 'string' ||
|
|
1076
|
+
id.length === 0 ||
|
|
1077
|
+
typeof label !== 'string' ||
|
|
1078
|
+
label.trim().length === 0
|
|
1079
|
+
) {
|
|
1080
|
+
ws.send(
|
|
1081
|
+
JSON.stringify(
|
|
1082
|
+
makeError(
|
|
1083
|
+
req,
|
|
1084
|
+
'bad_request',
|
|
1085
|
+
'payload requires { id: string, label: non-empty string }'
|
|
1086
|
+
)
|
|
1087
|
+
)
|
|
1088
|
+
);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
await mutateAndRespond(
|
|
1092
|
+
ws, req, id,
|
|
1093
|
+
() => removeLabel(id, label.trim()),
|
|
1094
|
+
['label', 'remove', id, label.trim()]
|
|
1095
|
+
);
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// get-comments: payload { id: string }
|
|
1100
|
+
if (req.type === 'get-comments') {
|
|
1101
|
+
const { id } = /** @type {any} */ (req.payload || {});
|
|
1102
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1103
|
+
ws.send(
|
|
1104
|
+
JSON.stringify(
|
|
1105
|
+
makeError(req, 'bad_request', 'payload requires { id: string }')
|
|
1106
|
+
)
|
|
1107
|
+
);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (isDoltPoolReady()) {
|
|
1111
|
+
const res = await queryComments(id);
|
|
1112
|
+
if (!res.ok) {
|
|
1113
|
+
ws.send(JSON.stringify(makeError(req, res.error.code, res.error.message)));
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
ws.send(JSON.stringify(makeOk(req, res.items)));
|
|
1117
|
+
} else {
|
|
1118
|
+
const res = await runBdJson(['comments', id, '--json']);
|
|
1119
|
+
if (res.code !== 0) {
|
|
1120
|
+
ws.send(JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed')));
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
ws.send(JSON.stringify(makeOk(req, res.stdoutJson || [])));
|
|
1124
|
+
}
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// add-comment: payload { id: string, text: string }
|
|
1129
|
+
if (req.type === 'add-comment') {
|
|
1130
|
+
const { id, text } = /** @type {any} */ (req.payload || {});
|
|
1131
|
+
if (
|
|
1132
|
+
typeof id !== 'string' ||
|
|
1133
|
+
id.length === 0 ||
|
|
1134
|
+
typeof text !== 'string' ||
|
|
1135
|
+
text.trim().length === 0
|
|
1136
|
+
) {
|
|
1137
|
+
ws.send(
|
|
1138
|
+
JSON.stringify(
|
|
1139
|
+
makeError(
|
|
1140
|
+
req,
|
|
1141
|
+
'bad_request',
|
|
1142
|
+
'payload requires { id: string, text: non-empty string }'
|
|
1143
|
+
)
|
|
1144
|
+
)
|
|
1145
|
+
);
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const author = await getGitUserName();
|
|
1150
|
+
|
|
1151
|
+
if (isDoltPoolReady()) {
|
|
1152
|
+
const upd = await addComment(id, text.trim(), author || 'anonymous');
|
|
1153
|
+
if (!upd.ok) {
|
|
1154
|
+
ws.send(JSON.stringify(makeError(req, upd.error.code, upd.error.message)));
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
const res = await queryComments(id);
|
|
1158
|
+
if (!res.ok) {
|
|
1159
|
+
ws.send(JSON.stringify(makeError(req, res.error.code, res.error.message)));
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
ws.send(JSON.stringify(makeOk(req, res.items)));
|
|
1163
|
+
} else {
|
|
1164
|
+
const args = ['comment', id, text.trim()];
|
|
1165
|
+
if (author) {
|
|
1166
|
+
args.push('--author', author);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
const res = await runBd(args);
|
|
1170
|
+
if (res.code !== 0) {
|
|
1171
|
+
ws.send(JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed')));
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
const comments = await runBdJson(['comments', id, '--json']);
|
|
1176
|
+
if (comments.code !== 0) {
|
|
1177
|
+
ws.send(JSON.stringify(makeError(req, 'bd_error', comments.stderr || 'bd failed')));
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
ws.send(JSON.stringify(makeOk(req, comments.stdoutJson || [])));
|
|
1181
|
+
}
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// delete-issue: payload { id: string }
|
|
1186
|
+
if (req.type === 'delete-issue') {
|
|
1187
|
+
const { id } = /** @type {any} */ (req.payload || {});
|
|
1188
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
1189
|
+
ws.send(
|
|
1190
|
+
JSON.stringify(
|
|
1191
|
+
makeError(req, 'bad_request', 'payload requires { id: string }')
|
|
1192
|
+
)
|
|
1193
|
+
);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
if (isDoltPoolReady()) {
|
|
1197
|
+
const upd = await deleteIssue(id);
|
|
1198
|
+
if (!upd.ok) {
|
|
1199
|
+
ws.send(JSON.stringify(makeError(req, upd.error.code, upd.error.message)));
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
ws.send(JSON.stringify(makeOk(req, { deleted: true, id })));
|
|
1203
|
+
} else {
|
|
1204
|
+
const res = await runBd(['delete', id, '--force']);
|
|
1205
|
+
if (res.code !== 0) {
|
|
1206
|
+
ws.send(JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd delete failed')));
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
ws.send(JSON.stringify(makeOk(req, { deleted: true, id })));
|
|
1210
|
+
}
|
|
1211
|
+
try {
|
|
1212
|
+
triggerMutationRefreshOnce();
|
|
1213
|
+
} catch {
|
|
1214
|
+
// ignore
|
|
1215
|
+
}
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
// list-workspaces: returns all available workspaces from the registry
|
|
1220
|
+
if (req.type === 'list-workspaces') {
|
|
1221
|
+
log('list-workspaces');
|
|
1222
|
+
const workspaces = getAvailableWorkspaces();
|
|
1223
|
+
ws.send(
|
|
1224
|
+
JSON.stringify(
|
|
1225
|
+
makeOk(req, {
|
|
1226
|
+
workspaces,
|
|
1227
|
+
current: CURRENT_WORKSPACE
|
|
1228
|
+
})
|
|
1229
|
+
)
|
|
1230
|
+
);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// get-workspace: returns the current workspace
|
|
1235
|
+
if (req.type === 'get-workspace') {
|
|
1236
|
+
log('get-workspace');
|
|
1237
|
+
ws.send(JSON.stringify(makeOk(req, CURRENT_WORKSPACE)));
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
// set-workspace: payload { path: string }
|
|
1242
|
+
if (req.type === 'set-workspace') {
|
|
1243
|
+
log('set-workspace');
|
|
1244
|
+
const { path: workspace_path } = /** @type {any} */ (req.payload || {});
|
|
1245
|
+
if (typeof workspace_path !== 'string' || workspace_path.length === 0) {
|
|
1246
|
+
ws.send(
|
|
1247
|
+
JSON.stringify(
|
|
1248
|
+
makeError(
|
|
1249
|
+
req,
|
|
1250
|
+
'bad_request',
|
|
1251
|
+
'payload requires { path: string } (absolute workspace path)'
|
|
1252
|
+
)
|
|
1253
|
+
)
|
|
1254
|
+
);
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// Resolve and validate the path
|
|
1259
|
+
const resolved = path.resolve(workspace_path);
|
|
1260
|
+
|
|
1261
|
+
// Update workspace (this will rebind watcher, clear registry, broadcast change)
|
|
1262
|
+
const new_db = resolveWorkspaceDatabase({ cwd: resolved });
|
|
1263
|
+
const old_path = CURRENT_WORKSPACE?.db_path || '';
|
|
1264
|
+
|
|
1265
|
+
CURRENT_WORKSPACE = {
|
|
1266
|
+
root_dir: resolved,
|
|
1267
|
+
db_path: new_db.path
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
const changed = new_db.path !== old_path;
|
|
1271
|
+
|
|
1272
|
+
if (changed) {
|
|
1273
|
+
log(
|
|
1274
|
+
'workspace changed via set-workspace: %s → %s',
|
|
1275
|
+
old_path,
|
|
1276
|
+
new_db.path
|
|
1277
|
+
);
|
|
1278
|
+
|
|
1279
|
+
// Rebind the database watcher
|
|
1280
|
+
if (DB_WATCHER) {
|
|
1281
|
+
DB_WATCHER.rebind({ root_dir: resolved });
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Clear existing registry entries
|
|
1285
|
+
registry.clear();
|
|
1286
|
+
|
|
1287
|
+
// Schedule refresh of all active list subscriptions
|
|
1288
|
+
scheduleListRefresh();
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
ws.send(
|
|
1292
|
+
JSON.stringify(
|
|
1293
|
+
makeOk(req, {
|
|
1294
|
+
changed,
|
|
1295
|
+
workspace: CURRENT_WORKSPACE
|
|
1296
|
+
})
|
|
1297
|
+
)
|
|
1298
|
+
);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Unknown type
|
|
1303
|
+
const err = makeError(
|
|
1304
|
+
req,
|
|
1305
|
+
'unknown_type',
|
|
1306
|
+
`Unknown message type: ${req.type}`
|
|
1307
|
+
);
|
|
1308
|
+
ws.send(JSON.stringify(err));
|
|
1309
|
+
}
|