beads-ui 0.2.0 → 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 (48) hide show
  1. package/CHANGES.md +5 -0
  2. package/README.md +4 -4
  3. package/app/data/list-selectors.js +98 -0
  4. package/app/data/providers.js +5 -138
  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/main.js +342 -66
  10. package/app/protocol.js +10 -14
  11. package/app/protocol.md +18 -15
  12. package/app/styles.css +222 -197
  13. package/app/utils/markdown.js +15 -194
  14. package/app/utils/priority-badge.js +0 -2
  15. package/app/utils/status-badge.js +0 -1
  16. package/app/utils/toast.js +0 -1
  17. package/app/utils/type-badge.js +0 -3
  18. package/app/views/board.js +166 -144
  19. package/app/views/detail.js +76 -66
  20. package/app/views/epics.js +126 -74
  21. package/app/views/issue-dialog.js +8 -15
  22. package/app/views/issue-row.js +1 -3
  23. package/app/views/list.js +101 -104
  24. package/app/views/new-issue-dialog.js +27 -34
  25. package/app/ws.js +6 -9
  26. package/bin/bdui.js +1 -1
  27. package/docs/adr/001-push-only-lists.md +134 -0
  28. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  29. package/docs/architecture.md +34 -84
  30. package/docs/data-exchange-subscription-plan.md +198 -0
  31. package/docs/db-watching.md +2 -1
  32. package/docs/migration-v2.md +54 -0
  33. package/docs/protocol/issues-push-v2.md +179 -0
  34. package/docs/subscription-issue-store.md +112 -0
  35. package/package.json +4 -2
  36. package/server/bd.js +0 -2
  37. package/server/cli/commands.js +1 -2
  38. package/server/cli/daemon.js +12 -5
  39. package/server/cli/index.js +0 -2
  40. package/server/cli/usage.js +1 -1
  41. package/server/config.js +12 -6
  42. package/server/db.js +0 -1
  43. package/server/index.js +9 -5
  44. package/server/list-adapters.js +218 -0
  45. package/server/subscriptions.js +277 -0
  46. package/server/validators.js +111 -0
  47. package/server/watcher.js +5 -8
  48. package/server/ws.js +449 -230
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, blocked?: 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/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;
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,119 +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
- // 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));
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
+ );
308
528
  return;
309
529
  }
310
- // 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
311
537
  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
- }
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
+ });
326
547
  } catch {
327
- // ignore tracking errors
548
+ // ignore snapshot errors
328
549
  }
329
- ws.send(JSON.stringify(makeOk(req, res.stdoutJson)));
550
+ ws.send(JSON.stringify(makeOk(req, { id: client_id, key })));
330
551
  return;
331
552
  }
332
553
 
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) {
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) {
349
558
  ws.send(
350
559
  JSON.stringify(
351
560
  makeError(req, 'bad_request', 'payload.id must be a non-empty string')
@@ -353,37 +562,38 @@ export async function handleMessage(ws, data) {
353
562
  );
354
563
  return;
355
564
  }
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
378
- }
379
- 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
+ );
380
584
  return;
381
585
  }
382
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
+
383
593
  // type updates are not exposed via UI; no handler
384
594
 
385
595
  // update-assignee
386
- if (req.type === /** @type {any} */ ('update-assignee')) {
596
+ if (req.type === 'update-assignee') {
387
597
  const { id, assignee } = /** @type {any} */ (req.payload || {});
388
598
  if (
389
599
  typeof id !== 'string' ||
@@ -417,6 +627,11 @@ export async function handleMessage(ws, data) {
417
627
  return;
418
628
  }
419
629
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
630
+ try {
631
+ triggerMutationRefreshOnce();
632
+ } catch {
633
+ // ignore
634
+ }
420
635
  return;
421
636
  }
422
637
 
@@ -456,11 +671,11 @@ export async function handleMessage(ws, data) {
456
671
  return;
457
672
  }
458
673
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
459
- // Push targeted invalidation with updated issue context
674
+ // After mutation, refresh active subscriptions once (watcher or timeout)
460
675
  try {
461
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
676
+ triggerMutationRefreshOnce();
462
677
  } catch {
463
- // ignore fanout errors
678
+ // ignore
464
679
  }
465
680
  return;
466
681
  }
@@ -502,9 +717,9 @@ export async function handleMessage(ws, data) {
502
717
  }
503
718
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
504
719
  try {
505
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
720
+ triggerMutationRefreshOnce();
506
721
  } catch {
507
- // ignore fanout errors
722
+ // ignore
508
723
  }
509
724
  return;
510
725
  }
@@ -565,15 +780,15 @@ export async function handleMessage(ws, data) {
565
780
  }
566
781
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
567
782
  try {
568
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
783
+ triggerMutationRefreshOnce();
569
784
  } catch {
570
- // ignore fanout errors
785
+ // ignore
571
786
  }
572
787
  return;
573
788
  }
574
789
 
575
790
  // create-issue
576
- if (req.type === /** @type {any} */ ('create-issue')) {
791
+ if (req.type === 'create-issue') {
577
792
  const { title, type, priority, description } = /** @type {any} */ (
578
793
  req.payload || {}
579
794
  );
@@ -589,7 +804,6 @@ export async function handleMessage(ws, data) {
589
804
  );
590
805
  return;
591
806
  }
592
- /** @type {string[]} */
593
807
  const args = ['create', title];
594
808
  if (
595
809
  typeof type === 'string' &&
@@ -614,13 +828,19 @@ export async function handleMessage(ws, data) {
614
828
  );
615
829
  return;
616
830
  }
617
- // Rely on watcher to refresh clients; reply with a minimal ack
831
+ // Reply with a minimal ack
618
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
+ }
619
839
  return;
620
840
  }
621
841
 
622
842
  // dep-add: payload { a: string, b: string, view_id?: string }
623
- if (req.type === /** @type {any} */ ('dep-add')) {
843
+ if (req.type === 'dep-add') {
624
844
  const { a, b, view_id } = /** @type {any} */ (req.payload || {});
625
845
  if (
626
846
  typeof a !== 'string' ||
@@ -656,16 +876,15 @@ export async function handleMessage(ws, data) {
656
876
  }
657
877
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
658
878
  try {
659
- // Dependencies can affect readiness; conservatively target by issue id
660
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
879
+ triggerMutationRefreshOnce();
661
880
  } catch {
662
- // ignore fanout errors
881
+ // ignore
663
882
  }
664
883
  return;
665
884
  }
666
885
 
667
886
  // dep-remove: payload { a: string, b: string, view_id?: string }
668
- if (req.type === /** @type {any} */ ('dep-remove')) {
887
+ if (req.type === 'dep-remove') {
669
888
  const { a, b, view_id } = /** @type {any} */ (req.payload || {});
670
889
  if (
671
890
  typeof a !== 'string' ||
@@ -701,15 +920,15 @@ export async function handleMessage(ws, data) {
701
920
  }
702
921
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
703
922
  try {
704
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
923
+ triggerMutationRefreshOnce();
705
924
  } catch {
706
- // ignore fanout errors
925
+ // ignore
707
926
  }
708
927
  return;
709
928
  }
710
929
 
711
930
  // label-add: payload { id: string, label: string }
712
- if (req.type === /** @type {any} */ ('label-add')) {
931
+ if (req.type === 'label-add') {
713
932
  const { id, label } = /** @type {any} */ (req.payload || {});
714
933
  if (
715
934
  typeof id !== 'string' ||
@@ -744,7 +963,7 @@ export async function handleMessage(ws, data) {
744
963
  }
745
964
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
746
965
  try {
747
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
966
+ triggerMutationRefreshOnce();
748
967
  } catch {
749
968
  // ignore
750
969
  }
@@ -752,7 +971,7 @@ export async function handleMessage(ws, data) {
752
971
  }
753
972
 
754
973
  // label-remove: payload { id: string, label: string }
755
- if (req.type === /** @type {any} */ ('label-remove')) {
974
+ if (req.type === 'label-remove') {
756
975
  const { id, label } = /** @type {any} */ (req.payload || {});
757
976
  if (
758
977
  typeof id !== 'string' ||
@@ -787,7 +1006,7 @@ export async function handleMessage(ws, data) {
787
1006
  }
788
1007
  ws.send(JSON.stringify(makeOk(req, shown.stdoutJson)));
789
1008
  try {
790
- notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
1009
+ triggerMutationRefreshOnce();
791
1010
  } catch {
792
1011
  // ignore
793
1012
  }