@worca/ui 0.1.0-rc.5 → 0.1.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/app/styles.css CHANGED
@@ -3895,3 +3895,22 @@ sl-details.learnings-panel::part(content) {
3895
3895
  color: var(--fg);
3896
3896
  }
3897
3897
 
3898
+ /* ─── Version info rows ─────────────────────────────────────────────── */
3899
+ .version-row { display: flex; align-items: center; padding: 4px 0; gap: 8px; }
3900
+ .version-row-label { font-size: 12px; color: var(--muted); white-space: nowrap; min-width: 64px; }
3901
+ .version-row-value { font-size: 13px; font-weight: 500; color: var(--fg); font-family: var(--sl-font-mono); white-space: nowrap; margin-left: auto; display: flex; align-items: center; gap: 6px; }
3902
+ .settings-card-header > sl-badge { margin-left: auto; }
3903
+ .version-title-exact { text-transform: none; }
3904
+ .version-copy-btn {
3905
+ background: none; border: 1px solid var(--border); border-radius: 4px;
3906
+ padding: 2px 4px; cursor: pointer; color: var(--muted);
3907
+ display: inline-flex; align-items: center; margin-left: 4px;
3908
+ transition: var(--transition-fast);
3909
+ }
3910
+ .version-copy-btn:hover { background: var(--bg-tertiary); color: var(--fg); }
3911
+ .version-copy-icon { display: inline-flex; align-items: center; min-width: 12px; }
3912
+ .version-refresh { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
3913
+ .version-refresh-hint { font-size: 11px; color: var(--muted); }
3914
+ .project-worca-version { font-size: 11px; color: var(--muted); font-family: var(--sl-font-mono); margin-top: 2px; }
3915
+ .project-worca-version--behind { color: var(--status-failed, #dc2626); }
3916
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.1.0-rc.5",
3
+ "version": "0.1.1",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -56,7 +56,6 @@
56
56
  "@xterm/addon-fit": "^0.11.0",
57
57
  "@xterm/addon-search": "^0.16.0",
58
58
  "@xterm/xterm": "^6.0.0",
59
- "better-sqlite3": "^12.6.2",
60
59
  "express": "^5.2.1",
61
60
  "lit-html": "^3.3.1",
62
61
  "lucide": "^0.577.0",
package/server/app.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  createProjectScopedRoutes,
14
14
  projectResolver,
15
15
  } from './project-routes.js';
16
+ import { getVersionInfo } from './versions.js';
16
17
  import { createInbox } from './webhook-inbox.js';
17
18
 
18
19
  export function createApp(options = {}) {
@@ -403,6 +404,19 @@ export function createApp(options = {}) {
403
404
  }
404
405
  });
405
406
 
407
+ // GET /api/versions — installed + registry version info
408
+ app.get('/api/versions', async (req, res) => {
409
+ const force = req.query.force === '1';
410
+ const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
411
+ const worcaVersion = app.locals.worcaVersion || null;
412
+ try {
413
+ const data = await getVersionInfo({ prefsPath, worcaVersion, force });
414
+ res.json(data);
415
+ } catch (err) {
416
+ res.status(500).json({ ok: false, error: err.message });
417
+ }
418
+ });
419
+
406
420
  // ─── Multi-project routes ──────────────────────────────────────────────
407
421
  if (prefsDir) {
408
422
  app.use('/api/projects', createProjectRoutes({ prefsDir, projectRoot }));
@@ -1,199 +1,123 @@
1
+ import { execFileSync } from 'node:child_process';
1
2
  import { existsSync } from 'node:fs';
2
- import Database from 'better-sqlite3';
3
+
4
+ function runBd(args, dbPath) {
5
+ const fullArgs = [...args, '--json', '--db', dbPath, '--readonly'];
6
+ const stdout = execFileSync('bd', fullArgs, {
7
+ encoding: 'utf8',
8
+ timeout: 10000,
9
+ maxBuffer: 10 * 1024 * 1024,
10
+ stdio: ['ignore', 'pipe', 'pipe'],
11
+ });
12
+ return JSON.parse(stdout);
13
+ }
14
+
15
+ function transformIssue(issue, deps) {
16
+ const depends_on = (deps || []).map((d) => d.id);
17
+ const blocked_by = (deps || [])
18
+ .filter((d) => d.status !== 'closed' && d.status !== 'tombstone')
19
+ .map((d) => d.id);
20
+ return {
21
+ id: issue.id,
22
+ title: issue.title,
23
+ body: issue.description || '',
24
+ status: issue.status,
25
+ priority: issue.priority,
26
+ created_at: issue.created_at || '',
27
+ external_ref: issue.external_ref || null,
28
+ depends_on,
29
+ blocked_by,
30
+ };
31
+ }
32
+
33
+ function enrichWithDeps(issues, dbPath) {
34
+ const needDeps = issues.filter((i) => i.dependency_count > 0);
35
+ if (needDeps.length === 0) {
36
+ return issues.map((i) => transformIssue(i, []));
37
+ }
38
+ const detailed = runBd(['show', ...needDeps.map((i) => i.id)], dbPath);
39
+ const depMap = new Map(detailed.map((d) => [d.id, d.dependencies || []]));
40
+ return issues.map((i) => transformIssue(i, depMap.get(i.id) || []));
41
+ }
3
42
 
4
43
  export function dbExists(beadsDb) {
5
44
  return existsSync(beadsDb);
6
45
  }
7
46
 
8
47
  export function listIssues(beadsDb) {
9
- let db;
10
48
  try {
11
- db = new Database(beadsDb, { readonly: true, fileMustExist: true });
12
- const rows = db
13
- .prepare(
14
- `SELECT id, title, description AS body, status, priority, created_at, external_ref
15
- FROM issues
16
- WHERE status NOT IN ('closed','tombstone')
17
- ORDER BY priority ASC, id ASC`,
18
- )
19
- .all();
20
-
21
- const depStmt = db.prepare(
22
- `SELECT depends_on_id FROM dependencies WHERE issue_id = ?`,
23
- );
24
- const statusMap = new Map(rows.map((r) => [r.id, r.status]));
25
-
26
- return rows.map((row) => {
27
- const depends_on = depStmt.all(row.id).map((d) => d.depends_on_id);
28
- const blocked_by = depends_on.filter((depId) => statusMap.has(depId));
29
- return { ...row, depends_on, blocked_by };
30
- });
49
+ const issues = runBd(['list', '--limit', '0'], beadsDb);
50
+ return enrichWithDeps(issues, beadsDb);
31
51
  } catch {
32
52
  return [];
33
- } finally {
34
- try {
35
- db?.close();
36
- } catch {
37
- /* ignore */
38
- }
39
53
  }
40
54
  }
41
55
 
42
56
  export function listIssuesByLabel(beadsDb, label) {
43
- let db;
44
57
  try {
45
- db = new Database(beadsDb, { readonly: true, fileMustExist: true });
46
- const rows = db
47
- .prepare(
48
- `SELECT i.id, i.title, i.description AS body, i.status, i.priority, i.created_at
49
- FROM issues i
50
- JOIN labels l ON l.issue_id = i.id
51
- WHERE l.label = ?
52
- ORDER BY i.priority ASC, i.id ASC`,
53
- )
54
- .all(label);
55
-
56
- const depStmt = db.prepare(
57
- `SELECT depends_on_id FROM dependencies WHERE issue_id = ?`,
58
+ const issues = runBd(
59
+ ['list', '--label-any', label, '--all', '--limit', '0'],
60
+ beadsDb,
58
61
  );
59
- const statusMap = new Map(rows.map((r) => [r.id, r.status]));
60
-
61
- return rows.map((row) => {
62
- const depends_on = depStmt.all(row.id).map((d) => d.depends_on_id);
63
- const blocked_by = depends_on.filter((depId) => {
64
- const s = statusMap.get(depId);
65
- return s && s !== 'closed';
66
- });
67
- return { ...row, depends_on, blocked_by };
68
- });
62
+ return enrichWithDeps(issues, beadsDb);
69
63
  } catch {
70
64
  return [];
71
- } finally {
72
- try {
73
- db?.close();
74
- } catch {
75
- /* ignore */
76
- }
77
65
  }
78
66
  }
79
67
 
80
68
  export function listUnlinkedIssues(beadsDb) {
81
- let db;
82
69
  try {
83
- db = new Database(beadsDb, { readonly: true, fileMustExist: true });
84
- const rows = db
85
- .prepare(
86
- `SELECT i.id, i.title, i.description AS body, i.status, i.priority, i.created_at
87
- FROM issues i
88
- WHERE NOT EXISTS (
89
- SELECT 1 FROM labels l WHERE l.issue_id = i.id AND l.label LIKE 'run:%'
90
- )
91
- AND i.status NOT IN ('closed','tombstone')
92
- ORDER BY i.priority ASC, i.id ASC`,
93
- )
94
- .all();
95
-
96
- const depStmt = db.prepare(
97
- `SELECT depends_on_id FROM dependencies WHERE issue_id = ?`,
98
- );
99
- const statusMap = new Map(rows.map((r) => [r.id, r.status]));
100
-
101
- return rows.map((row) => {
102
- const depends_on = depStmt.all(row.id).map((d) => d.depends_on_id);
103
- const blocked_by = depends_on.filter((depId) => statusMap.has(depId));
104
- return { ...row, depends_on, blocked_by };
70
+ const issues = runBd(['list', '--limit', '0'], beadsDb);
71
+ if (issues.length === 0) return [];
72
+ // bd list doesn't include labels — use bd show to get them
73
+ const detailed = runBd(['show', ...issues.map((i) => i.id)], beadsDb);
74
+ const detailMap = new Map(detailed.map((d) => [d.id, d]));
75
+ const unlinked = issues.filter((i) => {
76
+ const d = detailMap.get(i.id);
77
+ const labels = d?.labels || [];
78
+ return !labels.some((l) => l.startsWith('run:'));
79
+ });
80
+ // detailed already has dependencies, use them directly
81
+ return unlinked.map((i) => {
82
+ const d = detailMap.get(i.id);
83
+ return transformIssue(i, d?.dependencies || []);
105
84
  });
106
85
  } catch {
107
86
  return [];
108
- } finally {
109
- try {
110
- db?.close();
111
- } catch {
112
- /* ignore */
113
- }
114
87
  }
115
88
  }
116
89
 
117
90
  export function countIssuesByRunLabel(beadsDb) {
118
- let db;
119
91
  try {
120
- db = new Database(beadsDb, { readonly: true, fileMustExist: true });
121
- const rows = db
122
- .prepare(
123
- `SELECT l.label, COUNT(*) AS count FROM labels l
124
- WHERE l.label LIKE 'run:%' GROUP BY l.label`,
125
- )
126
- .all();
92
+ const rows = runBd(['label', 'list-all'], beadsDb);
127
93
  const counts = {};
128
94
  for (const row of rows) {
129
- const runId = row.label.replace('run:', '');
130
- counts[runId] = row.count;
95
+ if (row.label.startsWith('run:')) {
96
+ counts[row.label.replace('run:', '')] = row.count;
97
+ }
131
98
  }
132
99
  return counts;
133
100
  } catch {
134
101
  return {};
135
- } finally {
136
- try {
137
- db?.close();
138
- } catch {
139
- /* ignore */
140
- }
141
102
  }
142
103
  }
143
104
 
144
105
  export function listDistinctRunLabels(beadsDb) {
145
- let db;
146
106
  try {
147
- db = new Database(beadsDb, { readonly: true, fileMustExist: true });
148
- const rows = db
149
- .prepare(`SELECT DISTINCT label FROM labels WHERE label LIKE 'run:%'`)
150
- .all();
151
- return rows.map((r) => r.label);
107
+ const rows = runBd(['label', 'list-all'], beadsDb);
108
+ return rows.filter((r) => r.label.startsWith('run:')).map((r) => r.label);
152
109
  } catch {
153
110
  return [];
154
- } finally {
155
- try {
156
- db?.close();
157
- } catch {
158
- /* ignore */
159
- }
160
111
  }
161
112
  }
162
113
 
163
114
  export function getIssue(beadsDb, id) {
164
- let db;
165
115
  try {
166
- db = new Database(beadsDb, { readonly: true, fileMustExist: true });
167
- const row = db
168
- .prepare(
169
- `SELECT id, title, description AS body, status, priority, created_at, external_ref
170
- FROM issues WHERE id = ?`,
171
- )
172
- .get(id);
173
- if (!row) return null;
174
-
175
- const depends_on = db
176
- .prepare(`SELECT depends_on_id FROM dependencies WHERE issue_id = ?`)
177
- .all(id)
178
- .map((d) => d.depends_on_id);
179
-
180
- const blocked_by = [];
181
- for (const depId of depends_on) {
182
- const dep = db
183
- .prepare(`SELECT status FROM issues WHERE id = ?`)
184
- .get(depId);
185
- if (dep && dep.status !== 'closed' && dep.status !== 'tombstone') {
186
- blocked_by.push(depId);
187
- }
188
- }
189
- return { ...row, depends_on, blocked_by };
116
+ const results = runBd(['show', id], beadsDb);
117
+ if (!results || results.length === 0) return null;
118
+ const issue = results[0];
119
+ return transformIssue(issue, issue.dependencies || []);
190
120
  } catch {
191
121
  return null;
192
- } finally {
193
- try {
194
- db?.close();
195
- } catch {
196
- /* ignore */
197
- }
198
122
  }
199
123
  }
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname } from 'node:path';
3
3
 
4
- const DEFAULTS = { theme: 'light', source_repo: '' };
4
+ const DEFAULTS = { theme: 'light', source_repo: '', notifications: null };
5
5
 
6
6
  export function readPreferences(path) {
7
7
  try {
@@ -37,7 +37,11 @@ import {
37
37
  } from './settings-merge.js';
38
38
  import { validateSettingsPayload } from './settings-validator.js';
39
39
  import { discoverRuns } from './watcher.js';
40
- import { checkWorcaInstalled, runWorcaSetup } from './worca-setup.js';
40
+ import {
41
+ checkWorcaInstalled,
42
+ readProjectWorcaVersion,
43
+ runWorcaSetup,
44
+ } from './worca-setup.js';
41
45
 
42
46
  /** Validate a runId — must not contain path traversal characters */
43
47
  const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
@@ -133,13 +137,16 @@ export function createProjectRoutes({ prefsDir, projectRoot }) {
133
137
 
134
138
  // GET /api/projects — list all projects (or synthesized default)
135
139
  router.get('/', (_req, res) => {
136
- const projects = readProjects(prefsDir);
137
- if (projects.length > 0) {
138
- return res.json({ ok: true, projects });
139
- }
140
- // No registered projects synthesize from cwd
141
- const synth = synthesizeDefaultProject(projectRoot);
142
- res.json({ ok: true, projects: [synth] });
140
+ let projects = readProjects(prefsDir);
141
+ if (projects.length === 0) {
142
+ projects = [synthesizeDefaultProject(projectRoot)];
143
+ }
144
+ // Enrich each project with its worca-cc version
145
+ const enriched = projects.map((p) => ({
146
+ ...p,
147
+ worcaVersion: readProjectWorcaVersion(p.path),
148
+ }));
149
+ res.json({ ok: true, projects: enriched });
143
150
  });
144
151
 
145
152
  // POST /api/projects — create a new project
@@ -203,7 +210,17 @@ export function createProjectScopedRoutes() {
203
210
  router.get('/runs', requireWorcaDir, (req, res) => {
204
211
  try {
205
212
  const runs = discoverRuns(req.project.worcaDir);
206
- res.json({ ok: true, runs });
213
+ const response = { ok: true, runs };
214
+ // Include settings so multi-project clients can use loop limits, etc.
215
+ const { settingsPath } = req.project;
216
+ if (settingsPath && existsSync(settingsPath)) {
217
+ try {
218
+ response.settings = readMergedSettings(settingsPath);
219
+ } catch {
220
+ /* non-fatal — runs still returned */
221
+ }
222
+ }
223
+ res.json(response);
207
224
  } catch (err) {
208
225
  res.status(500).json({ ok: false, error: err.message });
209
226
  }
@@ -659,21 +676,17 @@ export function createProjectScopedRoutes() {
659
676
  res.json({ ok: true, stopped: true, runId, pid: result.pid });
660
677
  } catch (err) {
661
678
  if (err.code === 'not_running') {
662
- const statusPath = join(
663
- req.project.worcaDir,
664
- 'runs',
665
- runId,
666
- 'status.json',
667
- );
668
- if (existsSync(statusPath)) {
679
+ const statusPath = findRunStatusPath(req.project.worcaDir, runId);
680
+ if (statusPath) {
669
681
  try {
670
682
  const st = JSON.parse(readFileSync(statusPath, 'utf8'));
671
683
  if (
672
684
  st.pipeline_status === 'paused' ||
673
685
  st.pipeline_status === 'running'
674
686
  ) {
675
- st.pipeline_status = 'failed';
676
- st.stop_reason = 'stopped';
687
+ st.pipeline_status = 'cancelled';
688
+ st.stop_reason = 'force_cancelled';
689
+ st.completed_at = new Date().toISOString();
677
690
  writeFileSync(
678
691
  statusPath,
679
692
  `${JSON.stringify(st, null, 2)}\n`,
@@ -693,6 +706,39 @@ export function createProjectScopedRoutes() {
693
706
  }
694
707
  });
695
708
 
709
+ // POST /api/projects/:projectId/runs/:id/cancel — force-cancel a stale run
710
+ router.post('/runs/:id/cancel', requireWorcaDir, (req, res) => {
711
+ const runId = req.params.id;
712
+ if (!validateRunId(runId)) {
713
+ return res.status(400).json({ ok: false, error: 'Invalid runId' });
714
+ }
715
+ const { worcaDir } = req.project;
716
+ const statusPath = findRunStatusPath(worcaDir, runId);
717
+ if (!statusPath) {
718
+ return res
719
+ .status(404)
720
+ .json({ ok: false, error: `Run "${runId}" not found` });
721
+ }
722
+ try {
723
+ const st = JSON.parse(readFileSync(statusPath, 'utf8'));
724
+ if (
725
+ st.pipeline_status === 'completed' ||
726
+ st.pipeline_status === 'cancelled'
727
+ ) {
728
+ return res.json({ ok: true, already: st.pipeline_status });
729
+ }
730
+ st.pipeline_status = 'cancelled';
731
+ st.stop_reason = 'force_cancelled';
732
+ st.completed_at = new Date().toISOString();
733
+ writeFileSync(statusPath, `${JSON.stringify(st, null, 2)}\n`, 'utf8');
734
+ const { broadcast } = req.app.locals;
735
+ if (broadcast) broadcast('run-stopped', { runId, pid: null });
736
+ res.json({ ok: true, cancelled: true, runId });
737
+ } catch (err) {
738
+ res.status(500).json({ ok: false, error: err.message });
739
+ }
740
+ });
741
+
696
742
  // POST /api/projects/:projectId/runs/:id/archive
697
743
  router.post('/runs/:id/archive', requireWorcaDir, (req, res) => {
698
744
  const runId = req.params.id;