@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/main.bundle.js +600 -531
- package/app/main.bundle.js.map +3 -3
- package/app/styles.css +19 -0
- package/package.json +1 -2
- package/server/app.js +14 -0
- package/server/beads-reader.js +70 -146
- package/server/preferences.js +1 -1
- package/server/project-routes.js +64 -18
- package/server/versions.js +260 -0
- package/server/worca-setup.js +18 -1
- package/server/ws-beads-watcher.js +1 -1
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.
|
|
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 }));
|
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/preferences.js
CHANGED
|
@@ -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 {
|
package/server/project-routes.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
137
|
-
if (projects.length
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
//
|
|
141
|
-
const
|
|
142
|
-
|
|
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
|
-
|
|
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 =
|
|
663
|
-
|
|
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 = '
|
|
676
|
-
st.stop_reason = '
|
|
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;
|