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 (98) hide show
  1. package/.beads/issues.jsonl +107 -0
  2. package/.editorconfig +10 -0
  3. package/.eslintrc.json +36 -0
  4. package/.github/workflows/ci.yml +38 -0
  5. package/.prettierignore +5 -0
  6. package/AGENTS.md +85 -0
  7. package/CHANGES.md +5 -0
  8. package/LICENSE +22 -0
  9. package/README.md +75 -0
  10. package/app/data/providers.js +178 -0
  11. package/app/data/providers.test.js +126 -0
  12. package/app/index.html +29 -0
  13. package/app/main.board-switch.test.js +94 -0
  14. package/app/main.deep-link.test.js +64 -0
  15. package/app/main.js +280 -0
  16. package/app/main.live-updates.test.js +229 -0
  17. package/app/main.test.js +17 -0
  18. package/app/main.theme.test.js +41 -0
  19. package/app/main.view-sync.test.js +54 -0
  20. package/app/protocol.js +200 -0
  21. package/app/protocol.md +64 -0
  22. package/app/protocol.test.js +57 -0
  23. package/app/router.js +78 -0
  24. package/app/router.test.js +34 -0
  25. package/app/state.js +87 -0
  26. package/app/state.test.js +21 -0
  27. package/app/styles.css +1343 -0
  28. package/app/utils/issue-id.js +10 -0
  29. package/app/utils/issue-type.js +27 -0
  30. package/app/utils/markdown.js +201 -0
  31. package/app/utils/markdown.test.js +103 -0
  32. package/app/utils/priority-badge.js +49 -0
  33. package/app/utils/priority.js +1 -0
  34. package/app/utils/status-badge.js +33 -0
  35. package/app/utils/status.js +23 -0
  36. package/app/utils/type-badge.js +36 -0
  37. package/app/utils/type-badge.test.js +30 -0
  38. package/app/views/board.js +183 -0
  39. package/app/views/board.test.js +184 -0
  40. package/app/views/detail.acceptance-notes.test.js +67 -0
  41. package/app/views/detail.assignee.test.js +161 -0
  42. package/app/views/detail.deps.test.js +97 -0
  43. package/app/views/detail.edits.test.js +146 -0
  44. package/app/views/detail.js +1039 -0
  45. package/app/views/detail.labels.test.js +73 -0
  46. package/app/views/detail.priority.test.js +86 -0
  47. package/app/views/detail.test.js +188 -0
  48. package/app/views/detail.ui47.test.js +78 -0
  49. package/app/views/epics.js +228 -0
  50. package/app/views/epics.test.js +283 -0
  51. package/app/views/issue-row.js +191 -0
  52. package/app/views/list.inline-edits.test.js +84 -0
  53. package/app/views/list.js +393 -0
  54. package/app/views/list.test.js +479 -0
  55. package/app/views/nav.js +67 -0
  56. package/app/views/nav.test.js +43 -0
  57. package/app/ws.js +252 -0
  58. package/app/ws.test.js +168 -0
  59. package/bin/bdui.js +18 -0
  60. package/docs/architecture.md +244 -0
  61. package/docs/db-watching.md +29 -0
  62. package/docs/quickstart.md +142 -0
  63. package/eslint.config.js +59 -0
  64. package/media/bdui-board.png +0 -0
  65. package/media/bdui-epics.png +0 -0
  66. package/media/bdui-issues.png +0 -0
  67. package/package.json +48 -0
  68. package/prettier.config.js +13 -0
  69. package/server/app.js +80 -0
  70. package/server/app.test.js +29 -0
  71. package/server/bd.js +125 -0
  72. package/server/bd.test.js +93 -0
  73. package/server/cli/cli.test.js +109 -0
  74. package/server/cli/commands.integration.test.js +155 -0
  75. package/server/cli/commands.js +91 -0
  76. package/server/cli/commands.unit.test.js +94 -0
  77. package/server/cli/daemon.js +239 -0
  78. package/server/cli/index.js +74 -0
  79. package/server/cli/open.js +96 -0
  80. package/server/cli/open.test.js +26 -0
  81. package/server/cli/usage.js +22 -0
  82. package/server/config.js +29 -0
  83. package/server/db.js +100 -0
  84. package/server/db.test.js +70 -0
  85. package/server/index.js +29 -0
  86. package/server/protocol.js +3 -0
  87. package/server/protocol.test.js +87 -0
  88. package/server/watcher.js +107 -0
  89. package/server/watcher.test.js +100 -0
  90. package/server/ws.handlers.test.js +174 -0
  91. package/server/ws.js +784 -0
  92. package/server/ws.labels.test.js +95 -0
  93. package/server/ws.mutations.test.js +261 -0
  94. package/server/ws.subscriptions.test.js +116 -0
  95. package/server/ws.test.js +52 -0
  96. package/test/setup-vitest.js +12 -0
  97. package/tsconfig.json +23 -0
  98. package/vitest.config.mjs +14 -0
package/server/ws.js ADDED
@@ -0,0 +1,784 @@
1
+ /**
2
+ * @import { Server } from 'node:http'
3
+ * @import { RawData, WebSocket } from 'ws'
4
+ * @import { MessageType } from '../app/protocol.js'
5
+ */
6
+ import { WebSocketServer } from 'ws';
7
+ import { runBd, runBdJson } from './bd.js';
8
+ import { isRequest, makeError, makeOk } from './protocol.js';
9
+
10
+ /**
11
+ * @typedef {{
12
+ * subscribed: boolean,
13
+ * list_filters?: { status?: 'open'|'in_progress'|'closed', ready?: boolean, limit?: number },
14
+ * show_id?: string | null
15
+ * }} ConnectionSubs
16
+ */
17
+
18
+ /** @type {WeakMap<WebSocket, ConnectionSubs>} */
19
+ const SUBS = new WeakMap();
20
+
21
+ /** @type {WebSocketServer | null} */
22
+ let CURRENT_WSS = null;
23
+
24
+ /**
25
+ * Get or initialize the subscription state for a socket.
26
+ * @param {WebSocket} ws
27
+ * @returns {ConnectionSubs}
28
+ */
29
+ function getSubs(ws) {
30
+ let s = SUBS.get(ws);
31
+ if (!s) {
32
+ s = { subscribed: false, show_id: null };
33
+ SUBS.set(ws, s);
34
+ }
35
+ return s;
36
+ }
37
+
38
+ /**
39
+ * Emit an issues-changed event to relevant clients when possible, or broadcast to all.
40
+ * Targeting rules:
41
+ * - If `issue` is provided, send to clients that currently show the same id or whose
42
+ * last list filter likely includes the issue (status match or ready=true).
43
+ * - If only `hint` is provided, but contains ids, send to clients that show one of those ids.
44
+ * - Otherwise, send to all open clients.
45
+ * @param {{ ts?: number, hint?: { ids?: string[] } }} payload
46
+ * @param {{ issue?: any }} [options]
47
+ */
48
+ export function notifyIssuesChanged(payload, options = {}) {
49
+ const wss = CURRENT_WSS;
50
+ if (!wss) {
51
+ return;
52
+ }
53
+ /** @type {Set<WebSocket>} */
54
+ const recipients = new Set();
55
+
56
+ /** @type {any} */
57
+ const issue = options.issue;
58
+ /** @type {string[]} */
59
+ const hint_ids = Array.isArray(payload?.hint?.ids)
60
+ ? /** @type {string[]} */ (payload.hint.ids)
61
+ : [];
62
+
63
+ if (issue && typeof issue === 'object' && issue.id) {
64
+ for (const ws of wss.clients) {
65
+ if (ws.readyState !== ws.OPEN) {
66
+ continue;
67
+ }
68
+ const s = getSubs(/** @type {any} */ (ws));
69
+ if (!s.subscribed) {
70
+ continue;
71
+ }
72
+ if (s.show_id && s.show_id === issue.id) {
73
+ recipients.add(ws);
74
+ continue;
75
+ }
76
+ if (s.list_filters) {
77
+ // Ready lists are conservatively invalidated on any change
78
+ if (s.list_filters.ready === true) {
79
+ recipients.add(ws);
80
+ continue;
81
+ }
82
+ // Status lists: invalidate when status matches updated issue
83
+ if (
84
+ s.list_filters.status &&
85
+ String(s.list_filters.status) === String(issue.status || '')
86
+ ) {
87
+ recipients.add(ws);
88
+ continue;
89
+ }
90
+ }
91
+ }
92
+ } else if (hint_ids.length > 0) {
93
+ for (const ws of wss.clients) {
94
+ if (ws.readyState !== ws.OPEN) {
95
+ continue;
96
+ }
97
+ const s = getSubs(/** @type {any} */ (ws));
98
+ if (!s.subscribed) {
99
+ continue;
100
+ }
101
+ if (s.show_id && hint_ids.includes(s.show_id)) {
102
+ recipients.add(ws);
103
+ }
104
+ }
105
+ }
106
+
107
+ /** @type {string} */
108
+ const msg = JSON.stringify({
109
+ id: `evt-${Date.now()}`,
110
+ ok: true,
111
+ type: /** @type {MessageType} */ ('issues-changed'),
112
+ payload: { ts: Date.now(), ...(payload || {}) }
113
+ });
114
+
115
+ if (recipients.size > 0) {
116
+ for (const ws of recipients) {
117
+ ws.send(msg);
118
+ }
119
+ } else {
120
+ // Fallback: full broadcast to keep clients consistent
121
+ for (const ws of wss.clients) {
122
+ if (ws.readyState === ws.OPEN) {
123
+ ws.send(msg);
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Attach a WebSocket server to an existing HTTP server.
131
+ * @param {Server} http_server
132
+ * @param {{ path?: string, heartbeat_ms?: number }} [options]
133
+ * @returns {{ wss: WebSocketServer, broadcast: (type: MessageType, payload?: unknown) => void, notifyIssuesChanged: (payload: { ts?: number, hint?: { ids?: string[] } }) => void }}
134
+ */
135
+ export function attachWsServer(http_server, options = {}) {
136
+ const path = options.path || '/ws';
137
+ const heartbeat_ms = options.heartbeat_ms ?? 30000;
138
+
139
+ const wss = new WebSocketServer({ server: http_server, path });
140
+ CURRENT_WSS = wss;
141
+
142
+ // Heartbeat: track if client answered the last ping
143
+ wss.on('connection', (ws) => {
144
+ // @ts-expect-error add marker property
145
+ ws.isAlive = true;
146
+
147
+ // Initialize subscription state for this connection
148
+ getSubs(/** @type {any} */ (ws));
149
+
150
+ ws.on('pong', () => {
151
+ // @ts-expect-error marker
152
+ ws.isAlive = true;
153
+ });
154
+
155
+ ws.on('message', (data) => {
156
+ handleMessage(ws, data);
157
+ });
158
+ });
159
+
160
+ const interval = setInterval(() => {
161
+ for (const ws of wss.clients) {
162
+ // @ts-expect-error marker
163
+ if (ws.isAlive === false) {
164
+ ws.terminate();
165
+ continue;
166
+ }
167
+ // @ts-expect-error marker
168
+ ws.isAlive = false;
169
+ ws.ping();
170
+ }
171
+ }, heartbeat_ms);
172
+
173
+ interval.unref?.();
174
+
175
+ wss.on('close', () => {
176
+ clearInterval(interval);
177
+ });
178
+
179
+ /**
180
+ * Broadcast a server-initiated event to all open clients.
181
+ * @param {MessageType} type
182
+ * @param {unknown} [payload]
183
+ */
184
+ function broadcast(type, payload) {
185
+ const msg = JSON.stringify({
186
+ id: `evt-${Date.now()}`,
187
+ ok: true,
188
+ type,
189
+ payload
190
+ });
191
+ for (const ws of wss.clients) {
192
+ if (ws.readyState === ws.OPEN) {
193
+ ws.send(msg);
194
+ }
195
+ }
196
+ }
197
+
198
+ return { wss, broadcast, notifyIssuesChanged: (p) => notifyIssuesChanged(p) };
199
+ }
200
+
201
+ /**
202
+ * Handle an incoming message frame and respond to the same socket.
203
+ * @param {WebSocket} ws
204
+ * @param {RawData} data
205
+ */
206
+ export async function handleMessage(ws, data) {
207
+ /** @type {unknown} */
208
+ let json;
209
+ try {
210
+ json = JSON.parse(data.toString());
211
+ } catch {
212
+ const reply = {
213
+ id: 'unknown',
214
+ ok: false,
215
+ type: 'bad-json',
216
+ error: { code: 'bad_json', message: 'Invalid JSON' }
217
+ };
218
+ ws.send(JSON.stringify(reply));
219
+ return;
220
+ }
221
+
222
+ if (!isRequest(json)) {
223
+ const reply = {
224
+ id: 'unknown',
225
+ ok: false,
226
+ type: 'bad-request',
227
+ error: { code: 'bad_request', message: 'Invalid request envelope' }
228
+ };
229
+ ws.send(JSON.stringify(reply));
230
+ return;
231
+ }
232
+
233
+ const req = json;
234
+
235
+ // Dispatch known types here as we implement them. For now, only a ping utility.
236
+ if (req.type === /** @type {any} */ ('ping')) {
237
+ ws.send(JSON.stringify(makeOk(req, { ts: Date.now() })));
238
+ return;
239
+ }
240
+
241
+ // subscribe-updates: mark this connection as event subscriber
242
+ if (req.type === 'subscribe-updates') {
243
+ const s = getSubs(ws);
244
+ s.subscribed = true;
245
+ ws.send(JSON.stringify(makeOk(req, { subscribed: true })));
246
+ return;
247
+ }
248
+
249
+ // list-issues
250
+ if (req.type === 'list-issues') {
251
+ const { filters } = /** @type {any} */ (req.payload || {});
252
+ // When "ready" is requested, use the dedicated bd subcommand
253
+ if (filters && typeof filters === 'object' && filters.ready === true) {
254
+ const res = await runBdJson(['ready', '--json']);
255
+ if (res.code !== 0) {
256
+ const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
257
+ ws.send(JSON.stringify(err));
258
+ return;
259
+ }
260
+ // Remember subscription scope for this connection
261
+ try {
262
+ const s = getSubs(ws);
263
+ s.list_filters = { ready: true };
264
+ } catch {
265
+ // ignore tracking errors
266
+ }
267
+ ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
268
+ return;
269
+ }
270
+
271
+ /** @type {string[]} */
272
+ const args = ['list', '--json'];
273
+ if (filters && typeof filters === 'object') {
274
+ if (typeof filters.status === 'string') {
275
+ // Use long flag for clarity and compatibility
276
+ args.push('--status', filters.status);
277
+ }
278
+ if (typeof filters.priority === 'number') {
279
+ args.push('--priority', String(filters.priority));
280
+ }
281
+ if (typeof filters.limit === 'number' && filters.limit > 0) {
282
+ args.push('--limit', String(filters.limit));
283
+ }
284
+ }
285
+ const res = await runBdJson(args);
286
+ if (res.code !== 0) {
287
+ const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
288
+ ws.send(JSON.stringify(err));
289
+ return;
290
+ }
291
+ // Remember last non-ready list filter
292
+ try {
293
+ const s = getSubs(ws);
294
+ /** @type {{ status?: any, limit?: any }} */
295
+ const f = filters && typeof filters === 'object' ? filters : {};
296
+ /** @type {any} */
297
+ const st = f.status;
298
+ /** @type {any} */
299
+ const lim = f.limit;
300
+ s.list_filters = {};
301
+ if (st === 'open' || st === 'in_progress' || st === 'closed') {
302
+ s.list_filters.status = st;
303
+ }
304
+ if (typeof lim === 'number') {
305
+ s.list_filters.limit = lim;
306
+ }
307
+ } catch {
308
+ // ignore tracking errors
309
+ }
310
+ ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
311
+ return;
312
+ }
313
+
314
+ // epic-status
315
+ if (req.type === /** @type {any} */ ('epic-status')) {
316
+ const res = await runBdJson(['epic', 'status', '--json']);
317
+ if (res.code !== 0) {
318
+ const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
319
+ ws.send(JSON.stringify(err));
320
+ return;
321
+ }
322
+ ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
323
+ return;
324
+ }
325
+
326
+ // show-issue
327
+ if (req.type === 'show-issue') {
328
+ const { id } = /** @type {any} */ (req.payload);
329
+ if (typeof id !== 'string' || id.length === 0) {
330
+ ws.send(
331
+ JSON.stringify(
332
+ makeError(req, 'bad_request', 'payload.id must be a non-empty string')
333
+ )
334
+ );
335
+ return;
336
+ }
337
+ const res = await runBdJson(['show', id, '--json']);
338
+ if (res.code !== 0) {
339
+ const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
340
+ ws.send(JSON.stringify(err));
341
+ return;
342
+ }
343
+ // bd show can return an array when it supports multiple ids;
344
+ // normalize to a single object for the single-id API.
345
+ /** @type {any} */
346
+ const out = Array.isArray(res.stdoutJson)
347
+ ? res.stdoutJson[0]
348
+ : res.stdoutJson;
349
+ if (!out) {
350
+ ws.send(JSON.stringify(makeError(req, 'not_found', 'issue not found')));
351
+ return;
352
+ }
353
+ // Track current detail subscription for this connection
354
+ try {
355
+ const s = getSubs(ws);
356
+ s.show_id = String(id);
357
+ } catch {
358
+ // ignore
359
+ }
360
+ ws.send(JSON.stringify(makeOk(req, out)));
361
+ return;
362
+ }
363
+
364
+ // type updates are not exposed via UI; no handler
365
+
366
+ // update-assignee
367
+ if (req.type === /** @type {any} */ ('update-assignee')) {
368
+ const { id, assignee } = /** @type {any} */ (req.payload || {});
369
+ if (
370
+ typeof id !== 'string' ||
371
+ id.length === 0 ||
372
+ typeof assignee !== 'string'
373
+ ) {
374
+ ws.send(
375
+ JSON.stringify(
376
+ makeError(
377
+ req,
378
+ 'bad_request',
379
+ 'payload requires { id: string, assignee: string }'
380
+ )
381
+ )
382
+ );
383
+ return;
384
+ }
385
+ // Pass empty string to clear assignee when requested
386
+ const res = await runBd(['update', id, '--assignee', assignee]);
387
+ if (res.code !== 0) {
388
+ ws.send(
389
+ JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
390
+ );
391
+ return;
392
+ }
393
+ const shown = await runBdJson(['show', id, '--json']);
394
+ if (shown.code !== 0) {
395
+ ws.send(
396
+ JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
397
+ );
398
+ return;
399
+ }
400
+ ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
401
+ return;
402
+ }
403
+
404
+ // update-status
405
+ if (req.type === 'update-status') {
406
+ const { id, status } = /** @type {any} */ (req.payload);
407
+ const allowed = new Set(['open', 'in_progress', 'closed']);
408
+ if (
409
+ typeof id !== 'string' ||
410
+ id.length === 0 ||
411
+ typeof status !== 'string' ||
412
+ !allowed.has(status)
413
+ ) {
414
+ ws.send(
415
+ JSON.stringify(
416
+ makeError(
417
+ req,
418
+ 'bad_request',
419
+ "payload requires { id: string, status: 'open'|'in_progress'|'closed' }"
420
+ )
421
+ )
422
+ );
423
+ return;
424
+ }
425
+ const res = await runBd(['update', id, '--status', status]);
426
+ if (res.code !== 0) {
427
+ ws.send(
428
+ JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
429
+ );
430
+ return;
431
+ }
432
+ const shown = await runBdJson(['show', id, '--json']);
433
+ if (shown.code !== 0) {
434
+ ws.send(
435
+ JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
436
+ );
437
+ return;
438
+ }
439
+ ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
440
+ // Push targeted invalidation with updated issue context
441
+ try {
442
+ notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
443
+ } catch {
444
+ // ignore fanout errors
445
+ }
446
+ return;
447
+ }
448
+
449
+ // update-priority
450
+ if (req.type === 'update-priority') {
451
+ const { id, priority } = /** @type {any} */ (req.payload);
452
+ if (
453
+ typeof id !== 'string' ||
454
+ id.length === 0 ||
455
+ typeof priority !== 'number' ||
456
+ priority < 0 ||
457
+ priority > 4
458
+ ) {
459
+ ws.send(
460
+ JSON.stringify(
461
+ makeError(
462
+ req,
463
+ 'bad_request',
464
+ 'payload requires { id: string, priority: 0..4 }'
465
+ )
466
+ )
467
+ );
468
+ return;
469
+ }
470
+ const res = await runBd(['update', id, '--priority', String(priority)]);
471
+ if (res.code !== 0) {
472
+ ws.send(
473
+ JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
474
+ );
475
+ return;
476
+ }
477
+ const shown = await runBdJson(['show', id, '--json']);
478
+ if (shown.code !== 0) {
479
+ ws.send(
480
+ JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
481
+ );
482
+ return;
483
+ }
484
+ ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
485
+ try {
486
+ notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
487
+ } catch {
488
+ // ignore fanout errors
489
+ }
490
+ return;
491
+ }
492
+
493
+ // edit-text
494
+ if (req.type === 'edit-text') {
495
+ const { id, field, value } = /** @type {any} */ (req.payload);
496
+ if (
497
+ typeof id !== 'string' ||
498
+ id.length === 0 ||
499
+ (field !== 'title' &&
500
+ field !== 'description' &&
501
+ field !== 'acceptance') ||
502
+ typeof value !== 'string'
503
+ ) {
504
+ ws.send(
505
+ JSON.stringify(
506
+ makeError(
507
+ req,
508
+ 'bad_request',
509
+ "payload requires { id: string, field: 'title'|'description'|'acceptance', value: string }"
510
+ )
511
+ )
512
+ );
513
+ return;
514
+ }
515
+ // Description updates are currently not supported by bd
516
+ if (field === 'description') {
517
+ ws.send(
518
+ JSON.stringify(
519
+ makeError(
520
+ req,
521
+ 'bd_error',
522
+ 'editing description is not supported by bd'
523
+ )
524
+ )
525
+ );
526
+ return;
527
+ }
528
+ // Map UI fields to bd CLI flags
529
+ // title → --title
530
+ // acceptance → --acceptance-criteria
531
+ const flag = field === 'title' ? '--title' : '--acceptance-criteria';
532
+ const res = await runBd(['update', id, flag, value]);
533
+ if (res.code !== 0) {
534
+ ws.send(
535
+ JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
536
+ );
537
+ return;
538
+ }
539
+ const shown = await runBdJson(['show', id, '--json']);
540
+ if (shown.code !== 0) {
541
+ ws.send(
542
+ JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
543
+ );
544
+ return;
545
+ }
546
+ ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
547
+ try {
548
+ notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
549
+ } catch {
550
+ // ignore fanout errors
551
+ }
552
+ return;
553
+ }
554
+
555
+ // create-issue
556
+ if (req.type === /** @type {any} */ ('create-issue')) {
557
+ const { title, type, priority, description } = /** @type {any} */ (
558
+ req.payload || {}
559
+ );
560
+ if (typeof title !== 'string' || title.length === 0) {
561
+ ws.send(
562
+ JSON.stringify(
563
+ makeError(
564
+ req,
565
+ 'bad_request',
566
+ 'payload requires { title: string, ... }'
567
+ )
568
+ )
569
+ );
570
+ return;
571
+ }
572
+ /** @type {string[]} */
573
+ const args = ['create', title];
574
+ if (
575
+ typeof type === 'string' &&
576
+ (type === 'bug' ||
577
+ type === 'feature' ||
578
+ type === 'task' ||
579
+ type === 'epic' ||
580
+ type === 'chore')
581
+ ) {
582
+ args.push('-t', type);
583
+ }
584
+ if (typeof priority === 'number' && priority >= 0 && priority <= 4) {
585
+ args.push('-p', String(priority));
586
+ }
587
+ if (typeof description === 'string' && description.length > 0) {
588
+ args.push('-d', description);
589
+ }
590
+ const res = await runBd(args);
591
+ if (res.code !== 0) {
592
+ ws.send(
593
+ JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
594
+ );
595
+ return;
596
+ }
597
+ // Rely on watcher to refresh clients; reply with a minimal ack
598
+ ws.send(JSON.stringify(makeOk(req, { created: true })));
599
+ return;
600
+ }
601
+
602
+ // dep-add: payload { a: string, b: string, view_id?: string }
603
+ if (req.type === /** @type {any} */ ('dep-add')) {
604
+ const { a, b, view_id } = /** @type {any} */ (req.payload || {});
605
+ if (
606
+ typeof a !== 'string' ||
607
+ a.length === 0 ||
608
+ typeof b !== 'string' ||
609
+ b.length === 0
610
+ ) {
611
+ ws.send(
612
+ JSON.stringify(
613
+ makeError(
614
+ req,
615
+ 'bad_request',
616
+ 'payload requires { a: string, b: string }'
617
+ )
618
+ )
619
+ );
620
+ return;
621
+ }
622
+ const res = await runBd(['dep', 'add', a, b]);
623
+ if (res.code !== 0) {
624
+ ws.send(
625
+ JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
626
+ );
627
+ return;
628
+ }
629
+ const id = typeof view_id === 'string' && view_id.length > 0 ? view_id : a;
630
+ const shown = await runBdJson(['show', id, '--json']);
631
+ if (shown.code !== 0) {
632
+ ws.send(
633
+ JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
634
+ );
635
+ return;
636
+ }
637
+ ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
638
+ try {
639
+ // Dependencies can affect readiness; conservatively target by issue id
640
+ notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
641
+ } catch {
642
+ // ignore fanout errors
643
+ }
644
+ return;
645
+ }
646
+
647
+ // dep-remove: payload { a: string, b: string, view_id?: string }
648
+ if (req.type === /** @type {any} */ ('dep-remove')) {
649
+ const { a, b, view_id } = /** @type {any} */ (req.payload || {});
650
+ if (
651
+ typeof a !== 'string' ||
652
+ a.length === 0 ||
653
+ typeof b !== 'string' ||
654
+ b.length === 0
655
+ ) {
656
+ ws.send(
657
+ JSON.stringify(
658
+ makeError(
659
+ req,
660
+ 'bad_request',
661
+ 'payload requires { a: string, b: string }'
662
+ )
663
+ )
664
+ );
665
+ return;
666
+ }
667
+ const res = await runBd(['dep', 'remove', a, b]);
668
+ if (res.code !== 0) {
669
+ ws.send(
670
+ JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
671
+ );
672
+ return;
673
+ }
674
+ const id = typeof view_id === 'string' && view_id.length > 0 ? view_id : a;
675
+ const shown = await runBdJson(['show', id, '--json']);
676
+ if (shown.code !== 0) {
677
+ ws.send(
678
+ JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
679
+ );
680
+ return;
681
+ }
682
+ ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
683
+ try {
684
+ notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
685
+ } catch {
686
+ // ignore fanout errors
687
+ }
688
+ return;
689
+ }
690
+
691
+ // label-add: payload { id: string, label: string }
692
+ if (req.type === /** @type {any} */ ('label-add')) {
693
+ const { id, label } = /** @type {any} */ (req.payload || {});
694
+ if (
695
+ typeof id !== 'string' ||
696
+ id.length === 0 ||
697
+ typeof label !== 'string' ||
698
+ label.trim().length === 0
699
+ ) {
700
+ ws.send(
701
+ JSON.stringify(
702
+ makeError(
703
+ req,
704
+ 'bad_request',
705
+ 'payload requires { id: string, label: non-empty string }'
706
+ )
707
+ )
708
+ );
709
+ return;
710
+ }
711
+ const res = await runBd(['label', 'add', id, label.trim()]);
712
+ if (res.code !== 0) {
713
+ ws.send(
714
+ JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
715
+ );
716
+ return;
717
+ }
718
+ const shown = await runBdJson(['show', id, '--json']);
719
+ if (shown.code !== 0) {
720
+ ws.send(
721
+ JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
722
+ );
723
+ return;
724
+ }
725
+ ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
726
+ try {
727
+ notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
728
+ } catch {
729
+ // ignore
730
+ }
731
+ return;
732
+ }
733
+
734
+ // label-remove: payload { id: string, label: string }
735
+ if (req.type === /** @type {any} */ ('label-remove')) {
736
+ const { id, label } = /** @type {any} */ (req.payload || {});
737
+ if (
738
+ typeof id !== 'string' ||
739
+ id.length === 0 ||
740
+ typeof label !== 'string' ||
741
+ label.trim().length === 0
742
+ ) {
743
+ ws.send(
744
+ JSON.stringify(
745
+ makeError(
746
+ req,
747
+ 'bad_request',
748
+ 'payload requires { id: string, label: non-empty string }'
749
+ )
750
+ )
751
+ );
752
+ return;
753
+ }
754
+ const res = await runBd(['label', 'remove', id, label.trim()]);
755
+ if (res.code !== 0) {
756
+ ws.send(
757
+ JSON.stringify(makeError(req, 'bd_error', res.stderr || 'bd failed'))
758
+ );
759
+ return;
760
+ }
761
+ const shown = await runBdJson(['show', id, '--json']);
762
+ if (shown.code !== 0) {
763
+ ws.send(
764
+ JSON.stringify(makeError(req, 'bd_error', shown.stderr || 'bd failed'))
765
+ );
766
+ return;
767
+ }
768
+ ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
769
+ try {
770
+ notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
771
+ } catch {
772
+ // ignore
773
+ }
774
+ return;
775
+ }
776
+
777
+ // Unknown type
778
+ const err = makeError(
779
+ req,
780
+ 'unknown_type',
781
+ `Unknown message type: ${req.type}`
782
+ );
783
+ ws.send(JSON.stringify(err));
784
+ }