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.
Files changed (58) hide show
  1. package/CHANGES.md +14 -0
  2. package/README.md +4 -4
  3. package/app/data/list-selectors.js +103 -0
  4. package/app/data/providers.js +7 -138
  5. package/app/data/sort.js +47 -0
  6. package/app/data/subscription-issue-store.js +161 -0
  7. package/app/data/subscription-issue-stores.js +128 -0
  8. package/app/data/subscriptions-store.js +227 -0
  9. package/app/main.js +346 -66
  10. package/app/protocol.js +23 -17
  11. package/app/protocol.md +18 -15
  12. package/app/router.js +3 -0
  13. package/app/state.js +2 -0
  14. package/app/styles.css +222 -197
  15. package/app/utils/issue-id-renderer.js +2 -1
  16. package/app/utils/issue-id.js +1 -0
  17. package/app/utils/issue-type.js +2 -0
  18. package/app/utils/issue-url.js +1 -0
  19. package/app/utils/markdown.js +13 -198
  20. package/app/utils/priority-badge.js +1 -2
  21. package/app/utils/status-badge.js +1 -1
  22. package/app/utils/status.js +2 -0
  23. package/app/utils/toast.js +1 -1
  24. package/app/utils/type-badge.js +1 -3
  25. package/app/views/board.js +172 -148
  26. package/app/views/detail.js +79 -66
  27. package/app/views/epics.js +127 -74
  28. package/app/views/issue-dialog.js +9 -15
  29. package/app/views/issue-row.js +2 -3
  30. package/app/views/list.js +105 -104
  31. package/app/views/nav.js +1 -0
  32. package/app/views/new-issue-dialog.js +30 -34
  33. package/app/ws.js +10 -10
  34. package/bin/bdui.js +1 -1
  35. package/docs/adr/001-push-only-lists.md +134 -0
  36. package/docs/adr/002-per-subscription-stores-and-full-issue-push.md +200 -0
  37. package/docs/architecture.md +34 -84
  38. package/docs/data-exchange-subscription-plan.md +198 -0
  39. package/docs/db-watching.md +2 -1
  40. package/docs/migration-v2.md +54 -0
  41. package/docs/protocol/issues-push-v2.md +179 -0
  42. package/docs/subscription-issue-store.md +112 -0
  43. package/package.json +5 -4
  44. package/server/app.js +2 -0
  45. package/server/bd.js +4 -2
  46. package/server/cli/commands.js +5 -2
  47. package/server/cli/daemon.js +19 -5
  48. package/server/cli/index.js +2 -2
  49. package/server/cli/open.js +3 -0
  50. package/server/cli/usage.js +2 -1
  51. package/server/config.js +13 -6
  52. package/server/db.js +3 -1
  53. package/server/index.js +9 -5
  54. package/server/list-adapters.js +224 -0
  55. package/server/subscriptions.js +289 -0
  56. package/server/validators.js +113 -0
  57. package/server/watcher.js +8 -8
  58. package/server/ws.js +457 -229
@@ -1,24 +1,27 @@
1
+ /**
2
+ * @import { SpawnOptions } from 'node:child_process'
3
+ */
1
4
  import { spawn } from 'node:child_process';
2
5
  import fs from 'node:fs';
3
6
  import os from 'node:os';
4
7
  import path from 'node:path';
5
8
  import { fileURLToPath } from 'node:url';
6
9
  import { getConfig } from '../config.js';
10
+ import { resolveDbPath } from '../db.js';
7
11
 
8
12
  /**
9
13
  * Resolve the runtime directory used for PID and log files.
10
14
  * Prefers `BDUI_RUNTIME_DIR`, then `$XDG_RUNTIME_DIR/beads-ui`,
11
15
  * and finally `os.tmpdir()/beads-ui`.
16
+ *
12
17
  * @returns {string}
13
18
  */
14
19
  export function getRuntimeDir() {
15
- /** @type {string | undefined} */
16
20
  const override_dir = process.env.BDUI_RUNTIME_DIR;
17
21
  if (override_dir && override_dir.length > 0) {
18
22
  return ensureDir(override_dir);
19
23
  }
20
24
 
21
- /** @type {string | undefined} */
22
25
  const xdg_dir = process.env.XDG_RUNTIME_DIR;
23
26
  if (xdg_dir && xdg_dir.length > 0) {
24
27
  return ensureDir(path.join(xdg_dir, 'beads-ui'));
@@ -29,6 +32,7 @@ export function getRuntimeDir() {
29
32
 
30
33
  /**
31
34
  * Ensure a directory exists with safe permissions and return its path.
35
+ *
32
36
  * @param {string} dir_path
33
37
  * @returns {string}
34
38
  */
@@ -59,6 +63,7 @@ export function getLogFilePath() {
59
63
 
60
64
  /**
61
65
  * Read PID from the PID file if present.
66
+ *
62
67
  * @returns {number | null}
63
68
  */
64
69
  export function readPidFile() {
@@ -98,6 +103,7 @@ export function removePidFile() {
98
103
 
99
104
  /**
100
105
  * Check whether a process is running.
106
+ *
101
107
  * @param {number} pid
102
108
  * @returns {boolean}
103
109
  */
@@ -120,6 +126,7 @@ export function isProcessRunning(pid) {
120
126
 
121
127
  /**
122
128
  * Compute the absolute path to the server entry file.
129
+ *
123
130
  * @returns {string}
124
131
  */
125
132
  export function getServerEntryPath() {
@@ -132,6 +139,7 @@ export function getServerEntryPath() {
132
139
  /**
133
140
  * Spawn the server as a detached daemon, redirecting stdio to the log file.
134
141
  * Writes the PID file upon success.
142
+ *
135
143
  * @returns {{ pid: number } | null} Returns child PID on success; null on failure.
136
144
  */
137
145
  export function startDaemon() {
@@ -148,7 +156,7 @@ export function startDaemon() {
148
156
  log_fd = -1;
149
157
  }
150
158
 
151
- /** @type {import('node:child_process').SpawnOptions} */
159
+ /** @type {SpawnOptions} */
152
160
  const opts = {
153
161
  detached: true,
154
162
  env: { ...process.env },
@@ -181,6 +189,7 @@ export function startDaemon() {
181
189
 
182
190
  /**
183
191
  * Send SIGTERM then (optionally) SIGKILL to stop a process and wait for exit.
192
+ *
184
193
  * @param {number} pid
185
194
  * @param {number} timeout_ms
186
195
  * @returns {Promise<boolean>} Resolves true if the process is gone.
@@ -233,7 +242,12 @@ function sleep(ms) {
233
242
  * Print the server URL derived from current config.
234
243
  */
235
244
  export function printServerUrl() {
236
- const cfg = getConfig();
237
- const url = 'http://' + cfg.host + ':' + String(cfg.port);
245
+ const { url } = getConfig();
238
246
  console.log(url);
247
+
248
+ // Resolve from the caller's working directory by default
249
+ const resolved_db = resolveDbPath();
250
+ console.log(
251
+ `db: ${resolved_db.path} (${resolved_db.source}${resolved_db.exists ? '' : ', missing'})`
252
+ );
239
253
  }
@@ -3,6 +3,7 @@ import { printUsage } from './usage.js';
3
3
 
4
4
  /**
5
5
  * Parse argv into a command token and flags.
6
+ *
6
7
  * @param {string[]} args
7
8
  * @returns {{ command: string | null, flags: string[] }}
8
9
  */
@@ -41,6 +42,7 @@ export function parseArgs(args) {
41
42
  /**
42
43
  * CLI main entry. Returns an exit code and prints usage on `--help` or errors.
43
44
  * No side effects beyond invoking stub handlers.
45
+ *
44
46
  * @param {string[]} args
45
47
  * @returns {Promise<number>}
46
48
  */
@@ -61,7 +63,6 @@ export async function main(args) {
61
63
  * Default behavior: do NOT open a browser.
62
64
  * `--open` explicitly opens, overriding env/config; `--no-open` forces closed.
63
65
  */
64
- /** @type {{ no_open: boolean }} */
65
66
  const options = {
66
67
  no_open: true
67
68
  };
@@ -83,7 +84,6 @@ export async function main(args) {
83
84
  return await handleStop();
84
85
  }
85
86
  if (command === 'restart') {
86
- /** @type {{ no_open: boolean }} */
87
87
  const options = { no_open: true };
88
88
  const has_open = flags.includes('open');
89
89
  const has_no_open = flags.includes('no-open');
@@ -3,6 +3,7 @@ import http from 'node:http';
3
3
 
4
4
  /**
5
5
  * Compute a platform-specific command to open a URL in the default browser.
6
+ *
6
7
  * @param {string} url
7
8
  * @param {string} platform
8
9
  * @returns {{ cmd: string, args: string[] }}
@@ -21,6 +22,7 @@ export function computeOpenCommand(url, platform) {
21
22
 
22
23
  /**
23
24
  * Open the given URL in the default browser. Best-effort; resolves true on spawn success.
25
+ *
24
26
  * @param {string} url
25
27
  * @returns {Promise<boolean>}
26
28
  */
@@ -41,6 +43,7 @@ export async function openUrl(url) {
41
43
  /**
42
44
  * Wait until the server at the URL accepts a connection, with a brief retry.
43
45
  * Does not throw; returns when either a connection was accepted or timeout elapsed.
46
+ *
44
47
  * @param {string} url
45
48
  * @param {number} total_timeout_ms
46
49
  * @returns {Promise<void>}
@@ -1,5 +1,6 @@
1
1
  /**
2
2
  * Print CLI usage to a stream-like target.
3
+ *
3
4
  * @param {{ write: (chunk: string) => any }} out_stream
4
5
  */
5
6
  export function printUsage(out_stream) {
@@ -7,7 +8,7 @@ export function printUsage(out_stream) {
7
8
  'Usage: bdui <command> [options]',
8
9
  '',
9
10
  'Commands:',
10
- ' start Start the UI server (daemonized in later steps)',
11
+ ' start Start the UI server',
11
12
  ' stop Stop the UI server',
12
13
  ' restart Restart the UI server',
13
14
  '',
package/server/config.js CHANGED
@@ -3,27 +3,34 @@ import { fileURLToPath } from 'node:url';
3
3
 
4
4
  /**
5
5
  * Resolve runtime configuration for the server.
6
- * @returns {{ host: string, port: number, env: string, app_dir: string, root_dir: string }}
6
+ * Notes:
7
+ * - `app_dir` is resolved relative to the installed package location.
8
+ * - `root_dir` represents the directory where the process was invoked
9
+ * (i.e., the current working directory) so DB resolution follows the
10
+ * caller's context rather than the install location.
11
+ *
12
+ * @returns {{ host: string, port: number, env: string, app_dir: string, root_dir: string, url: string }}
7
13
  */
8
14
  export function getConfig() {
9
15
  const this_file = fileURLToPath(new URL(import.meta.url));
10
16
  const server_dir = path.dirname(this_file);
11
- const root_dir = path.resolve(server_dir, '..');
17
+ const package_root = path.resolve(server_dir, '..');
18
+ // Always reflect the directory from which the process was started
19
+ const root_dir = process.cwd();
12
20
 
13
- /** @type {number} */
14
21
  let port_value = Number.parseInt(process.env.PORT || '', 10);
15
22
  if (!Number.isFinite(port_value)) {
16
23
  port_value = 3000;
17
24
  }
18
25
 
19
- /** @type {string} */
20
26
  const host_value = '127.0.0.1';
21
27
 
22
28
  return {
23
29
  host: host_value,
24
30
  port: port_value,
25
31
  env: process.env.NODE_ENV ? String(process.env.NODE_ENV) : 'development',
26
- app_dir: path.resolve(root_dir, 'app'),
27
- root_dir
32
+ app_dir: path.resolve(package_root, 'app'),
33
+ root_dir,
34
+ url: `http://${host_value}:${port_value}`
28
35
  };
29
36
  }
package/server/db.js CHANGED
@@ -11,6 +11,7 @@ import path from 'node:path';
11
11
  *
12
12
  * Returns a normalized absolute path and a `source` indicator. Existence is
13
13
  * returned via the `exists` boolean.
14
+ *
14
15
  * @param {{ cwd?: string, env?: Record<string, string | undefined>, explicit_db?: string }} [options]
15
16
  * @returns {{ path: string, source: 'flag'|'env'|'nearest'|'home-default', exists: boolean }}
16
17
  */
@@ -48,6 +49,7 @@ export function resolveDbPath(options = {}) {
48
49
  /**
49
50
  * Find nearest .beads/*.db by walking up from start.
50
51
  * First alphabetical .db.
52
+ *
51
53
  * @param {string} start
52
54
  * @returns {string | null}
53
55
  */
@@ -58,7 +60,6 @@ export function findNearestBeadsDb(start) {
58
60
  const beadsDir = path.join(dir, '.beads');
59
61
  try {
60
62
  const entries = fs.readdirSync(beadsDir, { withFileTypes: true });
61
- /** @type {string[]} */
62
63
  const dbs = entries
63
64
  .filter((e) => e.isFile() && e.name.endsWith('.db'))
64
65
  .map((e) => e.name)
@@ -80,6 +81,7 @@ export function findNearestBeadsDb(start) {
80
81
 
81
82
  /**
82
83
  * Resolve possibly relative `p` against `cwd` to an absolute filesystem path.
84
+ *
83
85
  * @param {string} p
84
86
  * @param {string} cwd
85
87
  */
package/server/index.js CHANGED
@@ -7,14 +7,18 @@ import { attachWsServer } from './ws.js';
7
7
  const config = getConfig();
8
8
  const app = createApp(config);
9
9
  const server = createServer(app);
10
- const { notifyIssuesChanged } = attachWsServer(server, {
10
+ const { scheduleListRefresh } = attachWsServer(server, {
11
11
  path: '/ws',
12
- heartbeat_ms: 30000
12
+ heartbeat_ms: 30000,
13
+ // Coalesce DB change bursts into one refresh run
14
+ refresh_debounce_ms: 75
13
15
  });
14
16
 
15
- // Watch the active beads DB and push invalidation (targeted when possible)
16
- watchDb(config.root_dir, (payload) => {
17
- notifyIssuesChanged(payload);
17
+ // Watch the active beads DB and schedule subscription refresh for active lists
18
+ watchDb(config.root_dir, () => {
19
+ // Schedule subscription list refresh run for active subscriptions
20
+ scheduleListRefresh();
21
+ // v2: all updates flow via subscription push envelopes only
18
22
  });
19
23
 
20
24
  server.listen(config.port, config.host, () => {
@@ -0,0 +1,224 @@
1
+ import { runBdJson } from './bd.js';
2
+
3
+ /**
4
+ * Build concrete `bd` CLI args for a subscription type + params.
5
+ * Always includes `--json` for parseable output.
6
+ *
7
+ * @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
8
+ * @returns {string[]}
9
+ */
10
+ export function mapSubscriptionToBdArgs(spec) {
11
+ const t = String(spec.type);
12
+ switch (t) {
13
+ case 'all-issues': {
14
+ return ['list', '--json'];
15
+ }
16
+ case 'epics': {
17
+ return ['epic', 'status', '--json'];
18
+ }
19
+ case 'blocked-issues': {
20
+ return ['blocked', '--json'];
21
+ }
22
+ case 'ready-issues': {
23
+ return ['ready', '--limit', '1000', '--json'];
24
+ }
25
+ case 'in-progress-issues': {
26
+ return ['list', '--json', '--status', 'in_progress'];
27
+ }
28
+ case 'closed-issues': {
29
+ return ['list', '--json', '--status', 'closed'];
30
+ }
31
+ case 'issue-detail': {
32
+ const p = spec.params || {};
33
+ const id = String(p.id || '').trim();
34
+ if (id.length === 0) {
35
+ throw badRequest('Missing param: params.id');
36
+ }
37
+ return ['show', id, '--json'];
38
+ }
39
+ default: {
40
+ throw badRequest(`Unknown subscription type: ${t}`);
41
+ }
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Normalize bd list output to minimal Issue shape used by the registry.
47
+ * - Ensures `id` is a string.
48
+ * - Coerces timestamps to numbers.
49
+ * - `closed_at` defaults to null when missing or invalid.
50
+ *
51
+ * @param {unknown} value
52
+ * @returns {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>}
53
+ */
54
+ export function normalizeIssueList(value) {
55
+ if (!Array.isArray(value)) {
56
+ return [];
57
+ }
58
+ /** @type {Array<{ id: string, created_at: number, updated_at: number, closed_at: number | null } & Record<string, unknown>>} */
59
+ const out = [];
60
+ for (const it of value) {
61
+ const id = String(it.id ?? '');
62
+ if (id.length === 0) {
63
+ continue;
64
+ }
65
+ const created_at = parseTimestamp(/** @type {any} */ (it).created_at);
66
+ const updated_at = parseTimestamp(it.updated_at);
67
+ const closed_raw = it.closed_at;
68
+ /** @type {number | null} */
69
+ let closed_at = null;
70
+ if (closed_raw !== undefined && closed_raw !== null) {
71
+ const n = parseTimestamp(closed_raw);
72
+ closed_at = Number.isFinite(n) ? n : null;
73
+ }
74
+ out.push({
75
+ ...it,
76
+ id,
77
+ created_at: Number.isFinite(created_at) ? created_at : 0,
78
+ updated_at: Number.isFinite(updated_at) ? updated_at : 0,
79
+ closed_at
80
+ });
81
+ }
82
+ return out;
83
+ }
84
+
85
+ /**
86
+ * @typedef {Object} FetchListResultSuccess
87
+ * @property {true} ok
88
+ * @property {Array<{ id: string, updated_at: number, closed_at: number | null } & Record<string, unknown>>} items
89
+ */
90
+
91
+ /**
92
+ * @typedef {Object} FetchListResultFailure
93
+ * @property {false} ok
94
+ * @property {{ code: string, message: string, details?: Record<string, unknown> }} error
95
+ */
96
+
97
+ /**
98
+ * Execute the mapped `bd` command for a subscription spec and return normalized items.
99
+ * Errors do not throw; they are surfaced as a structured object.
100
+ *
101
+ * @param {{ type: string, params?: Record<string, string | number | boolean> }} spec
102
+ * @returns {Promise<FetchListResultSuccess | FetchListResultFailure>}
103
+ */
104
+ export async function fetchListForSubscription(spec) {
105
+ /** @type {string[]} */
106
+ let args;
107
+ try {
108
+ args = mapSubscriptionToBdArgs(spec);
109
+ } catch (err) {
110
+ const e = toErrorObject(err);
111
+ return { ok: false, error: e };
112
+ }
113
+
114
+ try {
115
+ const res = await runBdJson(args);
116
+ if (!res || res.code !== 0 || !('stdoutJson' in res)) {
117
+ return {
118
+ ok: false,
119
+ error: {
120
+ code: 'bd_error',
121
+ message: String(res?.stderr || 'bd failed'),
122
+ details: { exit_code: res?.code ?? -1 }
123
+ }
124
+ };
125
+ }
126
+ // bd show may return a single object; normalize to an array first
127
+ let raw = Array.isArray(res.stdoutJson)
128
+ ? res.stdoutJson
129
+ : res.stdoutJson && typeof res.stdoutJson === 'object'
130
+ ? [res.stdoutJson]
131
+ : [];
132
+
133
+ // Special-case mapping for `epics`: current bd output nests the epic under
134
+ // an `epic` key and exposes counters at the top level. Flatten so that
135
+ // each entry has a top-level `id` and core fields expected by the registry.
136
+ if (String(spec.type) === 'epics') {
137
+ raw = raw.map((it) => {
138
+ if (it && typeof it === 'object' && 'epic' in it) {
139
+ const e = /** @type {any} */ (it).epic || {};
140
+ /** @type {Record<string, unknown>} */
141
+ const flat = {
142
+ // Required minimal fields for registry + client rendering
143
+ id: String(e.id ?? ''),
144
+ title: e.title,
145
+ status: e.status,
146
+ issue_type: e.issue_type || 'epic',
147
+ created_at: e.created_at,
148
+ updated_at: e.updated_at,
149
+ closed_at: e.closed_at ?? null,
150
+ // Preserve useful counters from bd output
151
+ total_children: /** @type {any} */ (it).total_children,
152
+ closed_children: /** @type {any} */ (it).closed_children,
153
+ eligible_for_close: /** @type {any} */ (it).eligible_for_close
154
+ };
155
+ return flat;
156
+ }
157
+ return it;
158
+ });
159
+ }
160
+
161
+ const items = normalizeIssueList(raw);
162
+ return { ok: true, items };
163
+ } catch (err) {
164
+ return {
165
+ ok: false,
166
+ error: {
167
+ code: 'bd_error',
168
+ message:
169
+ (err && /** @type {any} */ (err).message) || 'bd invocation failed'
170
+ }
171
+ };
172
+ }
173
+ }
174
+
175
+ /**
176
+ * Create a `bad_request` error object.
177
+ *
178
+ * @param {string} message
179
+ */
180
+ function badRequest(message) {
181
+ const e = new Error(message);
182
+ // @ts-expect-error add code
183
+ e.code = 'bad_request';
184
+ return e;
185
+ }
186
+
187
+ /**
188
+ * Normalize arbitrary thrown values to a structured error object.
189
+ *
190
+ * @param {unknown} err
191
+ * @returns {FetchListResultFailure['error']}
192
+ */
193
+ function toErrorObject(err) {
194
+ if (err && typeof err === 'object') {
195
+ const any = /** @type {{ code?: unknown, message?: unknown }} */ (err);
196
+ const code = typeof any.code === 'string' ? any.code : 'bad_request';
197
+ const message =
198
+ typeof any.message === 'string' ? any.message : 'Request error';
199
+ return { code, message };
200
+ }
201
+ return { code: 'bad_request', message: 'Request error' };
202
+ }
203
+
204
+ /**
205
+ * Parse a bd timestamp string to epoch ms using Date.parse.
206
+ * Falls back to numeric coercion when parsing fails.
207
+ *
208
+ * @param {unknown} v
209
+ * @returns {number}
210
+ */
211
+ function parseTimestamp(v) {
212
+ if (typeof v === 'string') {
213
+ const ms = Date.parse(v);
214
+ if (Number.isFinite(ms)) {
215
+ return ms;
216
+ }
217
+ const n = Number(v);
218
+ return Number.isFinite(n) ? n : 0;
219
+ }
220
+ if (typeof v === 'number') {
221
+ return Number.isFinite(v) ? v : 0;
222
+ }
223
+ return 0;
224
+ }