beads-ui 0.1.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/.beads/issues.jsonl +107 -0
- package/.editorconfig +10 -0
- package/.eslintrc.json +36 -0
- package/.github/workflows/ci.yml +38 -0
- package/.prettierignore +5 -0
- package/AGENTS.md +85 -0
- package/CHANGES.md +5 -0
- package/LICENSE +22 -0
- package/README.md +75 -0
- package/app/data/providers.js +178 -0
- package/app/data/providers.test.js +126 -0
- package/app/index.html +29 -0
- package/app/main.board-switch.test.js +94 -0
- package/app/main.deep-link.test.js +64 -0
- package/app/main.js +280 -0
- package/app/main.live-updates.test.js +229 -0
- package/app/main.test.js +17 -0
- package/app/main.theme.test.js +41 -0
- package/app/main.view-sync.test.js +54 -0
- package/app/protocol.js +200 -0
- package/app/protocol.md +64 -0
- package/app/protocol.test.js +57 -0
- package/app/router.js +78 -0
- package/app/router.test.js +34 -0
- package/app/state.js +87 -0
- package/app/state.test.js +21 -0
- package/app/styles.css +1343 -0
- package/app/utils/issue-id.js +10 -0
- package/app/utils/issue-type.js +27 -0
- package/app/utils/markdown.js +201 -0
- package/app/utils/markdown.test.js +103 -0
- package/app/utils/priority-badge.js +49 -0
- package/app/utils/priority.js +1 -0
- package/app/utils/status-badge.js +33 -0
- package/app/utils/status.js +23 -0
- package/app/utils/type-badge.js +36 -0
- package/app/utils/type-badge.test.js +30 -0
- package/app/views/board.js +183 -0
- package/app/views/board.test.js +184 -0
- package/app/views/detail.acceptance-notes.test.js +67 -0
- package/app/views/detail.assignee.test.js +161 -0
- package/app/views/detail.deps.test.js +97 -0
- package/app/views/detail.edits.test.js +146 -0
- package/app/views/detail.js +1039 -0
- package/app/views/detail.labels.test.js +73 -0
- package/app/views/detail.priority.test.js +86 -0
- package/app/views/detail.test.js +188 -0
- package/app/views/detail.ui47.test.js +78 -0
- package/app/views/epics.js +228 -0
- package/app/views/epics.test.js +283 -0
- package/app/views/issue-row.js +191 -0
- package/app/views/list.inline-edits.test.js +84 -0
- package/app/views/list.js +393 -0
- package/app/views/list.test.js +479 -0
- package/app/views/nav.js +67 -0
- package/app/views/nav.test.js +43 -0
- package/app/ws.js +252 -0
- package/app/ws.test.js +168 -0
- package/bin/bdui.js +18 -0
- package/docs/architecture.md +244 -0
- package/docs/db-watching.md +29 -0
- package/docs/quickstart.md +142 -0
- package/eslint.config.js +59 -0
- package/media/bdui-board.png +0 -0
- package/media/bdui-epics.png +0 -0
- package/media/bdui-issues.png +0 -0
- package/package.json +48 -0
- package/prettier.config.js +13 -0
- package/server/app.js +80 -0
- package/server/app.test.js +29 -0
- package/server/bd.js +125 -0
- package/server/bd.test.js +93 -0
- package/server/cli/cli.test.js +109 -0
- package/server/cli/commands.integration.test.js +155 -0
- package/server/cli/commands.js +91 -0
- package/server/cli/commands.unit.test.js +94 -0
- package/server/cli/daemon.js +239 -0
- package/server/cli/index.js +74 -0
- package/server/cli/open.js +96 -0
- package/server/cli/open.test.js +26 -0
- package/server/cli/usage.js +22 -0
- package/server/config.js +29 -0
- package/server/db.js +100 -0
- package/server/db.test.js +70 -0
- package/server/index.js +29 -0
- package/server/protocol.js +3 -0
- package/server/protocol.test.js +87 -0
- package/server/watcher.js +107 -0
- package/server/watcher.test.js +100 -0
- package/server/ws.handlers.test.js +174 -0
- package/server/ws.js +784 -0
- package/server/ws.labels.test.js +95 -0
- package/server/ws.mutations.test.js +261 -0
- package/server/ws.subscriptions.test.js +116 -0
- package/server/ws.test.js +52 -0
- package/test/setup-vitest.js +12 -0
- package/tsconfig.json +23 -0
- package/vitest.config.mjs +14 -0
package/app/ws.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/* global Console */
|
|
2
|
+
/**
|
|
3
|
+
* @import { MessageType } from './protocol.js'
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Persistent WebSocket client with reconnect, request/response correlation,
|
|
7
|
+
* and simple event dispatching.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const ws = createWsClient();
|
|
11
|
+
* const data = await ws.send('list-issues', { filters: {} });
|
|
12
|
+
* const off = ws.on('issues-changed', (payload) => { ... });
|
|
13
|
+
*/
|
|
14
|
+
import { MESSAGE_TYPES, makeRequest, nextId } from './protocol.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {'connecting'|'open'|'closed'|'reconnecting'} ConnectionState
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{ initialMs?: number, maxMs?: number, factor?: number, jitterRatio?: number }} BackoffOptions
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {{ url?: string, backoff?: BackoffOptions, logger?: Console }} ClientOptions
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create a WebSocket client with auto-reconnect and message correlation.
|
|
30
|
+
* @param {ClientOptions} [options]
|
|
31
|
+
*/
|
|
32
|
+
export function createWsClient(options = {}) {
|
|
33
|
+
/** @type {Console} */
|
|
34
|
+
const logger = options.logger || console;
|
|
35
|
+
|
|
36
|
+
/** @type {BackoffOptions} */
|
|
37
|
+
const backoff = {
|
|
38
|
+
initialMs: options.backoff?.initialMs ?? 1000,
|
|
39
|
+
maxMs: options.backoff?.maxMs ?? 30000,
|
|
40
|
+
factor: options.backoff?.factor ?? 2,
|
|
41
|
+
jitterRatio: options.backoff?.jitterRatio ?? 0.2
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** @type {() => string} */
|
|
45
|
+
const resolveUrl = () => {
|
|
46
|
+
if (options.url && options.url.length > 0) {
|
|
47
|
+
return options.url;
|
|
48
|
+
}
|
|
49
|
+
if (typeof location !== 'undefined') {
|
|
50
|
+
return (
|
|
51
|
+
(location.protocol === 'https:' ? 'wss://' : 'ws://') +
|
|
52
|
+
location.host +
|
|
53
|
+
'/ws'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return 'ws://localhost/ws';
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** @type {WebSocket | null} */
|
|
60
|
+
let ws = null;
|
|
61
|
+
/** @type {ConnectionState} */
|
|
62
|
+
let state = 'closed';
|
|
63
|
+
/** @type {number} */
|
|
64
|
+
let attempts = 0;
|
|
65
|
+
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
66
|
+
let reconnect_timer = null;
|
|
67
|
+
/** @type {boolean} */
|
|
68
|
+
let should_reconnect = true;
|
|
69
|
+
|
|
70
|
+
/** @type {Map<string, { resolve: (v: any) => void, reject: (e: any) => void, type: string }>} */
|
|
71
|
+
const pending = new Map();
|
|
72
|
+
/** @type {Array<ReturnType<typeof makeRequest>>} */
|
|
73
|
+
const queue = [];
|
|
74
|
+
/** @type {Map<string, Set<(payload: any) => void>>} */
|
|
75
|
+
const handlers = new Map();
|
|
76
|
+
|
|
77
|
+
function scheduleReconnect() {
|
|
78
|
+
if (!should_reconnect || reconnect_timer) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
state = 'reconnecting';
|
|
82
|
+
const base = Math.min(
|
|
83
|
+
backoff.maxMs || 0,
|
|
84
|
+
(backoff.initialMs || 0) * Math.pow(backoff.factor || 1, attempts)
|
|
85
|
+
);
|
|
86
|
+
const jitter = (backoff.jitterRatio || 0) * base;
|
|
87
|
+
const delay = Math.max(
|
|
88
|
+
0,
|
|
89
|
+
Math.round(base + (Math.random() * 2 - 1) * jitter)
|
|
90
|
+
);
|
|
91
|
+
reconnect_timer = setTimeout(() => {
|
|
92
|
+
reconnect_timer = null;
|
|
93
|
+
connect();
|
|
94
|
+
}, delay);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {ReturnType<typeof makeRequest>} req */
|
|
98
|
+
function sendRaw(req) {
|
|
99
|
+
try {
|
|
100
|
+
ws?.send(JSON.stringify(req));
|
|
101
|
+
} catch (err) {
|
|
102
|
+
logger.error('ws send failed', err);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function onOpen() {
|
|
107
|
+
state = 'open';
|
|
108
|
+
attempts = 0;
|
|
109
|
+
// subscribe first
|
|
110
|
+
sendRaw(makeRequest('subscribe-updates', {}));
|
|
111
|
+
// flush queue
|
|
112
|
+
while (queue.length) {
|
|
113
|
+
const req = queue.shift();
|
|
114
|
+
if (req) {
|
|
115
|
+
sendRaw(req);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @param {MessageEvent} ev */
|
|
121
|
+
function onMessage(ev) {
|
|
122
|
+
/** @type {any} */
|
|
123
|
+
let msg;
|
|
124
|
+
try {
|
|
125
|
+
msg = JSON.parse(String(ev.data));
|
|
126
|
+
} catch {
|
|
127
|
+
logger.warn('ws received non-JSON message');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!msg || typeof msg.id !== 'string' || typeof msg.type !== 'string') {
|
|
131
|
+
logger.warn('ws received invalid envelope');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (pending.has(msg.id)) {
|
|
136
|
+
const entry = pending.get(msg.id);
|
|
137
|
+
pending.delete(msg.id);
|
|
138
|
+
if (msg.ok) {
|
|
139
|
+
entry?.resolve(msg.payload);
|
|
140
|
+
} else {
|
|
141
|
+
entry?.reject(msg.error || new Error('ws error'));
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Treat as server-initiated event
|
|
147
|
+
const set = handlers.get(msg.type);
|
|
148
|
+
if (set && set.size > 0) {
|
|
149
|
+
for (const fn of Array.from(set)) {
|
|
150
|
+
try {
|
|
151
|
+
fn(msg.payload);
|
|
152
|
+
} catch (err) {
|
|
153
|
+
logger.error('ws event handler error', err);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else {
|
|
157
|
+
logger.warn(`ws received unhandled message type: ${msg.type}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function onClose() {
|
|
162
|
+
state = 'closed';
|
|
163
|
+
// fail all pending
|
|
164
|
+
for (const [id, p] of pending.entries()) {
|
|
165
|
+
p.reject(new Error('ws disconnected'));
|
|
166
|
+
pending.delete(id);
|
|
167
|
+
}
|
|
168
|
+
attempts += 1;
|
|
169
|
+
scheduleReconnect();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function connect() {
|
|
173
|
+
if (!should_reconnect) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const url = resolveUrl();
|
|
177
|
+
try {
|
|
178
|
+
ws = /** @type {any} */ (new WebSocket(url));
|
|
179
|
+
state = 'connecting';
|
|
180
|
+
const s = /** @type {any} */ (ws);
|
|
181
|
+
s.addEventListener('open', onOpen);
|
|
182
|
+
s.addEventListener('message', onMessage);
|
|
183
|
+
s.addEventListener('error', () => {
|
|
184
|
+
// let close handler handle reconnect
|
|
185
|
+
});
|
|
186
|
+
s.addEventListener('close', onClose);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
logger.error('ws connect failed', err);
|
|
189
|
+
scheduleReconnect();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
connect();
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
/**
|
|
197
|
+
* Send a request and await its correlated reply payload.
|
|
198
|
+
* @param {MessageType} type
|
|
199
|
+
* @param {unknown} [payload]
|
|
200
|
+
* @returns {Promise<any>}
|
|
201
|
+
*/
|
|
202
|
+
send(type, payload) {
|
|
203
|
+
if (!MESSAGE_TYPES.includes(type)) {
|
|
204
|
+
return Promise.reject(new Error(`unknown message type: ${type}`));
|
|
205
|
+
}
|
|
206
|
+
const id = nextId();
|
|
207
|
+
const req = makeRequest(type, payload, id);
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
pending.set(id, { resolve, reject, type });
|
|
210
|
+
if (ws && ws.readyState === ws.OPEN) {
|
|
211
|
+
sendRaw(req);
|
|
212
|
+
} else {
|
|
213
|
+
queue.push(req);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
},
|
|
217
|
+
/**
|
|
218
|
+
* Register a handler for a server-initiated event type.
|
|
219
|
+
* Returns an unsubscribe function.
|
|
220
|
+
* @param {MessageType} type
|
|
221
|
+
* @param {(payload: any) => void} handler
|
|
222
|
+
* @returns {() => void}
|
|
223
|
+
*/
|
|
224
|
+
on(type, handler) {
|
|
225
|
+
if (!handlers.has(type)) {
|
|
226
|
+
handlers.set(type, new Set());
|
|
227
|
+
}
|
|
228
|
+
const set = handlers.get(type);
|
|
229
|
+
set?.add(handler);
|
|
230
|
+
return () => {
|
|
231
|
+
set?.delete(handler);
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
/** Close and stop reconnecting. */
|
|
235
|
+
close() {
|
|
236
|
+
should_reconnect = false;
|
|
237
|
+
if (reconnect_timer) {
|
|
238
|
+
clearTimeout(reconnect_timer);
|
|
239
|
+
reconnect_timer = null;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
ws?.close();
|
|
243
|
+
} catch {
|
|
244
|
+
/* ignore */
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
/** For diagnostics in tests or UI. */
|
|
248
|
+
getState() {
|
|
249
|
+
return state;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
}
|
package/app/ws.test.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
2
|
+
import { createWsClient } from './ws.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @returns {any[]}
|
|
6
|
+
*/
|
|
7
|
+
function setupFakeWebSocket() {
|
|
8
|
+
/** @type {any[]} */
|
|
9
|
+
const sockets = [];
|
|
10
|
+
class FakeWebSocket {
|
|
11
|
+
/** @param {string} url */
|
|
12
|
+
constructor(url) {
|
|
13
|
+
this.url = url;
|
|
14
|
+
this.readyState = 0; // CONNECTING
|
|
15
|
+
this.OPEN = 1;
|
|
16
|
+
this.CLOSING = 2;
|
|
17
|
+
this.CLOSED = 3;
|
|
18
|
+
/** @type {{ open: Array<(ev:any)=>void>, message: Array<(ev:any)=>void>, error: Array<(ev:any)=>void>, close: Array<(ev:any)=>void> }} */
|
|
19
|
+
this._listeners = { open: [], message: [], error: [], close: [] };
|
|
20
|
+
/** @type {string[]} */
|
|
21
|
+
this.sent = [];
|
|
22
|
+
sockets.push(this);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* @param {'open'|'message'|'error'|'close'} type
|
|
26
|
+
* @param {(ev:any)=>void} fn
|
|
27
|
+
*/
|
|
28
|
+
addEventListener(type, fn) {
|
|
29
|
+
this._listeners[type].push(fn);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* @param {'open'|'message'|'error'|'close'} type
|
|
33
|
+
* @param {(ev:any)=>void} fn
|
|
34
|
+
*/
|
|
35
|
+
removeEventListener(type, fn) {
|
|
36
|
+
const a = this._listeners[type];
|
|
37
|
+
const i = a.indexOf(fn);
|
|
38
|
+
if (i !== -1) {
|
|
39
|
+
a.splice(i, 1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* @param {'open'|'message'|'error'|'close'} type
|
|
44
|
+
* @param {any} ev
|
|
45
|
+
*/
|
|
46
|
+
_dispatch(type, ev) {
|
|
47
|
+
for (const fn of this._listeners[type]) {
|
|
48
|
+
try {
|
|
49
|
+
fn(ev);
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
openNow() {
|
|
56
|
+
this.readyState = this.OPEN;
|
|
57
|
+
this._dispatch('open', {});
|
|
58
|
+
}
|
|
59
|
+
/** @param {string} data */
|
|
60
|
+
send(data) {
|
|
61
|
+
this.sent.push(String(data));
|
|
62
|
+
}
|
|
63
|
+
/** @param {any} obj */
|
|
64
|
+
emitMessage(obj) {
|
|
65
|
+
this._dispatch('message', { data: JSON.stringify(obj) });
|
|
66
|
+
}
|
|
67
|
+
close() {
|
|
68
|
+
this.readyState = this.CLOSED;
|
|
69
|
+
this._dispatch('close', {});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
vi.stubGlobal('WebSocket', FakeWebSocket);
|
|
73
|
+
return sockets;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('app/ws client', () => {
|
|
77
|
+
test('correlates replies for concurrent sends', async () => {
|
|
78
|
+
const sockets = setupFakeWebSocket();
|
|
79
|
+
const client = createWsClient({
|
|
80
|
+
backoff: { initialMs: 5, maxMs: 5, jitterRatio: 0 }
|
|
81
|
+
});
|
|
82
|
+
// open connection
|
|
83
|
+
sockets[0].openNow();
|
|
84
|
+
|
|
85
|
+
const p1 = client.send('list-issues', { filters: {} });
|
|
86
|
+
const p2 = client.send('show-issue', { id: 'UI-1' });
|
|
87
|
+
|
|
88
|
+
// Parse the last two frames to extract ids
|
|
89
|
+
const frames = sockets[0].sent
|
|
90
|
+
.slice(-2)
|
|
91
|
+
.map((/** @type {string} */ s) => JSON.parse(s));
|
|
92
|
+
const id1 = frames[0].id;
|
|
93
|
+
const id2 = frames[1].id;
|
|
94
|
+
|
|
95
|
+
// Reply out of order
|
|
96
|
+
sockets[0].emitMessage({
|
|
97
|
+
id: id2,
|
|
98
|
+
ok: true,
|
|
99
|
+
type: 'show-issue',
|
|
100
|
+
payload: { id: 'UI-1' }
|
|
101
|
+
});
|
|
102
|
+
sockets[0].emitMessage({
|
|
103
|
+
id: id1,
|
|
104
|
+
ok: true,
|
|
105
|
+
type: 'list-issues',
|
|
106
|
+
payload: [{ id: 'UI-1' }]
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await expect(p2).resolves.toEqual({ id: 'UI-1' });
|
|
110
|
+
await expect(p1).resolves.toEqual([{ id: 'UI-1' }]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('reconnects and resubscribes after close', async () => {
|
|
114
|
+
vi.useFakeTimers();
|
|
115
|
+
const sockets = setupFakeWebSocket();
|
|
116
|
+
const client = createWsClient({
|
|
117
|
+
backoff: { initialMs: 10, maxMs: 10, jitterRatio: 0 }
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// First connection opens
|
|
121
|
+
sockets[0].openNow();
|
|
122
|
+
// subscribe-updates should be first frame
|
|
123
|
+
const firstFrame = JSON.parse(sockets[0].sent[0]);
|
|
124
|
+
expect(firstFrame.type).toBe('subscribe-updates');
|
|
125
|
+
|
|
126
|
+
// Close the socket to trigger reconnect
|
|
127
|
+
sockets[0].close();
|
|
128
|
+
// Advance timers for reconnect
|
|
129
|
+
await vi.advanceTimersByTimeAsync(10);
|
|
130
|
+
|
|
131
|
+
// Second socket should exist and open
|
|
132
|
+
expect(sockets.length).toBeGreaterThan(1);
|
|
133
|
+
sockets[1].openNow();
|
|
134
|
+
const sub = JSON.parse(sockets[1].sent[0]);
|
|
135
|
+
expect(sub.type).toBe('subscribe-updates');
|
|
136
|
+
|
|
137
|
+
vi.useRealTimers();
|
|
138
|
+
client.close();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('dispatches server events and logs unknown types', async () => {
|
|
142
|
+
const sockets = setupFakeWebSocket();
|
|
143
|
+
const logger = { ...console, warn: vi.fn(), error: vi.fn() };
|
|
144
|
+
const client = createWsClient({ logger });
|
|
145
|
+
sockets[0].openNow();
|
|
146
|
+
|
|
147
|
+
/** @type {any[]} */
|
|
148
|
+
const events = [];
|
|
149
|
+
client.on('issues-changed', (p) => events.push(p));
|
|
150
|
+
sockets[0].emitMessage({
|
|
151
|
+
id: 'evt-1',
|
|
152
|
+
ok: true,
|
|
153
|
+
type: 'issues-changed',
|
|
154
|
+
payload: { ids: ['UI-1'] }
|
|
155
|
+
});
|
|
156
|
+
expect(events).toEqual([{ ids: ['UI-1'] }]);
|
|
157
|
+
|
|
158
|
+
// No handler registered for create-issue -> warn
|
|
159
|
+
sockets[0].emitMessage({
|
|
160
|
+
id: 'evt-2',
|
|
161
|
+
ok: true,
|
|
162
|
+
type: 'create-issue',
|
|
163
|
+
payload: {}
|
|
164
|
+
});
|
|
165
|
+
expect(logger.warn).toHaveBeenCalled();
|
|
166
|
+
client.close();
|
|
167
|
+
});
|
|
168
|
+
});
|
package/bin/bdui.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Thin CLI entry for `bdui`.
|
|
4
|
+
* Delegates to `server/cli/index.js` and sets the process exit code.
|
|
5
|
+
*/
|
|
6
|
+
import { main } from '../server/cli/index.js';
|
|
7
|
+
|
|
8
|
+
const argv = process.argv.slice(2);
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const code = await main(argv);
|
|
12
|
+
if (Number.isFinite(code)) {
|
|
13
|
+
process.exitCode = /** @type {number} */ (code);
|
|
14
|
+
}
|
|
15
|
+
} catch (err) {
|
|
16
|
+
console.error(String(/** @type {any} */ (err)?.message || err));
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# beads-ui Architecture and Protocol (v1)
|
|
2
|
+
|
|
3
|
+
This document describes the high‑level architecture of beads‑ui and the v1
|
|
4
|
+
WebSocket protocol used between the browser SPA and the local Node.js server.
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
|
|
8
|
+
- Local‑first single‑page app served by a localhost HTTP server
|
|
9
|
+
- WebSocket for data (request/response + server push events)
|
|
10
|
+
- Server bridges UI intents to the `bd` CLI and watches the active beads
|
|
11
|
+
database for changes
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
+--------------+ ws://127.0.0.1:PORT/ws +--------------------+
|
|
15
|
+
| Browser SPA | <--------------------------------------> | HTTP + WS Server |
|
|
16
|
+
| (ESM, DOM) | requests (JSON) / replies + events | (Node.js, ESM) |
|
|
17
|
+
+--------------+ +---------+----------+
|
|
18
|
+
^ |
|
|
19
|
+
| v
|
|
20
|
+
| +----+-----+
|
|
21
|
+
| | bd |
|
|
22
|
+
| | (CLI) |
|
|
23
|
+
| +----+-----+
|
|
24
|
+
| |
|
|
25
|
+
| watches v
|
|
26
|
+
|------------------------------------ changes --------> [ SQLite DB ]
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Components and Responsibilities
|
|
30
|
+
|
|
31
|
+
- UI (app/)
|
|
32
|
+
- `app/main.js`: bootstraps shell, creates store/router, wires WS client,
|
|
33
|
+
refreshes on push
|
|
34
|
+
- Views: `app/views/list.js`, `app/views/detail.js` render issues and allow
|
|
35
|
+
edits
|
|
36
|
+
- Transport: `app/ws.js` persistent client with reconnect, correlation, and
|
|
37
|
+
event dispatcher
|
|
38
|
+
- Protocol: `app/protocol.js` shared message shapes, version, helpers, and
|
|
39
|
+
type guards
|
|
40
|
+
|
|
41
|
+
- Server (server/)
|
|
42
|
+
- Web: `server/app.js` (Express app), `server/index.js` (startup and wiring)
|
|
43
|
+
- WebSocket: `server/ws.js` (attach server, parse, validate, dispatch
|
|
44
|
+
handlers, broadcast events)
|
|
45
|
+
- bd bridge: `server/bd.js` (spawn `bd`, inject `--db` consistently, JSON
|
|
46
|
+
helpers)
|
|
47
|
+
- DB resolution/watch: `server/db.js` (resolve active DB path),
|
|
48
|
+
`server/watcher.js` (emit `issues-changed`)
|
|
49
|
+
- Config: `server/config.js` (bind to `127.0.0.1`, default port 3000)
|
|
50
|
+
|
|
51
|
+
## Data Flow
|
|
52
|
+
|
|
53
|
+
1. User action in the UI creates a request `{ id, type, payload }` via
|
|
54
|
+
`app/ws.js`.
|
|
55
|
+
2. Server validates and maps the request to a `bd` command (no shell; args array
|
|
56
|
+
only).
|
|
57
|
+
3. Server replies with `{ id, ok, type, payload }` or `{ id, ok:false, error }`.
|
|
58
|
+
4. Independent of requests, the DB watcher sends `issues-changed` events to all
|
|
59
|
+
clients.
|
|
60
|
+
|
|
61
|
+
## Protocol (v1.0.0)
|
|
62
|
+
|
|
63
|
+
Envelope shapes (see `app/protocol.js` for the source of truth):
|
|
64
|
+
|
|
65
|
+
- Request: `{ id: string, type: MessageType, payload?: any }`
|
|
66
|
+
- Reply:
|
|
67
|
+
`{ id: string, ok: boolean, type: MessageType, payload?: any, error?: { code, message, details? } }`
|
|
68
|
+
|
|
69
|
+
Message types implemented by the server today:
|
|
70
|
+
|
|
71
|
+
- `list-issues` payload: `{ filters?: { status?: string, priority?: number } }`
|
|
72
|
+
- `show-issue` payload: `{ id: string }`
|
|
73
|
+
- `update-status` payload:
|
|
74
|
+
`{ id: string, status: 'open'|'in_progress'|'closed' }`
|
|
75
|
+
- `edit-text` payload:
|
|
76
|
+
`{ id: string, field: 'title'|'description'|'acceptance', value: string }`
|
|
77
|
+
- `update-priority` payload: `{ id: string, priority: 0|1|2|3|4 }`
|
|
78
|
+
- `dep-add` payload: `{ a: string, b: string, view_id?: string }`
|
|
79
|
+
- `dep-remove` payload: `{ a: string, b: string, view_id?: string }`
|
|
80
|
+
- `issues-changed` (server push) payload:
|
|
81
|
+
`{ ts: number, hint?: { ids?: string[] } }`
|
|
82
|
+
|
|
83
|
+
Defined in the spec but not yet handled on the server:
|
|
84
|
+
|
|
85
|
+
- `create-issue`, `list-ready`, `subscribe-updates` (client sends on connect;
|
|
86
|
+
ignored safely)
|
|
87
|
+
|
|
88
|
+
### Examples
|
|
89
|
+
|
|
90
|
+
List issues
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"id": "r1",
|
|
95
|
+
"type": "list-issues",
|
|
96
|
+
"payload": { "filters": { "status": "open" } }
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Reply
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"id": "r1",
|
|
105
|
+
"ok": true,
|
|
106
|
+
"type": "list-issues",
|
|
107
|
+
"payload": [{ "id": "UI-1", "title": "..." }]
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Update status
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"id": "r2",
|
|
116
|
+
"type": "update-status",
|
|
117
|
+
"payload": { "id": "UI-1", "status": "in_progress" }
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Server push (watcher)
|
|
122
|
+
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"id": "evt-1732212345000",
|
|
126
|
+
"ok": true,
|
|
127
|
+
"type": "issues-changed",
|
|
128
|
+
"payload": { "ts": 1732212345000 }
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Error reply
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"id": "r3",
|
|
137
|
+
"ok": false,
|
|
138
|
+
"type": "show-issue",
|
|
139
|
+
"error": { "code": "not_found", "message": "Issue UI-99" }
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## UI → bd Command Mapping
|
|
144
|
+
|
|
145
|
+
- List: `bd list --json [--status <s>] [--priority <n>]`
|
|
146
|
+
- Show: `bd show <id> --json`
|
|
147
|
+
- Update status: `bd update <id> --status <open|in_progress|closed>`
|
|
148
|
+
- Update priority: `bd update <id> --priority <0..4>`
|
|
149
|
+
- Edit title: `bd update <id> --title <text>`
|
|
150
|
+
- Edit description: not supported (immutable after create)
|
|
151
|
+
- Edit acceptance: `bd update <id> --acceptance-criteria <text>`
|
|
152
|
+
- Link dependency: `bd dep add <a> <b>` (a depends on b)
|
|
153
|
+
- Unlink dependency: `bd dep remove <a> <b>`
|
|
154
|
+
- Planned (UI not wired yet): Create:
|
|
155
|
+
`bd create "title" -t <type> -p <prio> -d "desc"`; Ready list:
|
|
156
|
+
`bd ready --json`
|
|
157
|
+
|
|
158
|
+
Rationale
|
|
159
|
+
|
|
160
|
+
- Use `--json` for read commands to ensure typed payloads.
|
|
161
|
+
- Avoid shell invocation; pass args array to `spawn` to prevent injection.
|
|
162
|
+
- Always inject a resolved `--db <path>` so watcher and CLI operate on the same
|
|
163
|
+
database.
|
|
164
|
+
|
|
165
|
+
## Issue Data Model (wire)
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
interface Issue {
|
|
169
|
+
id: string;
|
|
170
|
+
title?: string;
|
|
171
|
+
description?: string;
|
|
172
|
+
acceptance?: string;
|
|
173
|
+
status?: 'open' | 'in_progress' | 'closed';
|
|
174
|
+
priority?: 0 | 1 | 2 | 3 | 4;
|
|
175
|
+
dependencies?: Array<{
|
|
176
|
+
id: string;
|
|
177
|
+
title?: string;
|
|
178
|
+
status?: string;
|
|
179
|
+
priority?: number;
|
|
180
|
+
issue_type?: string;
|
|
181
|
+
}>;
|
|
182
|
+
dependents?: Array<{
|
|
183
|
+
id: string;
|
|
184
|
+
title?: string;
|
|
185
|
+
status?: string;
|
|
186
|
+
priority?: number;
|
|
187
|
+
issue_type?: string;
|
|
188
|
+
}>;
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Notes
|
|
193
|
+
|
|
194
|
+
- Fields are optional to allow partial views and forward compatibility.
|
|
195
|
+
- Additional properties may appear; clients should ignore unknown keys.
|
|
196
|
+
|
|
197
|
+
## Error Model and Versioning
|
|
198
|
+
|
|
199
|
+
- Error object: `{ code: string, message: string, details?: any }`
|
|
200
|
+
- Common codes: `bad_request`, `not_found`, `bd_error`, `unknown_type`,
|
|
201
|
+
`bad_json`
|
|
202
|
+
- Versioning: `PROTOCOL_VERSION` in `app/protocol.js` (currently `1.0.0`).
|
|
203
|
+
Breaking changes increment this value; additive message types are backwards
|
|
204
|
+
compatible.
|
|
205
|
+
|
|
206
|
+
## Security and Local Boundaries
|
|
207
|
+
|
|
208
|
+
- Server binds to `127.0.0.1` by default to keep traffic local.
|
|
209
|
+
- Basic input validation at the WS boundary; unknown or malformed messages
|
|
210
|
+
produce structured errors.
|
|
211
|
+
- No shell usage; `spawn` with args only; environment opt‑in via `BD_BIN`.
|
|
212
|
+
|
|
213
|
+
## Watcher Design
|
|
214
|
+
|
|
215
|
+
- The server resolves the active beads SQLite DB path (see
|
|
216
|
+
`docs/db-watching.md`).
|
|
217
|
+
- File watcher emits `issues-changed` events with a timestamp; UI refreshes
|
|
218
|
+
list/detail as needed.
|
|
219
|
+
|
|
220
|
+
## Risks & Open Questions
|
|
221
|
+
|
|
222
|
+
- Create flow not implemented in server handlers
|
|
223
|
+
- Owner: Server
|
|
224
|
+
- Next: Add `create-issue` handler and tests; wire minimal UI affordance
|
|
225
|
+
- Ready list support missing end‑to‑end
|
|
226
|
+
- Owner: Server + UI
|
|
227
|
+
- Next: Add `list-ready` handler and a list filter in UI
|
|
228
|
+
- Backpressure when many updates arrive
|
|
229
|
+
- Owner: Server
|
|
230
|
+
- Next: Coalesce broadcast events; consider debounce window
|
|
231
|
+
- Large databases and payload size
|
|
232
|
+
- Owner: UI
|
|
233
|
+
- Next: Add incremental refresh (fetch issue by id on hints)
|
|
234
|
+
- Cross‑platform DB path resolution nuances
|
|
235
|
+
- Owner: Server
|
|
236
|
+
- Next: Expand tests for Windows/macOS/Linux; document overrides
|
|
237
|
+
- Acceptance text editing
|
|
238
|
+
- Owner: UI + Server
|
|
239
|
+
- Status: Implemented via `edit-text` + `--acceptance-criteria`
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
For the normative protocol reference and unit tests, see `app/protocol.md` and
|
|
244
|
+
`app/protocol.test.js`.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# DB Watching and Resolution
|
|
2
|
+
|
|
3
|
+
The server watches the active beads SQLite database file for changes and
|
|
4
|
+
broadcasts an `issues-changed` event to connected clients.
|
|
5
|
+
|
|
6
|
+
## Resolution Order
|
|
7
|
+
|
|
8
|
+
The DB path is resolved to match beads CLI precedence:
|
|
9
|
+
|
|
10
|
+
1. `--db <path>` flag (when forced by the server configuration)
|
|
11
|
+
2. `BEADS_DB` environment variable
|
|
12
|
+
3. Nearest `.beads/*.db` by walking up from the server `root_dir`
|
|
13
|
+
4. `~/.beads/default.db` fallback
|
|
14
|
+
|
|
15
|
+
The resolved path is injected into all `bd` CLI invocations via `--db` to ensure
|
|
16
|
+
the watcher and CLI operate on the same database.
|
|
17
|
+
|
|
18
|
+
## Behavior When Missing
|
|
19
|
+
|
|
20
|
+
If no database exists at the resolved path (e.g., before `bd init`), the server
|
|
21
|
+
will still attempt to bind a watcher on the containing directory and log a clear
|
|
22
|
+
warning. Initialize a database with one of:
|
|
23
|
+
|
|
24
|
+
- `bd --db /path/to/file.db init`
|
|
25
|
+
- `export BEADS_DB=/path/to/file.db && bd init`
|
|
26
|
+
- `bd init` in a workspace with a `.beads/` directory
|
|
27
|
+
|
|
28
|
+
After initialization, changes will be detected without restarting the server.
|
|
29
|
+
The watcher can rebind when the workspace or configuration changes at runtime.
|