@worca/ui 0.19.0 → 0.20.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/app/styles.css CHANGED
@@ -3436,29 +3436,27 @@ sl-details.learnings-panel::part(content) {
3436
3436
  align-items: center;
3437
3437
  }
3438
3438
 
3439
- .pr-details-section {
3440
- margin-top: 8px;
3441
- }
3442
-
3443
- .pr-details-table {
3444
- border-collapse: collapse;
3439
+ .pr-info-strip {
3440
+ display: flex;
3441
+ flex-wrap: wrap;
3442
+ gap: 4px 20px;
3445
3443
  font-size: 13px;
3446
- width: 100%;
3447
- }
3448
-
3449
- .pr-details-table td {
3450
- padding: 3px 8px 3px 0;
3451
- vertical-align: middle;
3444
+ color: var(--muted);
3445
+ margin-top: 8px;
3446
+ padding-top: 8px;
3447
+ border-top: 1px solid var(--border-subtle);
3448
+ align-items: center;
3452
3449
  }
3453
3450
 
3454
- .pr-details-table td.meta-label {
3451
+ .pr-info-item {
3452
+ display: inline-flex;
3453
+ align-items: center;
3454
+ gap: 4px;
3455
3455
  white-space: nowrap;
3456
- width: 1%;
3457
- padding-right: 10px;
3458
3456
  }
3459
3457
 
3460
- .pr-commit-cell {
3461
- display: flex;
3458
+ .pr-info-strip .run-pr-link {
3459
+ display: inline-flex;
3462
3460
  align-items: center;
3463
3461
  gap: 4px;
3464
3462
  }
@@ -3472,13 +3470,8 @@ sl-details.learnings-panel::part(content) {
3472
3470
  border: 1px solid var(--border-subtle);
3473
3471
  }
3474
3472
 
3475
- .pr-branch-flow {
3476
- font-family: monospace;
3477
- font-size: 12px;
3478
- }
3479
-
3480
3473
  .pr-title-badge {
3481
- margin-left: 6px;
3474
+ /* spacing handled by parent .pipeline-stage-header gap */
3482
3475
  }
3483
3476
 
3484
3477
  .classification-strip {
@@ -14,6 +14,8 @@ export const STAGE_ORDER = [
14
14
  'learn',
15
15
  ];
16
16
 
17
+ export const STAGE_VALUES = new Set(STAGE_ORDER);
18
+
17
19
  /** Stage order with orchestrator prepended (for log display). */
18
20
  export const STAGE_ORDER_WITH_ORCHESTRATOR = ['orchestrator', ...STAGE_ORDER];
19
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.19.0",
3
+ "version": "0.20.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -0,0 +1,43 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * Ensure the bd daemon is running for the project at worcaDir.
10
+ * Best-effort — all errors swallowed.
11
+ *
12
+ * Probes `bd daemon status` first. The `daemon.stopped` sentinel only blocks
13
+ * auto-start; if the daemon is already running (e.g. started manually outside
14
+ * worca), we report it as up regardless of the sentinel.
15
+ */
16
+ export async function ensureBdDaemon(worcaDir) {
17
+ const beadsDir = resolve(join(worcaDir, '..', '.beads'));
18
+ if (!existsSync(beadsDir)) return false;
19
+
20
+ const workspaceDir = dirname(beadsDir);
21
+ const opts = {
22
+ encoding: 'utf8',
23
+ timeout: 5000,
24
+ env: { ...process.env, BEADS_DIR: beadsDir },
25
+ cwd: workspaceDir,
26
+ };
27
+
28
+ try {
29
+ await execFileAsync('bd', ['daemon', 'status'], opts);
30
+ return true;
31
+ } catch {
32
+ // not running — sentinel may block auto-start below
33
+ }
34
+
35
+ if (existsSync(join(beadsDir, 'daemon.stopped'))) return false;
36
+
37
+ try {
38
+ await execFileAsync('bd', ['daemon', 'start'], opts);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
@@ -49,7 +49,10 @@ export function dbExists(beadsDb) {
49
49
  export async function listIssues(beadsDb) {
50
50
  try {
51
51
  const issues = await runBd(['list', '--limit', '0'], beadsDb);
52
- return enrichWithDeps(issues, beadsDb);
52
+ // Must await here — without it, an enrichWithDeps rejection (e.g. bd show
53
+ // SIGTERM under daemon contention) escapes the try/catch and propagates
54
+ // to the WS handler as an unhandled rejection, crashing Node.
55
+ return await enrichWithDeps(issues, beadsDb);
53
56
  } catch {
54
57
  return [];
55
58
  }
@@ -61,7 +64,7 @@ export async function listIssuesByLabel(beadsDb, label) {
61
64
  ['list', '--label-any', label, '--all', '--limit', '0'],
62
65
  beadsDb,
63
66
  );
64
- return enrichWithDeps(issues, beadsDb);
67
+ return await enrichWithDeps(issues, beadsDb);
65
68
  } catch {
66
69
  return [];
67
70
  }
@@ -92,37 +95,48 @@ export async function listUnlinkedIssues(beadsDb) {
92
95
  /**
93
96
  * Returns { runId: { total, done } } for every run:<id> label in the beads db.
94
97
  *
95
- * `total` comes from the cheap `bd label list-all` count. `done` requires
96
- * looking at issue status, so we query `bd list --label-any run:<id>` per
97
- * run and count statuses === "closed". N+1 queries, but N is bounded by
98
- * the number of pipeline runs and this endpoint is called on app load /
99
- * project switch only, not on every render.
98
+ * Single-pass: bd label list-all (totals) + bd list --all (ids) +
99
+ * bd show <all-ids> (labels + statuses), then group by run labels in JS.
100
+ * Always 3 bd calls regardless of run-label count.
101
+ *
102
+ * Called by the beads watcher on every db change (counts are included in the
103
+ * broadcast payload) and by the list-beads-counts endpoint for initial load
104
+ * and project switch.
100
105
  */
101
106
  export async function countIssuesByRunLabel(beadsDb) {
102
107
  try {
103
108
  const rows = await runBd(['label', 'list-all'], beadsDb);
104
109
  const counts = {};
105
110
  const runLabels = rows.filter((r) => r.label.startsWith('run:'));
111
+ if (runLabels.length === 0) return counts;
106
112
  for (const row of runLabels) {
107
113
  counts[row.label.replace('run:', '')] = { total: row.count, done: 0 };
108
114
  }
109
- // Count closed issues per label in parallel.
110
- await Promise.all(
111
- runLabels.map(async (row) => {
112
- const runId = row.label.replace('run:', '');
113
- try {
114
- const issues = await runBd(
115
- ['list', '--label-any', row.label, '--all', '--limit', '0'],
116
- beadsDb,
117
- );
118
- counts[runId].done = issues.filter(
119
- (i) => i.status === 'closed',
120
- ).length;
121
- } catch {
122
- /* leave done at 0 on per-run failure */
115
+ try {
116
+ const issues = await runBd(['list', '--all', '--limit', '0'], beadsDb);
117
+ if (issues.length === 0) return counts;
118
+ const detailed = await runBd(
119
+ ['show', ...issues.map((i) => i.id)],
120
+ beadsDb,
121
+ );
122
+ for (const issue of detailed) {
123
+ if (issue.status !== 'closed') continue;
124
+ for (const label of issue.labels || []) {
125
+ if (label.startsWith('run:')) {
126
+ const runId = label.replace('run:', '');
127
+ if (counts[runId]) counts[runId].done++;
128
+ }
123
129
  }
124
- }),
125
- );
130
+ }
131
+ } catch (err) {
132
+ // Leave done=0 for all runs on list/show failure. Logged so a stale
133
+ // "0/N" badge can be traced back to bd subprocess timeout (typically
134
+ // daemon contention) rather than mistaken for "no closed issues."
135
+ // The next watcher tick recomputes and corrects.
136
+ console.warn(
137
+ `[countIssuesByRunLabel] bd list/show failed; counts.done left at 0: ${err?.message || err}`,
138
+ );
139
+ }
126
140
  return counts;
127
141
  } catch {
128
142
  return {};
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { existsSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
+ import { ensureBdDaemon } from './bd-daemon.js';
13
14
  import { resolveRunDir } from './run-dir-resolver.js';
14
15
  import { createBeadsWatcher } from './ws-beads-watcher.js';
15
16
  import { createEventWatcher } from './ws-event-watcher.js';
@@ -146,6 +147,7 @@ export class WatcherSet {
146
147
 
147
148
  // Beads watcher
148
149
  if (!this.beadsWatcher) {
150
+ ensureBdDaemon(worcaDir).catch(() => {});
149
151
  try {
150
152
  this.beadsWatcher = this._factories.createBeadsWatcher({
151
153
  worcaDir,
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { existsSync, unwatchFile, watch, watchFile } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
- import { listIssues } from './beads-reader.js';
9
+ import { countIssuesByRunLabel, listIssues } from './beads-reader.js';
10
10
 
11
11
  const BEADS_DEBOUNCE_MS = 500;
12
12
  const BEADS_POLL_MS = 2000;
@@ -26,11 +26,15 @@ export function createBeadsWatcher({ worcaDir, broadcaster, projectId }) {
26
26
  BEADS_REFRESH_TIMER = setTimeout(async () => {
27
27
  BEADS_REFRESH_TIMER = null;
28
28
  try {
29
- const issues = await listIssues(beadsDbPath);
29
+ const [issues, counts] = await Promise.all([
30
+ listIssues(beadsDbPath),
31
+ countIssuesByRunLabel(beadsDbPath).catch(() => ({})),
32
+ ]);
30
33
  broadcaster.broadcast(
31
34
  'beads-update',
32
35
  {
33
36
  issues,
37
+ counts,
34
38
  dbExists: true,
35
39
  dbPath: beadsDbPath,
36
40
  },