@worca/ui 0.1.0-rc.6 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.1.0-rc.6",
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",
@@ -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
  }
@@ -676,21 +676,17 @@ export function createProjectScopedRoutes() {
676
676
  res.json({ ok: true, stopped: true, runId, pid: result.pid });
677
677
  } catch (err) {
678
678
  if (err.code === 'not_running') {
679
- const statusPath = join(
680
- req.project.worcaDir,
681
- 'runs',
682
- runId,
683
- 'status.json',
684
- );
685
- if (existsSync(statusPath)) {
679
+ const statusPath = findRunStatusPath(req.project.worcaDir, runId);
680
+ if (statusPath) {
686
681
  try {
687
682
  const st = JSON.parse(readFileSync(statusPath, 'utf8'));
688
683
  if (
689
684
  st.pipeline_status === 'paused' ||
690
685
  st.pipeline_status === 'running'
691
686
  ) {
692
- st.pipeline_status = 'failed';
693
- st.stop_reason = 'stopped';
687
+ st.pipeline_status = 'cancelled';
688
+ st.stop_reason = 'force_cancelled';
689
+ st.completed_at = new Date().toISOString();
694
690
  writeFileSync(
695
691
  statusPath,
696
692
  `${JSON.stringify(st, null, 2)}\n`,
@@ -710,6 +706,39 @@ export function createProjectScopedRoutes() {
710
706
  }
711
707
  });
712
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
+
713
742
  // POST /api/projects/:projectId/runs/:id/archive
714
743
  router.post('/runs/:id/archive', requireWorcaDir, (req, res) => {
715
744
  const runId = req.params.id;
@@ -8,7 +8,7 @@ import { existsSync, watch } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
9
  import { listIssues } from './beads-reader.js';
10
10
 
11
- const BEADS_DEBOUNCE_MS = 200;
11
+ const BEADS_DEBOUNCE_MS = 300;
12
12
 
13
13
  /**
14
14
  * @param {{ worcaDir: string, broadcaster: { broadcast: Function }, projectId?: string }} deps