beads-ui 0.2.0 → 0.3.1

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 (58) hide show
  1. package/CHANGES.md +14 -0
  2. package/README.md +4 -4
  3. package/app/data/list-selectors.js +103 -0
  4. package/app/data/providers.js +7 -138
  5. package/app/data/sort.js +47 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +128 -0
  8. package/app/data/subscriptions-store.js +227 -0
  9. package/app/main.js +346 -66
  10. package/app/protocol.js +23 -17
  11. package/app/protocol.md +18 -15
  12. package/app/router.js +3 -0
  13. package/app/state.js +2 -0
  14. package/app/styles.css +222 -197
  15. package/app/utils/issue-id-renderer.js +2 -1
  16. package/app/utils/issue-id.js +1 -0
  17. package/app/utils/issue-type.js +2 -0
  18. package/app/utils/issue-url.js +1 -0
  19. package/app/utils/markdown.js +13 -198
  20. package/app/utils/priority-badge.js +1 -2
  21. package/app/utils/status-badge.js +1 -1
  22. package/app/utils/status.js +2 -0
  23. package/app/utils/toast.js +1 -1
  24. package/app/utils/type-badge.js +1 -3
  25. package/app/views/board.js +172 -148
  26. package/app/views/detail.js +79 -66
  27. package/app/views/epics.js +127 -74
  28. package/app/views/issue-dialog.js +9 -15
  29. package/app/views/issue-row.js +2 -3
  30. package/app/views/list.js +105 -104
  31. package/app/views/nav.js +1 -0
  32. package/app/views/new-issue-dialog.js +30 -34
  33. package/app/ws.js +10 -10
  34. package/bin/bdui.js +1 -1
  35. package/docs/adr/001-push-only-lists.md +134 -0
  36. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  37. package/docs/architecture.md +34 -84
  38. package/docs/data-exchange-subscription-plan.md +198 -0
  39. package/docs/db-watching.md +2 -1
  40. package/docs/migration-v2.md +54 -0
  41. package/docs/protocol/issues-push-v2.md +179 -0
  42. package/docs/subscription-issue-store.md +112 -0
  43. package/package.json +5 -4
  44. package/server/app.js +2 -0
  45. package/server/bd.js +4 -2
  46. package/server/cli/commands.js +5 -2
  47. package/server/cli/daemon.js +19 -5
  48. package/server/cli/index.js +2 -2
  49. package/server/cli/open.js +3 -0
  50. package/server/cli/usage.js +2 -1
  51. package/server/config.js +13 -6
  52. package/server/db.js +3 -1
  53. package/server/index.js +9 -5
  54. package/server/list-adapters.js +224 -0
  55. package/server/subscriptions.js +289 -0
  56. package/server/validators.js +113 -0
  57. package/server/watcher.js +8 -8
  58. package/server/ws.js +457 -229
package/server/ws.js CHANGED
@@ -5,17 +5,177 @@
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
+ *
44
+ * @param {number} [timeout_ms]
45
+ */
46
+ function triggerMutationRefreshOnce(timeout_ms = 500) {
47
+ if (MUTATION_GATE) {
48
+ return;
49
+ }
50
+ /** @type {(r: 'watcher'|'timeout') => void} */
51
+ let doResolve = () => {};
52
+ const p = new Promise((resolve) => {
53
+ doResolve = resolve;
54
+ });
55
+ MUTATION_GATE = {
56
+ resolved: false,
57
+ resolve: (reason) => {
58
+ if (!MUTATION_GATE || MUTATION_GATE.resolved) {
59
+ return;
60
+ }
61
+ MUTATION_GATE.resolved = true;
62
+ try {
63
+ doResolve(reason);
64
+ } catch {
65
+ // ignore resolve errors
66
+ }
67
+ },
68
+ timer: setTimeout(() => {
69
+ try {
70
+ MUTATION_GATE?.resolve('timeout');
71
+ } catch {
72
+ // ignore
73
+ }
74
+ }, timeout_ms)
75
+ };
76
+ MUTATION_GATE.timer.unref?.();
77
+
78
+ // After resolution, run a single refresh across active subs and clear gate
79
+ void p.then(async () => {
80
+ try {
81
+ await refreshAllActiveListSubscriptions();
82
+ } catch {
83
+ // ignore refresh errors
84
+ } finally {
85
+ try {
86
+ if (MUTATION_GATE?.timer) {
87
+ clearTimeout(MUTATION_GATE.timer);
88
+ }
89
+ } catch {
90
+ // ignore
91
+ }
92
+ MUTATION_GATE = null;
93
+ }
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Collect unique active list subscription specs across all connected clients.
99
+ *
100
+ * @returns {Array<{ type: string, params?: Record<string,string|number|boolean> }>}
101
+ */
102
+ function collectActiveListSpecs() {
103
+ /** @type {Array<{ type: string, params?: Record<string,string|number|boolean> }>} */
104
+ const specs = [];
105
+ /** @type {Set<string>} */
106
+ const seen = new Set();
107
+ const wss = CURRENT_WSS;
108
+ if (!wss) {
109
+ return specs;
110
+ }
111
+ for (const ws of wss.clients) {
112
+ if (ws.readyState !== ws.OPEN) {
113
+ continue;
114
+ }
115
+ const s = ensureSubs(/** @type {any} */ (ws));
116
+ if (!s.list_subs) {
117
+ continue;
118
+ }
119
+ for (const { key, spec } of s.list_subs.values()) {
120
+ if (!seen.has(key)) {
121
+ seen.add(key);
122
+ specs.push(spec);
123
+ }
124
+ }
125
+ }
126
+ return specs;
127
+ }
128
+
129
+ /**
130
+ * Run refresh for all active list subscription specs and publish deltas.
131
+ */
132
+ async function refreshAllActiveListSubscriptions() {
133
+ const specs = collectActiveListSpecs();
134
+ // Run refreshes concurrently; locking is handled per key in the registry
135
+ await Promise.all(
136
+ specs.map(async (spec) => {
137
+ try {
138
+ await refreshAndPublish(spec);
139
+ } catch {
140
+ // ignore refresh errors per spec
141
+ }
142
+ })
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Schedule a coalesced refresh of all active list subscriptions.
148
+ */
149
+ export function scheduleListRefresh() {
150
+ // Suppress watcher-driven refreshes during an active mutation gate; resolve gate once
151
+ if (MUTATION_GATE) {
152
+ try {
153
+ MUTATION_GATE.resolve('watcher');
154
+ } catch {
155
+ // ignore
156
+ }
157
+ return;
158
+ }
159
+ if (REFRESH_TIMER) {
160
+ clearTimeout(REFRESH_TIMER);
161
+ }
162
+ REFRESH_TIMER = setTimeout(() => {
163
+ REFRESH_TIMER = null;
164
+ // Fire and forget; callers don't await scheduling
165
+ void refreshAllActiveListSubscriptions();
166
+ }, REFRESH_DEBOUNCE_MS);
167
+ REFRESH_TIMER.unref?.();
168
+ }
9
169
 
10
170
  /**
11
171
  * @typedef {{
12
- * subscribed: boolean,
13
- * list_filters?: { status?: 'open'|'in_progress'|'closed', ready?: boolean, blocked?: boolean, limit?: number },
14
- * show_id?: string | null
172
+ * show_id?: string | null,
173
+ * list_subs?: Map<string, { key: string, spec: { type: string, params?: Record<string, string | number | boolean> } }>,
174
+ * list_revisions?: Map<string, number>
15
175
  * }} ConnectionSubs
16
176
  */
17
177
 
18
- /** @type {WeakMap<WebSocket, ConnectionSubs>} */
178
+ /** @type {WeakMap<WebSocket, any>} */
19
179
  const SUBS = new WeakMap();
20
180
 
21
181
  /** @type {WebSocketServer | null} */
@@ -23,118 +183,230 @@ let CURRENT_WSS = null;
23
183
 
24
184
  /**
25
185
  * Get or initialize the subscription state for a socket.
186
+ *
26
187
  * @param {WebSocket} ws
27
- * @returns {ConnectionSubs}
188
+ * @returns {any}
28
189
  */
29
- function getSubs(ws) {
190
+ function ensureSubs(ws) {
30
191
  let s = SUBS.get(ws);
31
192
  if (!s) {
32
- s = { subscribed: false, show_id: null };
193
+ s = {
194
+ show_id: null,
195
+ list_subs: new Map(),
196
+ list_revisions: new Map()
197
+ };
33
198
  SUBS.set(ws, s);
34
199
  }
35
200
  return s;
36
201
  }
37
202
 
38
203
  /**
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]
204
+ * Get next monotonically increasing revision for a subscription key on this connection.
205
+ *
206
+ * @param {WebSocket} ws
207
+ * @param {string} key
47
208
  */
48
- export function notifyIssuesChanged(payload, options = {}) {
49
- const wss = CURRENT_WSS;
50
- if (!wss) {
51
- return;
209
+ /**
210
+ * @param {WebSocket} ws
211
+ * @param {string} key
212
+ */
213
+ function nextListRevision(ws, key) {
214
+ const s = ensureSubs(ws);
215
+ const m = s.list_revisions || new Map();
216
+ s.list_revisions = m;
217
+ const prev = m.get(key) || 0;
218
+ const next = prev + 1;
219
+ m.set(key, next);
220
+ return next;
221
+ }
222
+
223
+ /**
224
+ * Emit per-subscription envelopes to a specific client id on a socket.
225
+ * Helpers for snapshot / upsert / delete.
226
+ */
227
+ /**
228
+ * @param {WebSocket} ws
229
+ * @param {string} client_id
230
+ * @param {string} key
231
+ * @param {Array<Record<string, unknown>>} issues
232
+ */
233
+ function emitSubscriptionSnapshot(ws, client_id, key, issues) {
234
+ const revision = nextListRevision(ws, key);
235
+ const payload = {
236
+ type: /** @type {const} */ ('snapshot'),
237
+ id: client_id,
238
+ revision,
239
+ issues
240
+ };
241
+ const msg = JSON.stringify({
242
+ id: `evt-${Date.now()}`,
243
+ ok: true,
244
+ type: /** @type {MessageType} */ ('snapshot'),
245
+ payload
246
+ });
247
+ try {
248
+ ws.send(msg);
249
+ } catch {
250
+ // ignore per-socket errors
52
251
  }
53
- /** @type {Set<WebSocket>} */
54
- const recipients = new Set();
252
+ }
55
253
 
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
- : [];
254
+ /**
255
+ * @param {WebSocket} ws
256
+ * @param {string} client_id
257
+ * @param {string} key
258
+ * @param {Record<string, unknown>} issue
259
+ */
260
+ function emitSubscriptionUpsert(ws, client_id, key, issue) {
261
+ const revision = nextListRevision(ws, key);
262
+ const payload = {
263
+ type: 'upsert',
264
+ id: client_id,
265
+ revision,
266
+ issue
267
+ };
268
+ const msg = JSON.stringify({
269
+ id: `evt-${Date.now()}`,
270
+ ok: true,
271
+ type: /** @type {MessageType} */ ('upsert'),
272
+ payload
273
+ });
274
+ try {
275
+ ws.send(msg);
276
+ } catch {
277
+ // ignore
278
+ }
279
+ }
62
280
 
63
- if (issue && typeof issue === 'object' && issue.id) {
64
- for (const ws of wss.clients) {
65
- if (ws.readyState !== ws.OPEN) {
66
- continue;
281
+ /**
282
+ * @param {WebSocket} ws
283
+ * @param {string} client_id
284
+ * @param {string} key
285
+ * @param {string} issue_id
286
+ */
287
+ function emitSubscriptionDelete(ws, client_id, key, issue_id) {
288
+ const revision = nextListRevision(ws, key);
289
+ const payload = {
290
+ type: 'delete',
291
+ id: client_id,
292
+ revision,
293
+ issue_id
294
+ };
295
+ const msg = JSON.stringify({
296
+ id: `evt-${Date.now()}`,
297
+ ok: true,
298
+ type: /** @type {MessageType} */ ('delete'),
299
+ payload
300
+ });
301
+ try {
302
+ ws.send(msg);
303
+ } catch {
304
+ // ignore
305
+ }
306
+ }
307
+
308
+ // issues-changed removed in v2: detail and lists are pushed via subscriptions
309
+
310
+ /**
311
+ * Refresh a subscription spec: fetch via adapter, apply to registry and emit
312
+ * per-subscription full-issue envelopes to subscribers. Serialized per key.
313
+ *
314
+ * @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
315
+ */
316
+ async function refreshAndPublish(spec) {
317
+ const key = keyOf(spec);
318
+ await registry.withKeyLock(key, async () => {
319
+ const res = await fetchListForSubscription(spec);
320
+ if (!res.ok) {
321
+ return;
322
+ }
323
+ const items = applyClosedIssuesFilter(spec, res.items);
324
+ const prevSize = registry.get(key)?.itemsById.size || 0;
325
+ const delta = registry.applyItems(key, items);
326
+ const entry = registry.get(key);
327
+ if (!entry || entry.subscribers.size === 0) {
328
+ return;
329
+ }
330
+ /** @type {Map<string, any>} */
331
+ const byId = new Map();
332
+ for (const it of items) {
333
+ if (it && typeof it.id === 'string') {
334
+ byId.set(it.id, it);
67
335
  }
68
- const s = getSubs(/** @type {any} */ (ws));
69
- if (!s.subscribed) {
70
- continue;
336
+ }
337
+ for (const ws of entry.subscribers) {
338
+ if (ws.readyState !== ws.OPEN) continue;
339
+ const s = ensureSubs(ws);
340
+ const subs = s.list_subs || new Map();
341
+ /** @type {string[]} */
342
+ const clientIds = [];
343
+ for (const [cid, v] of subs.entries()) {
344
+ if (v.key === key) clientIds.push(cid);
71
345
  }
72
- if (s.show_id && s.show_id === issue.id) {
73
- recipients.add(ws);
346
+ if (clientIds.length === 0) continue;
347
+ if (prevSize === 0) {
348
+ for (const cid of clientIds) {
349
+ emitSubscriptionSnapshot(ws, cid, key, items);
350
+ }
74
351
  continue;
75
352
  }
76
- if (s.list_filters) {
77
- // Ready/Blocked lists are conservatively invalidated on any change
78
- if (s.list_filters.ready === true || s.list_filters.blocked === true) {
79
- recipients.add(ws);
80
- continue;
353
+ for (const cid of clientIds) {
354
+ for (const id of [...delta.added, ...delta.updated]) {
355
+ const issue = byId.get(id);
356
+ if (issue) {
357
+ emitSubscriptionUpsert(ws, cid, key, issue);
358
+ }
81
359
  }
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;
360
+ for (const id of delta.removed) {
361
+ emitSubscriptionDelete(ws, cid, key, id);
89
362
  }
90
363
  }
91
364
  }
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
365
  });
366
+ }
114
367
 
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
- }
368
+ /**
369
+ * Apply pre-diff filtering for closed-issues lists based on spec.params.since (epoch ms).
370
+ *
371
+ * @param {{ type: string, params?: Record<string, string|number|boolean> }} spec
372
+ * @param {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
373
+ */
374
+ function applyClosedIssuesFilter(spec, items) {
375
+ if (String(spec.type) !== 'closed-issues') {
376
+ return items;
377
+ }
378
+ const p = spec.params || {};
379
+ const since = typeof p.since === 'number' ? p.since : 0;
380
+ if (!Number.isFinite(since) || since <= 0) {
381
+ return items;
382
+ }
383
+ /** @type {typeof items} */
384
+ const out = [];
385
+ for (const it of items) {
386
+ const ca = it.closed_at;
387
+ if (typeof ca === 'number' && Number.isFinite(ca) && ca >= since) {
388
+ out.push(it);
125
389
  }
126
390
  }
391
+ return out;
127
392
  }
128
393
 
129
394
  /**
130
395
  * Attach a WebSocket server to an existing HTTP server.
396
+ *
131
397
  * @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 }}
398
+ * @param {{ path?: string, heartbeat_ms?: number, refresh_debounce_ms?: number }} [options]
399
+ * @returns {{ wss: WebSocketServer, broadcast: (type: MessageType, payload?: unknown) => void, scheduleListRefresh: () => void }}
134
400
  */
135
401
  export function attachWsServer(http_server, options = {}) {
136
402
  const path = options.path || '/ws';
137
403
  const heartbeat_ms = options.heartbeat_ms ?? 30000;
404
+ if (typeof options.refresh_debounce_ms === 'number') {
405
+ const n = options.refresh_debounce_ms;
406
+ if (Number.isFinite(n) && n >= 0) {
407
+ REFRESH_DEBOUNCE_MS = n;
408
+ }
409
+ }
138
410
 
139
411
  const wss = new WebSocketServer({ server: http_server, path });
140
412
  CURRENT_WSS = wss;
@@ -145,7 +417,7 @@ export function attachWsServer(http_server, options = {}) {
145
417
  ws.isAlive = true;
146
418
 
147
419
  // Initialize subscription state for this connection
148
- getSubs(/** @type {any} */ (ws));
420
+ ensureSubs(ws);
149
421
 
150
422
  ws.on('pong', () => {
151
423
  // @ts-expect-error marker
@@ -155,6 +427,14 @@ export function attachWsServer(http_server, options = {}) {
155
427
  ws.on('message', (data) => {
156
428
  handleMessage(ws, data);
157
429
  });
430
+
431
+ ws.on('close', () => {
432
+ try {
433
+ registry.onDisconnect(ws);
434
+ } catch {
435
+ // ignore cleanup errors
436
+ }
437
+ });
158
438
  });
159
439
 
160
440
  const interval = setInterval(() => {
@@ -178,6 +458,7 @@ export function attachWsServer(http_server, options = {}) {
178
458
 
179
459
  /**
180
460
  * Broadcast a server-initiated event to all open clients.
461
+ *
181
462
  * @param {MessageType} type
182
463
  * @param {unknown} [payload]
183
464
  */
@@ -195,11 +476,17 @@ export function attachWsServer(http_server, options = {}) {
195
476
  }
196
477
  }
197
478
 
198
- return { wss, broadcast, notifyIssuesChanged: (p) => notifyIssuesChanged(p) };
479
+ return {
480
+ wss,
481
+ broadcast,
482
+ scheduleListRefresh
483
+ // v2: list subscription refresh handles updates
484
+ };
199
485
  }
200
486
 
201
487
  /**
202
488
  * Handle an incoming message frame and respond to the same socket.
489
+ *
203
490
  * @param {WebSocket} ws
204
491
  * @param {RawData} data
205
492
  */
@@ -233,119 +520,50 @@ export async function handleMessage(ws, data) {
233
520
  const req = json;
234
521
 
235
522
  // Dispatch known types here as we implement them. For now, only a ping utility.
236
- if (req.type === /** @type {any} */ ('ping')) {
523
+ if (req.type === /** @type {MessageType} */ ('ping')) {
237
524
  ws.send(JSON.stringify(makeOk(req, { ts: Date.now() })));
238
525
  return;
239
526
  }
240
527
 
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
- // When "blocked" is requested, use the dedicated bd subcommand
272
- if (filters && typeof filters === 'object' && filters.blocked === true) {
273
- const res = await runBdJson(['blocked', '--json']);
274
- if (res.code !== 0) {
275
- const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
276
- ws.send(JSON.stringify(err));
277
- return;
278
- }
279
- // Remember subscription scope for this connection
280
- try {
281
- const s = getSubs(ws);
282
- s.list_filters = { blocked: true };
283
- } catch {
284
- // ignore tracking errors
285
- }
286
- ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
287
- return;
288
- }
289
-
290
- /** @type {string[]} */
291
- const args = ['list', '--json'];
292
- if (filters && typeof filters === 'object') {
293
- if (typeof filters.status === 'string') {
294
- // Use long flag for clarity and compatibility
295
- args.push('--status', filters.status);
296
- }
297
- if (typeof filters.priority === 'number') {
298
- args.push('--priority', String(filters.priority));
299
- }
300
- if (typeof filters.limit === 'number' && filters.limit > 0) {
301
- args.push('--limit', String(filters.limit));
302
- }
303
- }
304
- const res = await runBdJson(args);
305
- if (res.code !== 0) {
306
- const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
307
- ws.send(JSON.stringify(err));
528
+ // subscribe-list: payload { id: string, type: string, params?: object }
529
+ if (req.type === 'subscribe-list') {
530
+ const validation = validateSubscribeListPayload(
531
+ /** @type {any} */ (req.payload || {})
532
+ );
533
+ if (!validation.ok) {
534
+ ws.send(
535
+ JSON.stringify(makeError(req, validation.code, validation.message))
536
+ );
308
537
  return;
309
538
  }
310
- // Remember last non-ready list filter
539
+ const client_id = validation.id;
540
+ const spec = validation.spec;
541
+ const s = ensureSubs(ws);
542
+ // Attach to registry
543
+ const { key } = registry.attach(spec, ws);
544
+ s.list_subs?.set(client_id, { key, spec });
545
+ // Send an initial snapshot for this client id only and store items
311
546
  try {
312
- const s = getSubs(ws);
313
- /** @type {{ status?: any, limit?: any }} */
314
- const f = filters && typeof filters === 'object' ? filters : {};
315
- /** @type {any} */
316
- const st = f.status;
317
- /** @type {any} */
318
- const lim = f.limit;
319
- s.list_filters = {};
320
- if (st === 'open' || st === 'in_progress' || st === 'closed') {
321
- s.list_filters.status = st;
322
- }
323
- if (typeof lim === 'number') {
324
- s.list_filters.limit = lim;
325
- }
547
+ await registry.withKeyLock(key, async () => {
548
+ const res = await fetchListForSubscription(spec);
549
+ if (!res.ok) {
550
+ return;
551
+ }
552
+ const items = applyClosedIssuesFilter(spec, res.items);
553
+ void registry.applyItems(key, items);
554
+ emitSubscriptionSnapshot(ws, client_id, key, items);
555
+ });
326
556
  } catch {
327
- // ignore tracking errors
557
+ // ignore snapshot errors
328
558
  }
329
- ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
559
+ ws.send(JSON.stringify(makeOk(req, { id: client_id, key })));
330
560
  return;
331
561
  }
332
562
 
333
- // epic-status
334
- if (req.type === /** @type {any} */ ('epic-status')) {
335
- const res = await runBdJson(['epic', 'status', '--json']);
336
- if (res.code !== 0) {
337
- const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
338
- ws.send(JSON.stringify(err));
339
- return;
340
- }
341
- ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
342
- return;
343
- }
344
-
345
- // show-issue
346
- if (req.type === 'show-issue') {
347
- const { id } = /** @type {any} */ (req.payload);
348
- if (typeof id !== 'string' || id.length === 0) {
563
+ // unsubscribe-list: payload { id: string }
564
+ if (req.type === 'unsubscribe-list') {
565
+ const { id: client_id } = /** @type {any} */ (req.payload || {});
566
+ if (typeof client_id !== 'string' || client_id.length === 0) {
349
567
  ws.send(
350
568
  JSON.stringify(
351
569
  makeError(req, 'bad_request', 'payload.id must be a non-empty string')
@@ -353,37 +571,38 @@ export async function handleMessage(ws, data) {
353
571
  );
354
572
  return;
355
573
  }
356
- const res = await runBdJson(['show', id, '--json']);
357
- if (res.code !== 0) {
358
- const err = makeError(req, 'bd_error', res.stderr || 'bd failed');
359
- ws.send(JSON.stringify(err));
360
- return;
361
- }
362
- // bd show can return an array when it supports multiple ids;
363
- // normalize to a single object for the single-id API.
364
- /** @type {any} */
365
- const out = Array.isArray(res.stdoutJson)
366
- ? res.stdoutJson[0]
367
- : res.stdoutJson;
368
- if (!out) {
369
- ws.send(JSON.stringify(makeError(req, 'not_found', 'issue not found')));
370
- return;
371
- }
372
- // Track current detail subscription for this connection
373
- try {
374
- const s = getSubs(ws);
375
- s.show_id = String(id);
376
- } catch {
377
- // ignore
574
+ const s = ensureSubs(ws);
575
+ const sub = s.list_subs?.get(client_id) || null;
576
+ let removed = false;
577
+ if (sub) {
578
+ try {
579
+ removed = registry.detach(sub.spec, ws);
580
+ } catch {
581
+ removed = false;
582
+ }
583
+ s.list_subs?.delete(client_id);
378
584
  }
379
- ws.send(JSON.stringify(makeOk(req, out)));
585
+ ws.send(
586
+ JSON.stringify(
587
+ makeOk(req, {
588
+ id: client_id,
589
+ unsubscribed: removed
590
+ })
591
+ )
592
+ );
380
593
  return;
381
594
  }
382
595
 
596
+ // Removed: subscribe-updates and subscribe-issues. No-ops in v2.
597
+
598
+ // list-issues and epic-status were removed in favor of push-only subscriptions
599
+
600
+ // Removed: show-issue. Details flow is push-only via `subscribe-list { type: 'issue-detail' }`.
601
+
383
602
  // type updates are not exposed via UI; no handler
384
603
 
385
604
  // update-assignee
386
- if (req.type === /** @type {any} */ ('update-assignee')) {
605
+ if (req.type === 'update-assignee') {
387
606
  const { id, assignee } = /** @type {any} */ (req.payload || {});
388
607
  if (
389
608
  typeof id !== 'string' ||
@@ -417,6 +636,11 @@ export async function handleMessage(ws, data) {
417
636
  return;
418
637
  }
419
638
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
639
+ try {
640
+ triggerMutationRefreshOnce();
641
+ } catch {
642
+ // ignore
643
+ }
420
644
  return;
421
645
  }
422
646
 
@@ -456,11 +680,11 @@ export async function handleMessage(ws, data) {
456
680
  return;
457
681
  }
458
682
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
459
- // Push targeted invalidation with updated issue context
683
+ // After mutation, refresh active subscriptions once (watcher or timeout)
460
684
  try {
461
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
685
+ triggerMutationRefreshOnce();
462
686
  } catch {
463
- // ignore fanout errors
687
+ // ignore
464
688
  }
465
689
  return;
466
690
  }
@@ -502,9 +726,9 @@ export async function handleMessage(ws, data) {
502
726
  }
503
727
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
504
728
  try {
505
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
729
+ triggerMutationRefreshOnce();
506
730
  } catch {
507
- // ignore fanout errors
731
+ // ignore
508
732
  }
509
733
  return;
510
734
  }
@@ -565,15 +789,15 @@ export async function handleMessage(ws, data) {
565
789
  }
566
790
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
567
791
  try {
568
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
792
+ triggerMutationRefreshOnce();
569
793
  } catch {
570
- // ignore fanout errors
794
+ // ignore
571
795
  }
572
796
  return;
573
797
  }
574
798
 
575
799
  // create-issue
576
- if (req.type === /** @type {any} */ ('create-issue')) {
800
+ if (req.type === 'create-issue') {
577
801
  const { title, type, priority, description } = /** @type {any} */ (
578
802
  req.payload || {}
579
803
  );
@@ -589,7 +813,6 @@ export async function handleMessage(ws, data) {
589
813
  );
590
814
  return;
591
815
  }
592
- /** @type {string[]} */
593
816
  const args = ['create', title];
594
817
  if (
595
818
  typeof type === 'string' &&
@@ -614,13 +837,19 @@ export async function handleMessage(ws, data) {
614
837
  );
615
838
  return;
616
839
  }
617
- // Rely on watcher to refresh clients; reply with a minimal ack
840
+ // Reply with a minimal ack
618
841
  ws.send(JSON.stringify(makeOk(req, { created: true })));
842
+ // Refresh active subscriptions once (watcher or timeout)
843
+ try {
844
+ triggerMutationRefreshOnce();
845
+ } catch {
846
+ // ignore
847
+ }
619
848
  return;
620
849
  }
621
850
 
622
851
  // dep-add: payload { a: string, b: string, view_id?: string }
623
- if (req.type === /** @type {any} */ ('dep-add')) {
852
+ if (req.type === 'dep-add') {
624
853
  const { a, b, view_id } = /** @type {any} */ (req.payload || {});
625
854
  if (
626
855
  typeof a !== 'string' ||
@@ -656,16 +885,15 @@ export async function handleMessage(ws, data) {
656
885
  }
657
886
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
658
887
  try {
659
- // Dependencies can affect readiness; conservatively target by issue id
660
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
888
+ triggerMutationRefreshOnce();
661
889
  } catch {
662
- // ignore fanout errors
890
+ // ignore
663
891
  }
664
892
  return;
665
893
  }
666
894
 
667
895
  // dep-remove: payload { a: string, b: string, view_id?: string }
668
- if (req.type === /** @type {any} */ ('dep-remove')) {
896
+ if (req.type === 'dep-remove') {
669
897
  const { a, b, view_id } = /** @type {any} */ (req.payload || {});
670
898
  if (
671
899
  typeof a !== 'string' ||
@@ -701,15 +929,15 @@ export async function handleMessage(ws, data) {
701
929
  }
702
930
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
703
931
  try {
704
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
932
+ triggerMutationRefreshOnce();
705
933
  } catch {
706
- // ignore fanout errors
934
+ // ignore
707
935
  }
708
936
  return;
709
937
  }
710
938
 
711
939
  // label-add: payload { id: string, label: string }
712
- if (req.type === /** @type {any} */ ('label-add')) {
940
+ if (req.type === 'label-add') {
713
941
  const { id, label } = /** @type {any} */ (req.payload || {});
714
942
  if (
715
943
  typeof id !== 'string' ||
@@ -744,7 +972,7 @@ export async function handleMessage(ws, data) {
744
972
  }
745
973
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
746
974
  try {
747
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
975
+ triggerMutationRefreshOnce();
748
976
  } catch {
749
977
  // ignore
750
978
  }
@@ -752,7 +980,7 @@ export async function handleMessage(ws, data) {
752
980
  }
753
981
 
754
982
  // label-remove: payload { id: string, label: string }
755
- if (req.type === /** @type {any} */ ('label-remove')) {
983
+ if (req.type === 'label-remove') {
756
984
  const { id, label } = /** @type {any} */ (req.payload || {});
757
985
  if (
758
986
  typeof id !== 'string' ||
@@ -787,7 +1015,7 @@ export async function handleMessage(ws, data) {
787
1015
  }
788
1016
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
789
1017
  try {
790
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
1018
+ triggerMutationRefreshOnce();
791
1019
  } catch {
792
1020
  // ignore
793
1021
  }