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.
- package/CHANGES.md +14 -0
- package/README.md +4 -4
- package/app/data/list-selectors.js +103 -0
- package/app/data/providers.js +7 -138
- package/app/data/sort.js +47 -0
- package/app/data/subscription-issue-store.js +161 -0
- package/app/data/subscription-issue-stores.js +128 -0
- package/app/data/subscriptions-store.js +227 -0
- package/app/main.js +346 -66
- package/app/protocol.js +23 -17
- package/app/protocol.md +18 -15
- package/app/router.js +3 -0
- package/app/state.js +2 -0
- package/app/styles.css +222 -197
- package/app/utils/issue-id-renderer.js +2 -1
- package/app/utils/issue-id.js +1 -0
- package/app/utils/issue-type.js +2 -0
- package/app/utils/issue-url.js +1 -0
- package/app/utils/markdown.js +13 -198
- package/app/utils/priority-badge.js +1 -2
- package/app/utils/status-badge.js +1 -1
- package/app/utils/status.js +2 -0
- package/app/utils/toast.js +1 -1
- package/app/utils/type-badge.js +1 -3
- package/app/views/board.js +172 -148
- package/app/views/detail.js +79 -66
- package/app/views/epics.js +127 -74
- package/app/views/issue-dialog.js +9 -15
- package/app/views/issue-row.js +2 -3
- package/app/views/list.js +105 -104
- package/app/views/nav.js +1 -0
- package/app/views/new-issue-dialog.js +30 -34
- package/app/ws.js +10 -10
- 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 +34 -84
- 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 +5 -4
- package/server/app.js +2 -0
- package/server/bd.js +4 -2
- package/server/cli/commands.js +5 -2
- package/server/cli/daemon.js +19 -5
- package/server/cli/index.js +2 -2
- package/server/cli/open.js +3 -0
- package/server/cli/usage.js +2 -1
- package/server/config.js +13 -6
- package/server/db.js +3 -1
- package/server/index.js +9 -5
- package/server/list-adapters.js +224 -0
- package/server/subscriptions.js +289 -0
- package/server/validators.js +113 -0
- package/server/watcher.js +8 -8
- 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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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,
|
|
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 {
|
|
188
|
+
* @returns {any}
|
|
28
189
|
*/
|
|
29
|
-
function
|
|
190
|
+
function ensureSubs(ws) {
|
|
30
191
|
let s = SUBS.get(ws);
|
|
31
192
|
if (!s) {
|
|
32
|
-
s = {
|
|
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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
const recipients = new Set();
|
|
252
|
+
}
|
|
55
253
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 (
|
|
73
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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-
|
|
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
|
-
// 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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
|
557
|
+
// ignore snapshot errors
|
|
328
558
|
}
|
|
329
|
-
ws.send(JSON.stringify(makeOk(req,
|
|
559
|
+
ws.send(JSON.stringify(makeOk(req, { id: client_id, key })));
|
|
330
560
|
return;
|
|
331
561
|
}
|
|
332
562
|
|
|
333
|
-
//
|
|
334
|
-
if (req.type ===
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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(
|
|
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 ===
|
|
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
|
-
//
|
|
683
|
+
// After mutation, refresh active subscriptions once (watcher or timeout)
|
|
460
684
|
try {
|
|
461
|
-
|
|
685
|
+
triggerMutationRefreshOnce();
|
|
462
686
|
} catch {
|
|
463
|
-
// ignore
|
|
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
|
-
|
|
729
|
+
triggerMutationRefreshOnce();
|
|
506
730
|
} catch {
|
|
507
|
-
// ignore
|
|
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
|
-
|
|
792
|
+
triggerMutationRefreshOnce();
|
|
569
793
|
} catch {
|
|
570
|
-
// ignore
|
|
794
|
+
// ignore
|
|
571
795
|
}
|
|
572
796
|
return;
|
|
573
797
|
}
|
|
574
798
|
|
|
575
799
|
// create-issue
|
|
576
|
-
if (req.type ===
|
|
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
|
-
//
|
|
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 ===
|
|
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
|
-
|
|
660
|
-
notifyIssuesChanged({ hint: { ids: [id] } }, { issue: shown.stdoutJson });
|
|
888
|
+
triggerMutationRefreshOnce();
|
|
661
889
|
} catch {
|
|
662
|
-
// ignore
|
|
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 ===
|
|
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
|
-
|
|
932
|
+
triggerMutationRefreshOnce();
|
|
705
933
|
} catch {
|
|
706
|
-
// ignore
|
|
934
|
+
// ignore
|
|
707
935
|
}
|
|
708
936
|
return;
|
|
709
937
|
}
|
|
710
938
|
|
|
711
939
|
// label-add: payload { id: string, label: string }
|
|
712
|
-
if (req.type ===
|
|
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
|
-
|
|
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 ===
|
|
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
|
-
|
|
1018
|
+
triggerMutationRefreshOnce();
|
|
791
1019
|
} catch {
|
|
792
1020
|
// ignore
|
|
793
1021
|
}
|