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.
Files changed (98) hide show
  1. package/.beads/issues.jsonl +107 -0
  2. package/.editorconfig +10 -0
  3. package/.eslintrc.json +36 -0
  4. package/.github/workflows/ci.yml +38 -0
  5. package/.prettierignore +5 -0
  6. package/AGENTS.md +85 -0
  7. package/CHANGES.md +5 -0
  8. package/LICENSE +22 -0
  9. package/README.md +75 -0
  10. package/app/data/providers.js +178 -0
  11. package/app/data/providers.test.js +126 -0
  12. package/app/index.html +29 -0
  13. package/app/main.board-switch.test.js +94 -0
  14. package/app/main.deep-link.test.js +64 -0
  15. package/app/main.js +280 -0
  16. package/app/main.live-updates.test.js +229 -0
  17. package/app/main.test.js +17 -0
  18. package/app/main.theme.test.js +41 -0
  19. package/app/main.view-sync.test.js +54 -0
  20. package/app/protocol.js +200 -0
  21. package/app/protocol.md +64 -0
  22. package/app/protocol.test.js +57 -0
  23. package/app/router.js +78 -0
  24. package/app/router.test.js +34 -0
  25. package/app/state.js +87 -0
  26. package/app/state.test.js +21 -0
  27. package/app/styles.css +1343 -0
  28. package/app/utils/issue-id.js +10 -0
  29. package/app/utils/issue-type.js +27 -0
  30. package/app/utils/markdown.js +201 -0
  31. package/app/utils/markdown.test.js +103 -0
  32. package/app/utils/priority-badge.js +49 -0
  33. package/app/utils/priority.js +1 -0
  34. package/app/utils/status-badge.js +33 -0
  35. package/app/utils/status.js +23 -0
  36. package/app/utils/type-badge.js +36 -0
  37. package/app/utils/type-badge.test.js +30 -0
  38. package/app/views/board.js +183 -0
  39. package/app/views/board.test.js +184 -0
  40. package/app/views/detail.acceptance-notes.test.js +67 -0
  41. package/app/views/detail.assignee.test.js +161 -0
  42. package/app/views/detail.deps.test.js +97 -0
  43. package/app/views/detail.edits.test.js +146 -0
  44. package/app/views/detail.js +1039 -0
  45. package/app/views/detail.labels.test.js +73 -0
  46. package/app/views/detail.priority.test.js +86 -0
  47. package/app/views/detail.test.js +188 -0
  48. package/app/views/detail.ui47.test.js +78 -0
  49. package/app/views/epics.js +228 -0
  50. package/app/views/epics.test.js +283 -0
  51. package/app/views/issue-row.js +191 -0
  52. package/app/views/list.inline-edits.test.js +84 -0
  53. package/app/views/list.js +393 -0
  54. package/app/views/list.test.js +479 -0
  55. package/app/views/nav.js +67 -0
  56. package/app/views/nav.test.js +43 -0
  57. package/app/ws.js +252 -0
  58. package/app/ws.test.js +168 -0
  59. package/bin/bdui.js +18 -0
  60. package/docs/architecture.md +244 -0
  61. package/docs/db-watching.md +29 -0
  62. package/docs/quickstart.md +142 -0
  63. package/eslint.config.js +59 -0
  64. package/media/bdui-board.png +0 -0
  65. package/media/bdui-epics.png +0 -0
  66. package/media/bdui-issues.png +0 -0
  67. package/package.json +48 -0
  68. package/prettier.config.js +13 -0
  69. package/server/app.js +80 -0
  70. package/server/app.test.js +29 -0
  71. package/server/bd.js +125 -0
  72. package/server/bd.test.js +93 -0
  73. package/server/cli/cli.test.js +109 -0
  74. package/server/cli/commands.integration.test.js +155 -0
  75. package/server/cli/commands.js +91 -0
  76. package/server/cli/commands.unit.test.js +94 -0
  77. package/server/cli/daemon.js +239 -0
  78. package/server/cli/index.js +74 -0
  79. package/server/cli/open.js +96 -0
  80. package/server/cli/open.test.js +26 -0
  81. package/server/cli/usage.js +22 -0
  82. package/server/config.js +29 -0
  83. package/server/db.js +100 -0
  84. package/server/db.test.js +70 -0
  85. package/server/index.js +29 -0
  86. package/server/protocol.js +3 -0
  87. package/server/protocol.test.js +87 -0
  88. package/server/watcher.js +107 -0
  89. package/server/watcher.test.js +100 -0
  90. package/server/ws.handlers.test.js +174 -0
  91. package/server/ws.js +784 -0
  92. package/server/ws.labels.test.js +95 -0
  93. package/server/ws.mutations.test.js +261 -0
  94. package/server/ws.subscriptions.test.js +116 -0
  95. package/server/ws.test.js +52 -0
  96. package/test/setup-vitest.js +12 -0
  97. package/tsconfig.json +23 -0
  98. 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.