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