beads-ui 0.1.2 → 0.3.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 (54) hide show
  1. package/CHANGES.md +29 -2
  2. package/README.md +39 -45
  3. package/app/data/list-selectors.js +98 -0
  4. package/app/data/providers.js +25 -127
  5. package/app/data/sort.js +45 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +102 -0
  8. package/app/data/subscriptions-store.js +219 -0
  9. package/app/index.html +8 -0
  10. package/app/main.js +483 -61
  11. package/app/protocol.js +10 -14
  12. package/app/protocol.md +21 -19
  13. package/app/router.js +45 -9
  14. package/app/state.js +27 -11
  15. package/app/styles.css +373 -184
  16. package/app/utils/issue-id-renderer.js +71 -0
  17. package/app/utils/issue-url.js +9 -0
  18. package/app/utils/markdown.js +15 -194
  19. package/app/utils/priority-badge.js +0 -2
  20. package/app/utils/status-badge.js +0 -1
  21. package/app/utils/toast.js +34 -0
  22. package/app/utils/type-badge.js +0 -3
  23. package/app/views/board.js +439 -87
  24. package/app/views/detail.js +364 -154
  25. package/app/views/epics.js +128 -76
  26. package/app/views/issue-dialog.js +163 -0
  27. package/app/views/issue-row.js +10 -11
  28. package/app/views/list.js +164 -93
  29. package/app/views/new-issue-dialog.js +345 -0
  30. package/app/ws.js +36 -9
  31. package/bin/bdui.js +1 -1
  32. package/docs/adr/001-push-only-lists.md +134 -0
  33. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  34. package/docs/architecture.md +35 -85
  35. package/docs/data-exchange-subscription-plan.md +198 -0
  36. package/docs/db-watching.md +2 -1
  37. package/docs/migration-v2.md +54 -0
  38. package/docs/protocol/issues-push-v2.md +179 -0
  39. package/docs/subscription-issue-store.md +112 -0
  40. package/package.json +11 -3
  41. package/server/bd.js +0 -2
  42. package/server/cli/commands.js +12 -5
  43. package/server/cli/daemon.js +12 -5
  44. package/server/cli/index.js +34 -5
  45. package/server/cli/usage.js +2 -2
  46. package/server/config.js +12 -6
  47. package/server/db.js +0 -1
  48. package/server/index.js +9 -5
  49. package/server/list-adapters.js +218 -0
  50. package/server/subscriptions.js +277 -0
  51. package/server/validators.js +111 -0
  52. package/server/watcher.js +6 -9
  53. package/server/ws.js +466 -227
  54. package/docs/quickstart.md +0 -142
package/server/ws.js CHANGED
@@ -5,17 +5,175 @@
5
5
  */
6
6
  import { WebSocketServer } from 'ws';
7
7
  import { runBd, runBdJson } from './bd.js';
8
+ import { fetchListForSubscription } from './list-adapters.js';
8
9
  import { isRequest, makeError, makeOk } from './protocol.js';
10
+ import { keyOf, registry } from './subscriptions.js';
11
+ import { validateSubscribeListPayload } from './validators.js';
12
+
13
+ /**
14
+ * Debounced refresh scheduling for active list subscriptions.
15
+ * A trailing window coalesces rapid change bursts into a single refresh run.
16
+ */
17
+ /** @type {ReturnType<typeof setTimeout> | null} */
18
+ let REFRESH_TIMER = null;
19
+ let REFRESH_DEBOUNCE_MS = 75;
20
+
21
+ /**
22
+ * Mutation refresh window gate. When active, watcher-driven list refresh
23
+ * scheduling is suppressed. The gate resolves either when a watcher event
24
+ * arrives (via scheduleListRefresh) or when a timeout elapses, at which
25
+ * point a single refresh pass over all active list subscriptions is run.
26
+ */
27
+ /**
28
+ * @typedef {Object} MutationGate
29
+ * @property {boolean} resolved
30
+ * @property {(reason: 'watcher'|'timeout') => void} resolve
31
+ * @property {ReturnType<typeof setTimeout>} timer
32
+ */
33
+ /** @type {MutationGate | null} */
34
+ let MUTATION_GATE = null;
35
+
36
+ /**
37
+ * Start a mutation window gate if not already active. The gate resolves on the
38
+ * next watcher event or after `timeout_ms`, then triggers a single refresh run
39
+ * across all active list subscriptions. Watcher-driven refresh scheduling is
40
+ * suppressed during the window.
41
+ *
42
+ * Fire-and-forget; callers should not await this.
43
+ * @param {number} [timeout_ms]
44
+ */
45
+ function triggerMutationRefreshOnce(timeout_ms = 500) {
46
+ if (MUTATION_GATE) {
47
+ return;
48
+ }
49
+ /** @type {(r: 'watcher'|'timeout') => void} */
50
+ let doResolve = () => {};
51
+ const p = new Promise((resolve) => {
52
+ doResolve = resolve;
53
+ });
54
+ MUTATION_GATE = {
55
+ resolved: false,
56
+ resolve: (reason) => {
57
+ if (!MUTATION_GATE || MUTATION_GATE.resolved) {
58
+ return;
59
+ }
60
+ MUTATION_GATE.resolved = true;
61
+ try {
62
+ doResolve(reason);
63
+ } catch {
64
+ // ignore resolve errors
65
+ }
66
+ },
67
+ timer: setTimeout(() => {
68
+ try {
69
+ MUTATION_GATE?.resolve('timeout');
70
+ } catch {
71
+ // ignore
72
+ }
73
+ }, timeout_ms)
74
+ };
75
+ MUTATION_GATE.timer.unref?.();
76
+
77
+ // After resolution, run a single refresh across active subs and clear gate
78
+ void p.then(async () => {
79
+ try {
80
+ await refreshAllActiveListSubscriptions();
81
+ } catch {
82
+ // ignore refresh errors
83
+ } finally {
84
+ try {
85
+ if (MUTATION_GATE?.timer) {
86
+ clearTimeout(MUTATION_GATE.timer);
87
+ }
88
+ } catch {
89
+ // ignore
90
+ }
91
+ MUTATION_GATE = null;
92
+ }
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Collect unique active list subscription specs across all connected clients.
98
+ * @returns {Array<{ type: string, params?: Record<string,string|number|boolean> }>}
99
+ */
100
+ function collectActiveListSpecs() {
101
+ /** @type {Array<{ type: string, params?: Record<string,string|number|boolean> }>} */
102
+ const specs = [];
103
+ /** @type {Set<string>} */
104
+ const seen = new Set();
105
+ const wss = CURRENT_WSS;
106
+ if (!wss) {
107
+ return specs;
108
+ }
109
+ for (const ws of wss.clients) {
110
+ if (ws.readyState !== ws.OPEN) {
111
+ continue;
112
+ }
113
+ const s = ensureSubs(/** @type {any} */ (ws));
114
+ if (!s.list_subs) {
115
+ continue;
116
+ }
117
+ for (const { key, spec } of s.list_subs.values()) {
118
+ if (!seen.has(key)) {
119
+ seen.add(key);
120
+ specs.push(spec);
121
+ }
122
+ }
123
+ }
124
+ return specs;
125
+ }
126
+
127
+ /**
128
+ * Run refresh for all active list subscription specs and publish deltas.
129
+ */
130
+ async function refreshAllActiveListSubscriptions() {
131
+ const specs = collectActiveListSpecs();
132
+ // Run refreshes concurrently; locking is handled per key in the registry
133
+ await Promise.all(
134
+ specs.map(async (spec) => {
135
+ try {
136
+ await refreshAndPublish(spec);
137
+ } catch {
138
+ // ignore refresh errors per spec
139
+ }
140
+ })
141
+ );
142
+ }
143
+
144
+ /**
145
+ * Schedule a coalesced refresh of all active list subscriptions.
146
+ */
147
+ export function scheduleListRefresh() {
148
+ // Suppress watcher-driven refreshes during an active mutation gate; resolve gate once
149
+ if (MUTATION_GATE) {
150
+ try {
151
+ MUTATION_GATE.resolve('watcher');
152
+ } catch {
153
+ // ignore
154
+ }
155
+ return;
156
+ }
157
+ if (REFRESH_TIMER) {
158
+ clearTimeout(REFRESH_TIMER);
159
+ }
160
+ REFRESH_TIMER = setTimeout(() => {
161
+ REFRESH_TIMER = null;
162
+ // Fire and forget; callers don't await scheduling
163
+ void refreshAllActiveListSubscriptions();
164
+ }, REFRESH_DEBOUNCE_MS);
165
+ REFRESH_TIMER.unref?.();
166
+ }
9
167
 
10
168
  /**
11
169
  * @typedef {{
12
- * subscribed: boolean,
13
- * list_filters?: { status?: 'open'|'in_progress'|'closed', ready?: boolean, limit?: number },
14
- * show_id?: string | null
170
+ * show_id?: string | null,
171
+ * list_subs?: Map<string, { key: string, spec: { type: string, params?: Record<string, string | number | boolean> } }>,
172
+ * list_revisions?: Map<string, number>
15
173
  * }} ConnectionSubs
16
174
  */
17
175
 
18
- /** @type {WeakMap<WebSocket, ConnectionSubs>} */
176
+ /** @type {WeakMap<WebSocket, any>} */
19
177
  const SUBS = new WeakMap();
20
178
 
21
179
  /** @type {WebSocketServer | null} */
@@ -24,117 +182,224 @@ let CURRENT_WSS = null;
24
182
  /**
25
183
  * Get or initialize the subscription state for a socket.
26
184
  * @param {WebSocket} ws
27
- * @returns {ConnectionSubs}
185
+ * @returns {any}
28
186
  */
29
- function getSubs(ws) {
187
+ function ensureSubs(ws) {
30
188
  let s = SUBS.get(ws);
31
189
  if (!s) {
32
- s = { subscribed: false, show_id: null };
190
+ s = {
191
+ show_id: null,
192
+ list_subs: new Map(),
193
+ list_revisions: new Map()
194
+ };
33
195
  SUBS.set(ws, s);
34
196
  }
35
197
  return s;
36
198
  }
37
199
 
38
200
  /**
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]
201
+ * Get next monotonically increasing revision for a subscription key on this connection.
202
+ * @param {WebSocket} ws
203
+ * @param {string} key
47
204
  */
48
- export function notifyIssuesChanged(payload, options = {}) {
49
- const wss = CURRENT_WSS;
50
- if (!wss) {
51
- return;
205
+ /**
206
+ * @param {WebSocket} ws
207
+ * @param {string} key
208
+ */
209
+ function nextListRevision(ws, key) {
210
+ const s = ensureSubs(ws);
211
+ const m = s.list_revisions || new Map();
212
+ s.list_revisions = m;
213
+ const prev = m.get(key) || 0;
214
+ const next = prev + 1;
215
+ m.set(key, next);
216
+ return next;
217
+ }
218
+
219
+ /**
220
+ * Emit per-subscription envelopes to a specific client id on a socket.
221
+ * Helpers for snapshot / upsert / delete.
222
+ */
223
+ /**
224
+ * @param {WebSocket} ws
225
+ * @param {string} client_id
226
+ * @param {string} key
227
+ * @param {Array<Record<string, unknown>>} issues
228
+ */
229
+ function emitSubscriptionSnapshot(ws, client_id, key, issues) {
230
+ const revision = nextListRevision(ws, key);
231
+ const payload = {
232
+ type: /** @type {const} */ ('snapshot'),
233
+ id: client_id,
234
+ revision,
235
+ issues
236
+ };
237
+ const msg = JSON.stringify({
238
+ id: `evt-${Date.now()}`,
239
+ ok: true,
240
+ type: /** @type {MessageType} */ ('snapshot'),
241
+ payload
242
+ });
243
+ try {
244
+ ws.send(msg);
245
+ } catch {
246
+ // ignore per-socket errors
52
247
  }
53
- /** @type {Set<WebSocket>} */
54
- const recipients = new Set();
248
+ }
55
249
 
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
- : [];
250
+ /**
251
+ * @param {WebSocket} ws
252
+ * @param {string} client_id
253
+ * @param {string} key
254
+ * @param {Record<string, unknown>} issue
255
+ */
256
+ function emitSubscriptionUpsert(ws, client_id, key, issue) {
257
+ const revision = nextListRevision(ws, key);
258
+ const payload = {
259
+ type: 'upsert',
260
+ id: client_id,
261
+ revision,
262
+ issue
263
+ };
264
+ const msg = JSON.stringify({
265
+ id: `evt-${Date.now()}`,
266
+ ok: true,
267
+ type: /** @type {MessageType} */ ('upsert'),
268
+ payload
269
+ });
270
+ try {
271
+ ws.send(msg);
272
+ } catch {
273
+ // ignore
274
+ }
275
+ }
62
276
 
63
- if (issue && typeof issue === 'object' && issue.id) {
64
- for (const ws of wss.clients) {
65
- if (ws.readyState !== ws.OPEN) {
66
- continue;
277
+ /**
278
+ * @param {WebSocket} ws
279
+ * @param {string} client_id
280
+ * @param {string} key
281
+ * @param {string} issue_id
282
+ */
283
+ function emitSubscriptionDelete(ws, client_id, key, issue_id) {
284
+ const revision = nextListRevision(ws, key);
285
+ const payload = {
286
+ type: 'delete',
287
+ id: client_id,
288
+ revision,
289
+ issue_id
290
+ };
291
+ const msg = JSON.stringify({
292
+ id: `evt-${Date.now()}`,
293
+ ok: true,
294
+ type: /** @type {MessageType} */ ('delete'),
295
+ payload
296
+ });
297
+ try {
298
+ ws.send(msg);
299
+ } catch {
300
+ // ignore
301
+ }
302
+ }
303
+
304
+ // issues-changed removed in v2: detail and lists are pushed via subscriptions
305
+
306
+ /**
307
+ * Refresh a subscription spec: fetch via adapter, apply to registry and emit
308
+ * per-subscription full-issue envelopes to subscribers. Serialized per key.
309
+ * @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
310
+ */
311
+ async function refreshAndPublish(spec) {
312
+ const key = keyOf(spec);
313
+ await registry.withKeyLock(key, async () => {
314
+ const res = await fetchListForSubscription(spec);
315
+ if (!res.ok) {
316
+ return;
317
+ }
318
+ const items = applyClosedIssuesFilter(spec, res.items);
319
+ const prevSize = registry.get(key)?.itemsById.size || 0;
320
+ const delta = registry.applyItems(key, items);
321
+ const entry = registry.get(key);
322
+ if (!entry || entry.subscribers.size === 0) {
323
+ return;
324
+ }
325
+ /** @type {Map<string, any>} */
326
+ const byId = new Map();
327
+ for (const it of items) {
328
+ if (it && typeof it.id === 'string') {
329
+ byId.set(it.id, it);
67
330
  }
68
- const s = getSubs(/** @type {any} */ (ws));
69
- if (!s.subscribed) {
70
- continue;
331
+ }
332
+ for (const ws of entry.subscribers) {
333
+ if (ws.readyState !== ws.OPEN) continue;
334
+ const s = ensureSubs(ws);
335
+ const subs = s.list_subs || new Map();
336
+ /** @type {string[]} */
337
+ const clientIds = [];
338
+ for (const [cid, v] of subs.entries()) {
339
+ if (v.key === key) clientIds.push(cid);
71
340
  }
72
- if (s.show_id && s.show_id === issue.id) {
73
- recipients.add(ws);
341
+ if (clientIds.length === 0) continue;
342
+ if (prevSize === 0) {
343
+ for (const cid of clientIds) {
344
+ emitSubscriptionSnapshot(ws, cid, key, items);
345
+ }
74
346
  continue;
75
347
  }
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;
348
+ for (const cid of clientIds) {
349
+ for (const id of [...delta.added, ...delta.updated]) {
350
+ const issue = byId.get(id);
351
+ if (issue) {
352
+ emitSubscriptionUpsert(ws, cid, key, issue);
353
+ }
81
354
  }
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;
355
+ for (const id of delta.removed) {
356
+ emitSubscriptionDelete(ws, cid, key, id);
89
357
  }
90
358
  }
91
359
  }
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
360
  });
361
+ }
114
362
 
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
- }
363
+ /**
364
+ * Apply pre-diff filtering for closed-issues lists based on spec.params.since (epoch ms).
365
+ * @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
366
+ * @param {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
367
+ */
368
+ function applyClosedIssuesFilter(spec, items) {
369
+ if (String(spec.type) !== 'closed-issues') {
370
+ return items;
371
+ }
372
+ const p = spec.params || {};
373
+ const since = typeof p.since === 'number' ? p.since : 0;
374
+ if (!Number.isFinite(since) || since <= 0) {
375
+ return items;
376
+ }
377
+ /** @type {typeof items} */
378
+ const out = [];
379
+ for (const it of items) {
380
+ const ca = it.closed_at;
381
+ if (typeof ca === 'number' && Number.isFinite(ca) && ca >= since) {
382
+ out.push(it);
125
383
  }
126
384
  }
385
+ return out;
127
386
  }
128
387
 
129
388
  /**
130
389
  * Attach a WebSocket server to an existing HTTP server.
131
390
  * @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 }}
391
+ * @param {{ path?: string, heartbeat_ms?: number, refresh_debounce_ms?: number }} [options]
392
+ * @returns {{ wss: WebSocketServer, broadcast: (type: MessageType, payload?: unknown) => void, scheduleListRefresh: () => void }}
134
393
  */
135
394
  export function attachWsServer(http_server, options = {}) {
136
395
  const path = options.path || '/ws';
137
396
  const heartbeat_ms = options.heartbeat_ms ?? 30000;
397
+ if (typeof options.refresh_debounce_ms === 'number') {
398
+ const n = options.refresh_debounce_ms;
399
+ if (Number.isFinite(n) && n >= 0) {
400
+ REFRESH_DEBOUNCE_MS = n;
401
+ }
402
+ }
138
403
 
139
404
  const wss = new WebSocketServer({ server: http_server, path });
140
405
  CURRENT_WSS = wss;
@@ -145,7 +410,7 @@ export function attachWsServer(http_server, options = {}) {
145
410
  ws.isAlive = true;
146
411
 
147
412
  // Initialize subscription state for this connection
148
- getSubs(/** @type {any} */ (ws));
413
+ ensureSubs(ws);
149
414
 
150
415
  ws.on('pong', () => {
151
416
  // @ts-expect-error marker
@@ -155,6 +420,14 @@ export function attachWsServer(http_server, options = {}) {
155
420
  ws.on('message', (data) => {
156
421
  handleMessage(ws, data);
157
422
  });
423
+
424
+ ws.on('close', () => {
425
+ try {
426
+ registry.onDisconnect(ws);
427
+ } catch {
428
+ // ignore cleanup errors
429
+ }
430
+ });
158
431
  });
159
432
 
160
433
  const interval = setInterval(() => {
@@ -195,7 +468,12 @@ export function attachWsServer(http_server, options = {}) {
195
468
  }
196
469
  }
197
470
 
198
- return { wss, broadcast, notifyIssuesChanged: (p) => notifyIssuesChanged(p) };
471
+ return {
472
+ wss,
473
+ broadcast,
474
+ scheduleListRefresh
475
+ // v2: list subscription refresh handles updates
476
+ };
199
477
  }
200
478
 
201
479
  /**
@@ -233,100 +511,50 @@ export async function handleMessage(ws, data) {
233
511
  const req = json;
234
512
 
235
513
  // Dispatch known types here as we implement them. For now, only a ping utility.
236
- if (req.type === /** @type {any} */ ('ping')) {
514
+ if (req.type === /** @type {MessageType} */ ('ping')) {
237
515
  ws.send(JSON.stringify(makeOk(req, { ts: Date.now() })));
238
516
  return;
239
517
  }
240
518
 
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));
519
+ // subscribe-list: payload { id: string, type: string, params?: object }
520
+ if (req.type === 'subscribe-list') {
521
+ const validation = validateSubscribeListPayload(
522
+ /** @type {any} */ (req.payload || {})
523
+ );
524
+ if (!validation.ok) {
525
+ ws.send(
526
+ JSON.stringify(makeError(req, validation.code, validation.message))
527
+ );
289
528
  return;
290
529
  }
291
- // Remember last non-ready list filter
530
+ const client_id = validation.id;
531
+ const spec = validation.spec;
532
+ const s = ensureSubs(ws);
533
+ // Attach to registry
534
+ const { key } = registry.attach(spec, ws);
535
+ s.list_subs?.set(client_id, { key, spec });
536
+ // Send an initial snapshot for this client id only and store items
292
537
  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
- }
538
+ await registry.withKeyLock(key, async () => {
539
+ const res = await fetchListForSubscription(spec);
540
+ if (!res.ok) {
541
+ return;
542
+ }
543
+ const items = applyClosedIssuesFilter(spec, res.items);
544
+ void registry.applyItems(key, items);
545
+ emitSubscriptionSnapshot(ws, client_id, key, items);
546
+ });
307
547
  } catch {
308
- // ignore tracking errors
548
+ // ignore snapshot errors
309
549
  }
310
- ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
550
+ ws.send(JSON.stringify(makeOk(req, { id: client_id, key })));
311
551
  return;
312
552
  }
313
553
 
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) {
554
+ // unsubscribe-list: payload { id: string }
555
+ if (req.type === 'unsubscribe-list') {
556
+ const { id: client_id } = /** @type {any} */ (req.payload || {});
557
+ if (typeof client_id !== 'string' || client_id.length === 0) {
330
558
  ws.send(
331
559
  JSON.stringify(
332
560
  makeError(req, 'bad_request', 'payload.id must be a non-empty string')
@@ -334,37 +562,38 @@ export async function handleMessage(ws, data) {
334
562
  );
335
563
  return;
336
564
  }
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)));
565
+ const s = ensureSubs(ws);
566
+ const sub = s.list_subs?.get(client_id) || null;
567
+ let removed = false;
568
+ if (sub) {
569
+ try {
570
+ removed = registry.detach(sub.spec, ws);
571
+ } catch {
572
+ removed = false;
573
+ }
574
+ s.list_subs?.delete(client_id);
575
+ }
576
+ ws.send(
577
+ JSON.stringify(
578
+ makeOk(req, {
579
+ id: client_id,
580
+ unsubscribed: removed
581
+ })
582
+ )
583
+ );
361
584
  return;
362
585
  }
363
586
 
587
+ // Removed: subscribe-updates and subscribe-issues. No-ops in v2.
588
+
589
+ // list-issues and epic-status were removed in favor of push-only subscriptions
590
+
591
+ // Removed: show-issue. Details flow is push-only via `subscribe-list { type: 'issue-detail' }`.
592
+
364
593
  // type updates are not exposed via UI; no handler
365
594
 
366
595
  // update-assignee
367
- if (req.type === /** @type {any} */ ('update-assignee')) {
596
+ if (req.type === 'update-assignee') {
368
597
  const { id, assignee } = /** @type {any} */ (req.payload || {});
369
598
  if (
370
599
  typeof id !== 'string' ||
@@ -398,6 +627,11 @@ export async function handleMessage(ws, data) {
398
627
  return;
399
628
  }
400
629
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
630
+ try {
631
+ triggerMutationRefreshOnce();
632
+ } catch {
633
+ // ignore
634
+ }
401
635
  return;
402
636
  }
403
637
 
@@ -437,11 +671,11 @@ export async function handleMessage(ws, data) {
437
671
  return;
438
672
  }
439
673
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
440
- // Push targeted invalidation with updated issue context
674
+ // After mutation, refresh active subscriptions once (watcher or timeout)
441
675
  try {
442
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
676
+ triggerMutationRefreshOnce();
443
677
  } catch {
444
- // ignore fanout errors
678
+ // ignore
445
679
  }
446
680
  return;
447
681
  }
@@ -483,9 +717,9 @@ export async function handleMessage(ws, data) {
483
717
  }
484
718
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
485
719
  try {
486
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
720
+ triggerMutationRefreshOnce();
487
721
  } catch {
488
- // ignore fanout errors
722
+ // ignore
489
723
  }
490
724
  return;
491
725
  }
@@ -498,7 +732,9 @@ export async function handleMessage(ws, data) {
498
732
  id.length === 0 ||
499
733
  (field !== 'title' &&
500
734
  field !== 'description' &&
501
- field !== 'acceptance') ||
735
+ field !== 'acceptance' &&
736
+ field !== 'notes' &&
737
+ field !== 'design') ||
502
738
  typeof value !== 'string'
503
739
  ) {
504
740
  ws.send(
@@ -506,20 +742,7 @@ export async function handleMessage(ws, data) {
506
742
  makeError(
507
743
  req,
508
744
  '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'
745
+ "payload requires { id: string, field: 'title'|'description'|'acceptance'|'notes'|'design', value: string }"
523
746
  )
524
747
  )
525
748
  );
@@ -527,8 +750,20 @@ export async function handleMessage(ws, data) {
527
750
  }
528
751
  // Map UI fields to bd CLI flags
529
752
  // title → --title
753
+ // description → --description
530
754
  // acceptance → --acceptance-criteria
531
- const flag = field === 'title' ? '--title' : '--acceptance-criteria';
755
+ // notes → --notes
756
+ // design → --design
757
+ const flag =
758
+ field === 'title'
759
+ ? '--title'
760
+ : field === 'description'
761
+ ? '--description'
762
+ : field === 'acceptance'
763
+ ? '--acceptance-criteria'
764
+ : field === 'notes'
765
+ ? '--notes'
766
+ : '--design';
532
767
  const res = await runBd(['update', id, flag, value]);
533
768
  if (res.code !== 0) {
534
769
  ws.send(
@@ -545,15 +780,15 @@ export async function handleMessage(ws, data) {
545
780
  }
546
781
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
547
782
  try {
548
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
783
+ triggerMutationRefreshOnce();
549
784
  } catch {
550
- // ignore fanout errors
785
+ // ignore
551
786
  }
552
787
  return;
553
788
  }
554
789
 
555
790
  // create-issue
556
- if (req.type === /** @type {any} */ ('create-issue')) {
791
+ if (req.type === 'create-issue') {
557
792
  const { title, type, priority, description } = /** @type {any} */ (
558
793
  req.payload || {}
559
794
  );
@@ -569,7 +804,6 @@ export async function handleMessage(ws, data) {
569
804
  );
570
805
  return;
571
806
  }
572
- /** @type {string[]} */
573
807
  const args = ['create', title];
574
808
  if (
575
809
  typeof type === 'string' &&
@@ -594,13 +828,19 @@ export async function handleMessage(ws, data) {
594
828
  );
595
829
  return;
596
830
  }
597
- // Rely on watcher to refresh clients; reply with a minimal ack
831
+ // Reply with a minimal ack
598
832
  ws.send(JSON.stringify(makeOk(req, { created: true })));
833
+ // Refresh active subscriptions once (watcher or timeout)
834
+ try {
835
+ triggerMutationRefreshOnce();
836
+ } catch {
837
+ // ignore
838
+ }
599
839
  return;
600
840
  }
601
841
 
602
842
  // dep-add: payload { a: string, b: string, view_id?: string }
603
- if (req.type === /** @type {any} */ ('dep-add')) {
843
+ if (req.type === 'dep-add') {
604
844
  const { a, b, view_id } = /** @type {any} */ (req.payload || {});
605
845
  if (
606
846
  typeof a !== 'string' ||
@@ -636,16 +876,15 @@ export async function handleMessage(ws, data) {
636
876
  }
637
877
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
638
878
  try {
639
- // Dependencies can affect readiness; conservatively target by issue id
640
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
879
+ triggerMutationRefreshOnce();
641
880
  } catch {
642
- // ignore fanout errors
881
+ // ignore
643
882
  }
644
883
  return;
645
884
  }
646
885
 
647
886
  // dep-remove: payload { a: string, b: string, view_id?: string }
648
- if (req.type === /** @type {any} */ ('dep-remove')) {
887
+ if (req.type === 'dep-remove') {
649
888
  const { a, b, view_id } = /** @type {any} */ (req.payload || {});
650
889
  if (
651
890
  typeof a !== 'string' ||
@@ -681,15 +920,15 @@ export async function handleMessage(ws, data) {
681
920
  }
682
921
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
683
922
  try {
684
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
923
+ triggerMutationRefreshOnce();
685
924
  } catch {
686
- // ignore fanout errors
925
+ // ignore
687
926
  }
688
927
  return;
689
928
  }
690
929
 
691
930
  // label-add: payload { id: string, label: string }
692
- if (req.type === /** @type {any} */ ('label-add')) {
931
+ if (req.type === 'label-add') {
693
932
  const { id, label } = /** @type {any} */ (req.payload || {});
694
933
  if (
695
934
  typeof id !== 'string' ||
@@ -724,7 +963,7 @@ export async function handleMessage(ws, data) {
724
963
  }
725
964
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
726
965
  try {
727
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
966
+ triggerMutationRefreshOnce();
728
967
  } catch {
729
968
  // ignore
730
969
  }
@@ -732,7 +971,7 @@ export async function handleMessage(ws, data) {
732
971
  }
733
972
 
734
973
  // label-remove: payload { id: string, label: string }
735
- if (req.type === /** @type {any} */ ('label-remove')) {
974
+ if (req.type === 'label-remove') {
736
975
  const { id, label } = /** @type {any} */ (req.payload || {});
737
976
  if (
738
977
  typeof id !== 'string' ||
@@ -767,7 +1006,7 @@ export async function handleMessage(ws, data) {
767
1006
  }
768
1007
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
769
1008
  try {
770
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
1009
+ triggerMutationRefreshOnce();
771
1010
  } catch {
772
1011
  // ignore
773
1012
  }