@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.
Files changed (68) hide show
  1. package/.github/workflows/publish.yml +28 -0
  2. package/app/protocol.js +216 -0
  3. package/bin/bdui +19 -0
  4. package/client/index.html +12 -0
  5. package/client/postcss.config.js +11 -0
  6. package/client/src/App.tsx +35 -0
  7. package/client/src/components/IssueCard.tsx +73 -0
  8. package/client/src/components/Layout.tsx +175 -0
  9. package/client/src/components/Markdown.tsx +77 -0
  10. package/client/src/components/PriorityBadge.tsx +26 -0
  11. package/client/src/components/SearchDialog.tsx +137 -0
  12. package/client/src/components/SectionEditor.tsx +212 -0
  13. package/client/src/components/StatusBadge.tsx +64 -0
  14. package/client/src/components/TypeBadge.tsx +26 -0
  15. package/client/src/hooks/use-mutation.ts +55 -0
  16. package/client/src/hooks/use-search.ts +19 -0
  17. package/client/src/hooks/use-subscription.ts +187 -0
  18. package/client/src/index.css +133 -0
  19. package/client/src/lib/avatar.ts +17 -0
  20. package/client/src/lib/types.ts +115 -0
  21. package/client/src/lib/ws-client.ts +214 -0
  22. package/client/src/lib/ws-context.tsx +28 -0
  23. package/client/src/main.tsx +10 -0
  24. package/client/src/views/Board.tsx +200 -0
  25. package/client/src/views/Detail.tsx +398 -0
  26. package/client/src/views/List.tsx +461 -0
  27. package/client/tailwind.config.ts +68 -0
  28. package/client/tsconfig.json +16 -0
  29. package/client/vite.config.ts +20 -0
  30. package/package.json +43 -0
  31. package/server/app.js +120 -0
  32. package/server/app.test.js +30 -0
  33. package/server/bd.js +227 -0
  34. package/server/bd.test.js +194 -0
  35. package/server/cli/cli.test.js +207 -0
  36. package/server/cli/commands.integration.test.js +148 -0
  37. package/server/cli/commands.js +285 -0
  38. package/server/cli/commands.unit.test.js +408 -0
  39. package/server/cli/daemon.js +340 -0
  40. package/server/cli/daemon.test.js +31 -0
  41. package/server/cli/index.js +135 -0
  42. package/server/cli/open.js +178 -0
  43. package/server/cli/open.test.js +26 -0
  44. package/server/cli/usage.js +27 -0
  45. package/server/config.js +36 -0
  46. package/server/db.js +154 -0
  47. package/server/db.test.js +169 -0
  48. package/server/dolt-pool.js +257 -0
  49. package/server/dolt-queries.js +646 -0
  50. package/server/index.js +97 -0
  51. package/server/list-adapters.js +395 -0
  52. package/server/list-adapters.test.js +208 -0
  53. package/server/logging.js +23 -0
  54. package/server/registry-watcher.js +200 -0
  55. package/server/subscriptions.js +299 -0
  56. package/server/subscriptions.test.js +128 -0
  57. package/server/validators.js +124 -0
  58. package/server/watcher.js +139 -0
  59. package/server/watcher.test.js +120 -0
  60. package/server/ws.comments.test.js +262 -0
  61. package/server/ws.delete.test.js +119 -0
  62. package/server/ws.js +1309 -0
  63. package/server/ws.labels.test.js +95 -0
  64. package/server/ws.list-refresh.coalesce.test.js +95 -0
  65. package/server/ws.list-subscriptions.test.js +403 -0
  66. package/server/ws.mutation-window.test.js +147 -0
  67. package/server/ws.mutations.test.js +389 -0
  68. 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
+ }