@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/app/main.bundle.js +119 -115
- package/app/main.bundle.js.map +3 -3
- package/package.json +1 -2
- package/server/beads-reader.js +70 -146
- package/server/project-routes.js +38 -9
- package/server/ws-beads-watcher.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/ui",
|
|
3
|
-
"version": "0.1.
|
|
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/beads-reader.js
CHANGED
|
@@ -1,199 +1,123 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
1
2
|
import { existsSync } from 'node:fs';
|
|
2
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
}
|
package/server/project-routes.js
CHANGED
|
@@ -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 =
|
|
680
|
-
|
|
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 = '
|
|
693
|
-
st.stop_reason = '
|
|
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 =
|
|
11
|
+
const BEADS_DEBOUNCE_MS = 300;
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* @param {{ worcaDir: string, broadcaster: { broadcast: Function }, projectId?: string }} deps
|