beads-ui 0.3.1 → 0.4.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 +24 -0
- package/README.md +15 -6
- package/app/main.bundle.js +617 -0
- package/app/main.bundle.js.map +7 -0
- package/bin/bdui.js +2 -1
- package/package.json +27 -14
- package/server/app.js +38 -36
- package/server/bd.js +2 -2
- package/server/cli/commands.js +8 -8
- package/server/cli/daemon.js +13 -5
- package/server/cli/index.js +17 -31
- package/server/cli/usage.js +3 -2
- package/server/db.js +6 -6
- package/server/index.js +10 -4
- package/server/logging.js +23 -0
- package/server/watcher.js +7 -5
- package/server/ws.js +23 -11
- package/app/data/list-selectors.js +0 -103
- package/app/data/providers.js +0 -78
- package/app/data/sort.js +0 -47
- package/app/data/subscription-issue-store.js +0 -161
- package/app/data/subscription-issue-stores.js +0 -128
- package/app/data/subscriptions-store.js +0 -227
- package/app/main.js +0 -706
- package/app/protocol.md +0 -66
- package/app/router.js +0 -117
- package/app/state.js +0 -105
- package/app/utils/issue-id-renderer.js +0 -72
- package/app/utils/issue-id.js +0 -11
- package/app/utils/issue-type.js +0 -29
- package/app/utils/issue-url.js +0 -10
- package/app/utils/markdown.js +0 -16
- package/app/utils/priority-badge.js +0 -48
- package/app/utils/priority.js +0 -1
- package/app/utils/status-badge.js +0 -33
- package/app/utils/status.js +0 -25
- package/app/utils/toast.js +0 -35
- package/app/utils/type-badge.js +0 -34
- package/app/views/board.js +0 -537
- package/app/views/detail.js +0 -1252
- package/app/views/epics.js +0 -281
- package/app/views/issue-dialog.js +0 -164
- package/app/views/issue-row.js +0 -191
- package/app/views/list.js +0 -468
- package/app/views/nav.js +0 -68
- package/app/views/new-issue-dialog.js +0 -348
- package/app/ws.js +0 -282
- package/docs/adr/001-push-only-lists.md +0 -134
- package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +0 -200
- package/docs/architecture.md +0 -194
- package/docs/data-exchange-subscription-plan.md +0 -198
- package/docs/db-watching.md +0 -30
- package/docs/migration-v2.md +0 -54
- package/docs/protocol/issues-push-v2.md +0 -179
- package/docs/subscription-issue-store.md +0 -112
- package/server/protocol.js +0 -3
package/bin/bdui.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Delegates to `server/cli/index.js` and sets the process exit code.
|
|
5
5
|
*/
|
|
6
6
|
import { main } from '../server/cli/index.js';
|
|
7
|
+
import { debug } from '../server/logging.js';
|
|
7
8
|
|
|
8
9
|
const argv = process.argv.slice(2);
|
|
9
10
|
|
|
@@ -13,6 +14,6 @@ try {
|
|
|
13
14
|
process.exitCode = code;
|
|
14
15
|
}
|
|
15
16
|
} catch (err) {
|
|
16
|
-
|
|
17
|
+
debug('cli')('fatal %o', err);
|
|
17
18
|
process.exitCode = 1;
|
|
18
19
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "beads-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Local
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Local UI for Beads — Collaborate on issues with your coding agent.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"agent",
|
|
7
|
+
"issue-tracker",
|
|
8
|
+
"local-first",
|
|
9
|
+
"ai-tools"
|
|
10
|
+
],
|
|
5
11
|
"homepage": "https://github.com/mantoni/beads-ui",
|
|
6
12
|
"type": "module",
|
|
7
13
|
"bin": {
|
|
@@ -12,7 +18,8 @@
|
|
|
12
18
|
},
|
|
13
19
|
"scripts": {
|
|
14
20
|
"all": "npm run lint && npm run typecheck && npm test && npm run format:check",
|
|
15
|
-
"start": "node server/index.js",
|
|
21
|
+
"start": "node server/index.js --debug",
|
|
22
|
+
"build": "node scripts/build-frontend.js",
|
|
16
23
|
"test": "vitest run",
|
|
17
24
|
"test:watch": "vitest",
|
|
18
25
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
@@ -21,15 +28,26 @@
|
|
|
21
28
|
"format:check": "prettier --check .",
|
|
22
29
|
"preversion": "npm run all",
|
|
23
30
|
"version": "changes --commits --footer",
|
|
24
|
-
"postversion": "git push --follow-tags && npm publish"
|
|
31
|
+
"postversion": "git push --follow-tags && npm publish",
|
|
32
|
+
"prepack": "NODE_ENV=production npm run build"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"debug": "^4.4.3",
|
|
36
|
+
"dompurify": "^3.3.0",
|
|
37
|
+
"express": "^5.1.0",
|
|
38
|
+
"lit-html": "^3.3.1",
|
|
39
|
+
"marked": "^16.4.1",
|
|
40
|
+
"ws": "^8.18.3"
|
|
25
41
|
},
|
|
26
42
|
"devDependencies": {
|
|
27
43
|
"@eslint/js": "^9.38.0",
|
|
28
44
|
"@studio/changes": "^3.0.0",
|
|
29
45
|
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
|
|
46
|
+
"@types/debug": "^4.1.12",
|
|
30
47
|
"@types/express": "^5.0.3",
|
|
31
48
|
"@types/node": "^22.7.4",
|
|
32
49
|
"@types/ws": "^8.18.1",
|
|
50
|
+
"esbuild": "^0.25.11",
|
|
33
51
|
"eslint": "^9.11.0",
|
|
34
52
|
"eslint-plugin-import": "^2.29.1",
|
|
35
53
|
"eslint-plugin-jsdoc": "^61.1.9",
|
|
@@ -40,18 +58,13 @@
|
|
|
40
58
|
"typescript": "^5.6.3",
|
|
41
59
|
"vitest": "^2.1.3"
|
|
42
60
|
},
|
|
43
|
-
"dependencies": {
|
|
44
|
-
"dompurify": "^3.3.0",
|
|
45
|
-
"esbuild": "^0.25.11",
|
|
46
|
-
"express": "^5.1.0",
|
|
47
|
-
"lit-html": "^3.3.1",
|
|
48
|
-
"marked": "^16.4.1",
|
|
49
|
-
"ws": "^8.18.3"
|
|
50
|
-
},
|
|
51
61
|
"files": [
|
|
52
|
-
"app",
|
|
62
|
+
"app/index.html",
|
|
63
|
+
"app/styles.css",
|
|
64
|
+
"app/main.bundle.js",
|
|
65
|
+
"app/main.bundle.js.map",
|
|
66
|
+
"app/protocol.js",
|
|
53
67
|
"bin",
|
|
54
|
-
"docs",
|
|
55
68
|
"server",
|
|
56
69
|
"CHANGES.md",
|
|
57
70
|
"LICENSE",
|
package/server/app.js
CHANGED
|
@@ -26,44 +26,46 @@ export function createApp(config) {
|
|
|
26
26
|
res.status(200).send({ ok: true });
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
29
|
+
// In development, support on-demand bundling for a smooth DX.
|
|
30
|
+
// In production, we expect `app/main.bundle.js` to be pre-built and served statically.
|
|
31
|
+
if (config.env !== 'production') {
|
|
32
|
+
/**
|
|
33
|
+
* On-demand bundle for the browser using esbuild.
|
|
34
|
+
* Note: esbuild is loaded lazily so tests don't require it to be installed.
|
|
35
|
+
*
|
|
36
|
+
* @param {Request} _req
|
|
37
|
+
* @param {Response} res
|
|
38
|
+
*/
|
|
39
|
+
app.get('/main.bundle.js', async (_req, res) => {
|
|
40
|
+
try {
|
|
41
|
+
const esbuild = await import('esbuild');
|
|
42
|
+
const entry = path.join(config.app_dir, 'main.js');
|
|
43
|
+
const result = await esbuild.build({
|
|
44
|
+
entryPoints: [entry],
|
|
45
|
+
bundle: true,
|
|
46
|
+
format: 'esm',
|
|
47
|
+
platform: 'browser',
|
|
48
|
+
target: 'es2020',
|
|
49
|
+
sourcemap: 'inline',
|
|
50
|
+
minify: false,
|
|
51
|
+
write: false
|
|
52
|
+
});
|
|
53
|
+
const out = result.outputFiles && result.outputFiles[0];
|
|
54
|
+
if (!out) {
|
|
55
|
+
res.status(500).type('text/plain').send('Bundle failed: no output');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
57
59
|
res.setHeader('Cache-Control', 'no-store');
|
|
60
|
+
res.send(out.text);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
res
|
|
63
|
+
.status(500)
|
|
64
|
+
.type('text/plain')
|
|
65
|
+
.send('Bundle error: ' + (err && /** @type {any} */ (err).message));
|
|
58
66
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
res
|
|
62
|
-
.status(500)
|
|
63
|
-
.type('text/plain')
|
|
64
|
-
.send('Bundle error: ' + (err && /** @type {any} */ (err).message));
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
67
69
|
|
|
68
70
|
// Static assets from /app
|
|
69
71
|
app.use(express.static(config.app_dir));
|
package/server/bd.js
CHANGED
|
@@ -32,10 +32,10 @@ export function runBd(args, options = {}) {
|
|
|
32
32
|
|
|
33
33
|
// Ensure a consistent DB by injecting --db if missing, following beads precedence.
|
|
34
34
|
/** @type {string[]} */
|
|
35
|
-
const
|
|
35
|
+
const final_args = withDbArg(args, spawn_opts.cwd, spawn_opts.env);
|
|
36
36
|
|
|
37
37
|
return new Promise((resolve) => {
|
|
38
|
-
const child = spawn(bin,
|
|
38
|
+
const child = spawn(bin, final_args, spawn_opts);
|
|
39
39
|
|
|
40
40
|
/** @type {string[]} */
|
|
41
41
|
const out_chunks = [];
|
package/server/cli/commands.js
CHANGED
|
@@ -17,14 +17,14 @@ import { openUrl, waitForServer } from './open.js';
|
|
|
17
17
|
* @returns {Promise<number>} Exit code (0 on success)
|
|
18
18
|
*/
|
|
19
19
|
/**
|
|
20
|
-
* @param {{
|
|
20
|
+
* @param {{ open?: boolean, is_debug?: boolean }} [options]
|
|
21
21
|
*/
|
|
22
22
|
export async function handleStart(options) {
|
|
23
|
-
// Default
|
|
24
|
-
const
|
|
23
|
+
// Default: do not open a browser unless explicitly requested via `open: true`.
|
|
24
|
+
const should_open = options?.open === true;
|
|
25
25
|
const existing_pid = readPidFile();
|
|
26
26
|
if (existing_pid && isProcessRunning(existing_pid)) {
|
|
27
|
-
|
|
27
|
+
console.warn('Server is already running.');
|
|
28
28
|
return 0;
|
|
29
29
|
}
|
|
30
30
|
if (existing_pid && !isProcessRunning(existing_pid)) {
|
|
@@ -32,11 +32,11 @@ export async function handleStart(options) {
|
|
|
32
32
|
removePidFile();
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const started = startDaemon();
|
|
35
|
+
const started = startDaemon({ is_debug: options?.is_debug });
|
|
36
36
|
if (started && started.pid > 0) {
|
|
37
37
|
printServerUrl();
|
|
38
38
|
// Auto-open the browser once for a fresh daemon start
|
|
39
|
-
if (
|
|
39
|
+
if (should_open) {
|
|
40
40
|
const { url } = getConfig();
|
|
41
41
|
// Wait briefly for the server to accept connections (single retry window)
|
|
42
42
|
await waitForServer(url, 600);
|
|
@@ -86,9 +86,9 @@ export async function handleStop() {
|
|
|
86
86
|
/**
|
|
87
87
|
* Handle `restart` command: stop (ignore not-running) then start.
|
|
88
88
|
* Accepts the same options as `handleStart` and passes them through,
|
|
89
|
-
* so restart only opens a browser when `
|
|
89
|
+
* so restart only opens a browser when `open` is explicitly true.
|
|
90
90
|
*
|
|
91
|
-
* @param {{
|
|
91
|
+
* @param {{ open?: boolean }} [options]
|
|
92
92
|
* @returns {Promise<number>}
|
|
93
93
|
*/
|
|
94
94
|
export async function handleRestart(options) {
|
package/server/cli/daemon.js
CHANGED
|
@@ -140,9 +140,10 @@ export function getServerEntryPath() {
|
|
|
140
140
|
* Spawn the server as a detached daemon, redirecting stdio to the log file.
|
|
141
141
|
* Writes the PID file upon success.
|
|
142
142
|
*
|
|
143
|
+
* @param {{ is_debug?: boolean }} [options]
|
|
143
144
|
* @returns {{ pid: number } | null} Returns child PID on success; null on failure.
|
|
144
145
|
*/
|
|
145
|
-
export function startDaemon() {
|
|
146
|
+
export function startDaemon(options = {}) {
|
|
146
147
|
const server_entry = getServerEntryPath();
|
|
147
148
|
const log_file = getLogFilePath();
|
|
148
149
|
|
|
@@ -151,6 +152,9 @@ export function startDaemon() {
|
|
|
151
152
|
let log_fd;
|
|
152
153
|
try {
|
|
153
154
|
log_fd = fs.openSync(log_file, 'a');
|
|
155
|
+
if (options.is_debug) {
|
|
156
|
+
console.debug('log file ', log_file);
|
|
157
|
+
}
|
|
154
158
|
} catch {
|
|
155
159
|
// If log cannot be opened, fallback to ignoring stdio
|
|
156
160
|
log_fd = -1;
|
|
@@ -170,11 +174,15 @@ export function startDaemon() {
|
|
|
170
174
|
child.unref();
|
|
171
175
|
const child_pid = typeof child.pid === 'number' ? child.pid : -1;
|
|
172
176
|
if (child_pid > 0) {
|
|
177
|
+
if (options.is_debug) {
|
|
178
|
+
console.debug('starting ', child_pid);
|
|
179
|
+
}
|
|
173
180
|
writePidFile(child_pid);
|
|
174
181
|
return { pid: child_pid };
|
|
175
182
|
}
|
|
176
183
|
return null;
|
|
177
184
|
} catch (err) {
|
|
185
|
+
console.error('start error', err);
|
|
178
186
|
// Log startup error to log file for traceability
|
|
179
187
|
try {
|
|
180
188
|
const message =
|
|
@@ -242,12 +250,12 @@ function sleep(ms) {
|
|
|
242
250
|
* Print the server URL derived from current config.
|
|
243
251
|
*/
|
|
244
252
|
export function printServerUrl() {
|
|
245
|
-
const { url } = getConfig();
|
|
246
|
-
console.log(url);
|
|
247
|
-
|
|
248
253
|
// Resolve from the caller's working directory by default
|
|
249
254
|
const resolved_db = resolveDbPath();
|
|
250
255
|
console.log(
|
|
251
|
-
`db
|
|
256
|
+
`beads db ${resolved_db.path} (${resolved_db.source}${resolved_db.exists ? '' : ', missing'})`
|
|
252
257
|
);
|
|
258
|
+
|
|
259
|
+
const { url, env } = getConfig();
|
|
260
|
+
console.log(`beads ui listening on ${url} (${env})`);
|
|
253
261
|
}
|
package/server/cli/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { enableAllDebug } from '../logging.js';
|
|
1
2
|
import { handleRestart, handleStart, handleStop } from './commands.js';
|
|
2
3
|
import { printUsage } from './usage.js';
|
|
3
4
|
|
|
@@ -18,12 +19,12 @@ export function parseArgs(args) {
|
|
|
18
19
|
flags.push('help');
|
|
19
20
|
continue;
|
|
20
21
|
}
|
|
21
|
-
if (token === '--
|
|
22
|
-
flags.push('
|
|
22
|
+
if (token === '--debug' || token === '-d') {
|
|
23
|
+
flags.push('debug');
|
|
23
24
|
continue;
|
|
24
25
|
}
|
|
25
|
-
if (token === '--
|
|
26
|
-
flags.push('
|
|
26
|
+
if (token === '--open') {
|
|
27
|
+
flags.push('open');
|
|
27
28
|
continue;
|
|
28
29
|
}
|
|
29
30
|
if (
|
|
@@ -49,6 +50,11 @@ export function parseArgs(args) {
|
|
|
49
50
|
export async function main(args) {
|
|
50
51
|
const { command, flags } = parseArgs(args);
|
|
51
52
|
|
|
53
|
+
const is_debug = flags.includes('debug');
|
|
54
|
+
if (is_debug) {
|
|
55
|
+
enableAllDebug();
|
|
56
|
+
}
|
|
57
|
+
|
|
52
58
|
if (flags.includes('help')) {
|
|
53
59
|
printUsage(process.stdout);
|
|
54
60
|
return 0;
|
|
@@ -60,42 +66,22 @@ export async function main(args) {
|
|
|
60
66
|
|
|
61
67
|
if (command === 'start') {
|
|
62
68
|
/**
|
|
63
|
-
* Default behavior: do NOT open a browser.
|
|
64
|
-
* `--open` explicitly opens, overriding env/config; `--no-open` forces closed.
|
|
69
|
+
* Default behavior: do NOT open a browser. `--open` explicitly opens.
|
|
65
70
|
*/
|
|
66
71
|
const options = {
|
|
67
|
-
|
|
72
|
+
open: flags.includes('open'),
|
|
73
|
+
is_debug: is_debug || Boolean(process.env.DEBUG)
|
|
68
74
|
};
|
|
69
|
-
|
|
70
|
-
const has_open = flags.includes('open');
|
|
71
|
-
const has_no_open = flags.includes('no-open');
|
|
72
|
-
const env_no_open = String(process.env.BDUI_NO_OPEN || '') === '1';
|
|
73
|
-
|
|
74
|
-
if (has_open) {
|
|
75
|
-
options.no_open = false;
|
|
76
|
-
} else if (has_no_open) {
|
|
77
|
-
options.no_open = true;
|
|
78
|
-
} else if (env_no_open) {
|
|
79
|
-
options.no_open = true;
|
|
80
|
-
}
|
|
81
75
|
return await handleStart(options);
|
|
82
76
|
}
|
|
83
77
|
if (command === 'stop') {
|
|
84
78
|
return await handleStop();
|
|
85
79
|
}
|
|
86
80
|
if (command === 'restart') {
|
|
87
|
-
const options = {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (has_open) {
|
|
93
|
-
options.no_open = false;
|
|
94
|
-
} else if (has_no_open) {
|
|
95
|
-
options.no_open = true;
|
|
96
|
-
} else if (env_no_open) {
|
|
97
|
-
options.no_open = true;
|
|
98
|
-
}
|
|
81
|
+
const options = {
|
|
82
|
+
open: flags.includes('open'),
|
|
83
|
+
is_debug: is_debug || Boolean(process.env.DEBUG)
|
|
84
|
+
};
|
|
99
85
|
return await handleRestart(options);
|
|
100
86
|
}
|
|
101
87
|
|
package/server/cli/usage.js
CHANGED
|
@@ -13,8 +13,9 @@ export function printUsage(out_stream) {
|
|
|
13
13
|
' restart Restart the UI server',
|
|
14
14
|
'',
|
|
15
15
|
'Options:',
|
|
16
|
-
' -h, --help
|
|
17
|
-
'
|
|
16
|
+
' -h, --help Show this help message',
|
|
17
|
+
' -d, --debug Enable debug logging',
|
|
18
|
+
' --open Open the browser after start/restart',
|
|
18
19
|
''
|
|
19
20
|
];
|
|
20
21
|
for (const line of lines) {
|
package/server/db.js
CHANGED
|
@@ -38,11 +38,11 @@ export function resolveDbPath(options = {}) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
// 4) ~/.beads/default.db
|
|
41
|
-
const
|
|
41
|
+
const home_default = path.join(os.homedir(), '.beads', 'default.db');
|
|
42
42
|
return {
|
|
43
|
-
path:
|
|
43
|
+
path: home_default,
|
|
44
44
|
source: 'home-default',
|
|
45
|
-
exists: fileExists(
|
|
45
|
+
exists: fileExists(home_default)
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
48
|
|
|
@@ -57,15 +57,15 @@ export function findNearestBeadsDb(start) {
|
|
|
57
57
|
let dir = path.resolve(start);
|
|
58
58
|
// Cap iterations to avoid infinite loop in degenerate cases
|
|
59
59
|
for (let i = 0; i < 100; i++) {
|
|
60
|
-
const
|
|
60
|
+
const beads_dir = path.join(dir, '.beads');
|
|
61
61
|
try {
|
|
62
|
-
const entries = fs.readdirSync(
|
|
62
|
+
const entries = fs.readdirSync(beads_dir, { withFileTypes: true });
|
|
63
63
|
const dbs = entries
|
|
64
64
|
.filter((e) => e.isFile() && e.name.endsWith('.db'))
|
|
65
65
|
.map((e) => e.name)
|
|
66
66
|
.sort();
|
|
67
67
|
if (dbs.length > 0) {
|
|
68
|
-
return path.join(
|
|
68
|
+
return path.join(beads_dir, dbs[0]);
|
|
69
69
|
}
|
|
70
70
|
} catch {
|
|
71
71
|
// ignore and walk up
|
package/server/index.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { createApp } from './app.js';
|
|
3
|
+
import { printServerUrl } from './cli/daemon.js';
|
|
3
4
|
import { getConfig } from './config.js';
|
|
5
|
+
import { debug, enableAllDebug } from './logging.js';
|
|
4
6
|
import { watchDb } from './watcher.js';
|
|
5
7
|
import { attachWsServer } from './ws.js';
|
|
6
8
|
|
|
9
|
+
if (process.argv.includes('--debug') || process.argv.includes('-d')) {
|
|
10
|
+
enableAllDebug();
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
const config = getConfig();
|
|
8
14
|
const app = createApp(config);
|
|
9
15
|
const server = createServer(app);
|
|
16
|
+
const log = debug('server');
|
|
10
17
|
const { scheduleListRefresh } = attachWsServer(server, {
|
|
11
18
|
path: '/ws',
|
|
12
19
|
heartbeat_ms: 30000,
|
|
@@ -17,17 +24,16 @@ const { scheduleListRefresh } = attachWsServer(server, {
|
|
|
17
24
|
// Watch the active beads DB and schedule subscription refresh for active lists
|
|
18
25
|
watchDb(config.root_dir, () => {
|
|
19
26
|
// Schedule subscription list refresh run for active subscriptions
|
|
27
|
+
log('db change detected → schedule refresh');
|
|
20
28
|
scheduleListRefresh();
|
|
21
29
|
// v2: all updates flow via subscription push envelopes only
|
|
22
30
|
});
|
|
23
31
|
|
|
24
32
|
server.listen(config.port, config.host, () => {
|
|
25
|
-
|
|
26
|
-
`beads-ui server listening on http://${config.host}:${config.port} (${config.env})`
|
|
27
|
-
);
|
|
33
|
+
printServerUrl();
|
|
28
34
|
});
|
|
29
35
|
|
|
30
36
|
server.on('error', (err) => {
|
|
31
|
-
|
|
37
|
+
log('server error %o', err);
|
|
32
38
|
process.exitCode = 1;
|
|
33
39
|
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug logger helper for Node server/CLI.
|
|
3
|
+
*/
|
|
4
|
+
import createDebug from 'debug';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Create a namespaced logger for Node runtime.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} ns - Module namespace suffix (e.g., 'ws', 'watcher').
|
|
10
|
+
*/
|
|
11
|
+
export function debug(ns) {
|
|
12
|
+
return createDebug(`beads-ui:${ns}`);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Enable all `beads-ui:*` debug logs at runtime for Node/CLI.
|
|
17
|
+
* Safe to call multiple times.
|
|
18
|
+
*/
|
|
19
|
+
export function enableAllDebug() {
|
|
20
|
+
// `debug` exposes a global enable/disable API.
|
|
21
|
+
// Enabling after loggers are created updates their `.enabled` state.
|
|
22
|
+
createDebug.enable(process.env.DEBUG || 'beads-ui:*');
|
|
23
|
+
}
|
package/server/watcher.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { resolveDbPath } from './db.js';
|
|
4
|
+
import { debug } from './logging.js';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Watch the resolved beads SQLite DB file and invoke a callback after a debounce window.
|
|
@@ -13,6 +14,7 @@ import { resolveDbPath } from './db.js';
|
|
|
13
14
|
*/
|
|
14
15
|
export function watchDb(root_dir, onChange, options = {}) {
|
|
15
16
|
const debounce_ms = options.debounce_ms ?? 250;
|
|
17
|
+
const log = debug('watcher');
|
|
16
18
|
|
|
17
19
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
18
20
|
let timer;
|
|
@@ -44,10 +46,9 @@ export function watchDb(root_dir, onChange, options = {}) {
|
|
|
44
46
|
current_dir = path.dirname(current_path);
|
|
45
47
|
current_file = path.basename(current_path);
|
|
46
48
|
if (!resolved.exists) {
|
|
47
|
-
|
|
48
|
-
'
|
|
49
|
-
current_path
|
|
50
|
-
'\nHint: set --db, export BEADS_DB, or run `bd init` in your workspace.'
|
|
49
|
+
log(
|
|
50
|
+
'resolved DB missing: %s – Hint: set --db, export BEADS_DB, or run `bd init` in your workspace.',
|
|
51
|
+
current_path
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
54
|
|
|
@@ -61,12 +62,13 @@ export function watchDb(root_dir, onChange, options = {}) {
|
|
|
61
62
|
return;
|
|
62
63
|
}
|
|
63
64
|
if (event_type === 'change' || event_type === 'rename') {
|
|
65
|
+
log('fs %s %s', event_type, filename || '');
|
|
64
66
|
schedule();
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
);
|
|
68
70
|
} catch (err) {
|
|
69
|
-
|
|
71
|
+
log('unable to watch directory %s %o', current_dir, err);
|
|
70
72
|
}
|
|
71
73
|
};
|
|
72
74
|
|
package/server/ws.js
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
* @import { MessageType } from '../app/protocol.js'
|
|
5
5
|
*/
|
|
6
6
|
import { WebSocketServer } from 'ws';
|
|
7
|
+
import { isRequest, makeError, makeOk } from '../app/protocol.js';
|
|
7
8
|
import { runBd, runBdJson } from './bd.js';
|
|
8
9
|
import { fetchListForSubscription } from './list-adapters.js';
|
|
9
|
-
import {
|
|
10
|
+
import { debug } from './logging.js';
|
|
10
11
|
import { keyOf, registry } from './subscriptions.js';
|
|
11
12
|
import { validateSubscribeListPayload } from './validators.js';
|
|
12
13
|
|
|
14
|
+
const log = debug('ws');
|
|
15
|
+
|
|
13
16
|
/**
|
|
14
17
|
* Debounced refresh scheduling for active list subscriptions.
|
|
15
18
|
* A trailing window coalesces rapid change bursts into a single refresh run.
|
|
@@ -77,6 +80,7 @@ function triggerMutationRefreshOnce(timeout_ms = 500) {
|
|
|
77
80
|
|
|
78
81
|
// After resolution, run a single refresh across active subs and clear gate
|
|
79
82
|
void p.then(async () => {
|
|
83
|
+
log('mutation window resolved → refresh active subs');
|
|
80
84
|
try {
|
|
81
85
|
await refreshAllActiveListSubscriptions();
|
|
82
86
|
} catch {
|
|
@@ -321,17 +325,17 @@ async function refreshAndPublish(spec) {
|
|
|
321
325
|
return;
|
|
322
326
|
}
|
|
323
327
|
const items = applyClosedIssuesFilter(spec, res.items);
|
|
324
|
-
const
|
|
328
|
+
const prev_size = registry.get(key)?.itemsById.size || 0;
|
|
325
329
|
const delta = registry.applyItems(key, items);
|
|
326
330
|
const entry = registry.get(key);
|
|
327
331
|
if (!entry || entry.subscribers.size === 0) {
|
|
328
332
|
return;
|
|
329
333
|
}
|
|
330
334
|
/** @type {Map<string, any>} */
|
|
331
|
-
const
|
|
335
|
+
const by_id = new Map();
|
|
332
336
|
for (const it of items) {
|
|
333
337
|
if (it && typeof it.id === 'string') {
|
|
334
|
-
|
|
338
|
+
by_id.set(it.id, it);
|
|
335
339
|
}
|
|
336
340
|
}
|
|
337
341
|
for (const ws of entry.subscribers) {
|
|
@@ -339,20 +343,20 @@ async function refreshAndPublish(spec) {
|
|
|
339
343
|
const s = ensureSubs(ws);
|
|
340
344
|
const subs = s.list_subs || new Map();
|
|
341
345
|
/** @type {string[]} */
|
|
342
|
-
const
|
|
346
|
+
const client_ids = [];
|
|
343
347
|
for (const [cid, v] of subs.entries()) {
|
|
344
|
-
if (v.key === key)
|
|
348
|
+
if (v.key === key) client_ids.push(cid);
|
|
345
349
|
}
|
|
346
|
-
if (
|
|
347
|
-
if (
|
|
348
|
-
for (const cid of
|
|
350
|
+
if (client_ids.length === 0) continue;
|
|
351
|
+
if (prev_size === 0) {
|
|
352
|
+
for (const cid of client_ids) {
|
|
349
353
|
emitSubscriptionSnapshot(ws, cid, key, items);
|
|
350
354
|
}
|
|
351
355
|
continue;
|
|
352
356
|
}
|
|
353
|
-
for (const cid of
|
|
357
|
+
for (const cid of client_ids) {
|
|
354
358
|
for (const id of [...delta.added, ...delta.updated]) {
|
|
355
|
-
const issue =
|
|
359
|
+
const issue = by_id.get(id);
|
|
356
360
|
if (issue) {
|
|
357
361
|
emitSubscriptionUpsert(ws, cid, key, issue);
|
|
358
362
|
}
|
|
@@ -413,6 +417,7 @@ export function attachWsServer(http_server, options = {}) {
|
|
|
413
417
|
|
|
414
418
|
// Heartbeat: track if client answered the last ping
|
|
415
419
|
wss.on('connection', (ws) => {
|
|
420
|
+
log('client connected');
|
|
416
421
|
// @ts-expect-error add marker property
|
|
417
422
|
ws.isAlive = true;
|
|
418
423
|
|
|
@@ -507,6 +512,7 @@ export async function handleMessage(ws, data) {
|
|
|
507
512
|
}
|
|
508
513
|
|
|
509
514
|
if (!isRequest(json)) {
|
|
515
|
+
log('invalid request');
|
|
510
516
|
const reply = {
|
|
511
517
|
id: 'unknown',
|
|
512
518
|
ok: false,
|
|
@@ -527,6 +533,7 @@ export async function handleMessage(ws, data) {
|
|
|
527
533
|
|
|
528
534
|
// subscribe-list: payload { id: string, type: string, params?: object }
|
|
529
535
|
if (req.type === 'subscribe-list') {
|
|
536
|
+
log('subscribe-list %s', /** @type {any} */ (req.payload)?.id || '');
|
|
530
537
|
const validation = validateSubscribeListPayload(
|
|
531
538
|
/** @type {any} */ (req.payload || {})
|
|
532
539
|
);
|
|
@@ -562,6 +569,7 @@ export async function handleMessage(ws, data) {
|
|
|
562
569
|
|
|
563
570
|
// unsubscribe-list: payload { id: string }
|
|
564
571
|
if (req.type === 'unsubscribe-list') {
|
|
572
|
+
log('unsubscribe-list %s', /** @type {any} */ (req.payload)?.id || '');
|
|
565
573
|
const { id: client_id } = /** @type {any} */ (req.payload || {});
|
|
566
574
|
if (typeof client_id !== 'string' || client_id.length === 0) {
|
|
567
575
|
ws.send(
|
|
@@ -646,6 +654,7 @@ export async function handleMessage(ws, data) {
|
|
|
646
654
|
|
|
647
655
|
// update-status
|
|
648
656
|
if (req.type === 'update-status') {
|
|
657
|
+
log('update-status');
|
|
649
658
|
const { id, status } = /** @type {any} */ (req.payload);
|
|
650
659
|
const allowed = new Set(['open', 'in_progress', 'closed']);
|
|
651
660
|
if (
|
|
@@ -691,6 +700,7 @@ export async function handleMessage(ws, data) {
|
|
|
691
700
|
|
|
692
701
|
// update-priority
|
|
693
702
|
if (req.type === 'update-priority') {
|
|
703
|
+
log('update-priority');
|
|
694
704
|
const { id, priority } = /** @type {any} */ (req.payload);
|
|
695
705
|
if (
|
|
696
706
|
typeof id !== 'string' ||
|
|
@@ -735,6 +745,7 @@ export async function handleMessage(ws, data) {
|
|
|
735
745
|
|
|
736
746
|
// edit-text
|
|
737
747
|
if (req.type === 'edit-text') {
|
|
748
|
+
log('edit-text');
|
|
738
749
|
const { id, field, value } = /** @type {any} */ (req.payload);
|
|
739
750
|
if (
|
|
740
751
|
typeof id !== 'string' ||
|
|
@@ -798,6 +809,7 @@ export async function handleMessage(ws, data) {
|
|
|
798
809
|
|
|
799
810
|
// create-issue
|
|
800
811
|
if (req.type === 'create-issue') {
|
|
812
|
+
log('create-issue');
|
|
801
813
|
const { title, type, priority, description } = /** @type {any} */ (
|
|
802
814
|
req.payload || {}
|
|
803
815
|
);
|