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.
- package/CHANGES.md +29 -2
- package/README.md +39 -45
- package/app/data/list-selectors.js +98 -0
- package/app/data/providers.js +25 -127
- package/app/data/sort.js +45 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +102 -0
- package/app/data/subscriptions-store.js +219 -0
- package/app/index.html +8 -0
- package/app/main.js +483 -61
- package/app/protocol.js +10 -14
- package/app/protocol.md +21 -19
- package/app/router.js +45 -9
- package/app/state.js +27 -11
- package/app/styles.css +373 -184
- package/app/utils/issue-id-renderer.js +71 -0
- package/app/utils/issue-url.js +9 -0
- package/app/utils/markdown.js +15 -194
- package/app/utils/priority-badge.js +0 -2
- package/app/utils/status-badge.js +0 -1
- package/app/utils/toast.js +34 -0
- package/app/utils/type-badge.js +0 -3
- package/app/views/board.js +439 -87
- package/app/views/detail.js +364 -154
- package/app/views/epics.js +128 -76
- package/app/views/issue-dialog.js +163 -0
- package/app/views/issue-row.js +10 -11
- package/app/views/list.js +164 -93
- package/app/views/new-issue-dialog.js +345 -0
- package/app/ws.js +36 -9
- package/bin/bdui.js +1 -1
- package/docs/adr/001-push-only-lists.md +134 -0
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
- package/docs/architecture.md +35 -85
- package/docs/data-exchange-subscription-plan.md +198 -0
- package/docs/db-watching.md +2 -1
- package/docs/migration-v2.md +54 -0
- package/docs/protocol/issues-push-v2.md +179 -0
- package/docs/subscription-issue-store.md +112 -0
- package/package.json +11 -3
- package/server/bd.js +0 -2
- package/server/cli/commands.js +12 -5
- package/server/cli/daemon.js +12 -5
- package/server/cli/index.js +34 -5
- package/server/cli/usage.js +2 -2
- package/server/config.js +12 -6
- package/server/db.js +0 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +218 -0
- package/server/subscriptions.js +277 -0
- package/server/validators.js +111 -0
- package/server/watcher.js +6 -9
- package/server/ws.js +466 -227
- 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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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,
|
|
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 {
|
|
185
|
+
* @returns {any}
|
|
28
186
|
*/
|
|
29
|
-
function
|
|
187
|
+
function ensureSubs(ws) {
|
|
30
188
|
let s = SUBS.get(ws);
|
|
31
189
|
if (!s) {
|
|
32
|
-
s = {
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
const recipients = new Set();
|
|
248
|
+
}
|
|
55
249
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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-
|
|
242
|
-
if (req.type === 'subscribe-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
|
548
|
+
// ignore snapshot errors
|
|
309
549
|
}
|
|
310
|
-
ws.send(JSON.stringify(makeOk(req,
|
|
550
|
+
ws.send(JSON.stringify(makeOk(req, { id: client_id, key })));
|
|
311
551
|
return;
|
|
312
552
|
}
|
|
313
553
|
|
|
314
|
-
//
|
|
315
|
-
if (req.type ===
|
|
316
|
-
const
|
|
317
|
-
if (
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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 ===
|
|
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
|
-
//
|
|
674
|
+
// After mutation, refresh active subscriptions once (watcher or timeout)
|
|
441
675
|
try {
|
|
442
|
-
|
|
676
|
+
triggerMutationRefreshOnce();
|
|
443
677
|
} catch {
|
|
444
|
-
// ignore
|
|
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
|
-
|
|
720
|
+
triggerMutationRefreshOnce();
|
|
487
721
|
} catch {
|
|
488
|
-
// ignore
|
|
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
|
-
|
|
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
|
-
|
|
783
|
+
triggerMutationRefreshOnce();
|
|
549
784
|
} catch {
|
|
550
|
-
// ignore
|
|
785
|
+
// ignore
|
|
551
786
|
}
|
|
552
787
|
return;
|
|
553
788
|
}
|
|
554
789
|
|
|
555
790
|
// create-issue
|
|
556
|
-
if (req.type ===
|
|
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
|
-
//
|
|
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 ===
|
|
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
|
-
|
|
640
|
-
notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
|
|
879
|
+
triggerMutationRefreshOnce();
|
|
641
880
|
} catch {
|
|
642
|
-
// ignore
|
|
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 ===
|
|
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
|
-
|
|
923
|
+
triggerMutationRefreshOnce();
|
|
685
924
|
} catch {
|
|
686
|
-
// ignore
|
|
925
|
+
// ignore
|
|
687
926
|
}
|
|
688
927
|
return;
|
|
689
928
|
}
|
|
690
929
|
|
|
691
930
|
// label-add: payload { id: string, label: string }
|
|
692
|
-
if (req.type ===
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
|
|
1009
|
+
triggerMutationRefreshOnce();
|
|
771
1010
|
} catch {
|
|
772
1011
|
// ignore
|
|
773
1012
|
}
|