@worca/ui 0.1.0-rc.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/index.html +23 -0
- package/app/main.bundle.js +5738 -0
- package/app/main.bundle.js.map +7 -0
- package/app/styles.css +3897 -0
- package/app/vendor/shoelace-dark.css +483 -0
- package/app/vendor/shoelace-light.css +484 -0
- package/app/vendor/xterm.css +285 -0
- package/bin/worca-ui.js +540 -0
- package/package.json +71 -0
- package/scripts/build-frontend.js +49 -0
- package/server/app.js +421 -0
- package/server/beads-reader.js +199 -0
- package/server/index.js +131 -0
- package/server/log-tailer.js +156 -0
- package/server/multi-watcher.js +237 -0
- package/server/preferences.js +17 -0
- package/server/process-manager.js +546 -0
- package/server/project-registry.js +145 -0
- package/server/project-routes.js +1265 -0
- package/server/settings-merge.js +83 -0
- package/server/settings-reader.js +23 -0
- package/server/settings-validator.js +506 -0
- package/server/watcher-set.js +286 -0
- package/server/watcher.js +357 -0
- package/server/webhook-inbox.js +59 -0
- package/server/worca-setup.js +114 -0
- package/server/ws-beads-watcher.js +62 -0
- package/server/ws-broadcaster.js +106 -0
- package/server/ws-client-manager.js +129 -0
- package/server/ws-event-watcher.js +124 -0
- package/server/ws-log-watcher.js +299 -0
- package/server/ws-message-router.js +870 -0
- package/server/ws-modular.js +309 -0
- package/server/ws-status-watcher.js +259 -0
- package/server/ws.js +5 -0
|
@@ -0,0 +1,1265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST API routes for multi-project management.
|
|
3
|
+
*
|
|
4
|
+
* - createProjectRoutes() → CRUD on /api/projects
|
|
5
|
+
* - projectResolver() → middleware for /api/projects/:projectId/...
|
|
6
|
+
* - createProjectScopedRoutes() → sub-routes under a resolved project
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
unlinkSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from 'node:fs';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { Router } from 'express';
|
|
21
|
+
import { dbExists, getIssue, listIssues } from './beads-reader.js';
|
|
22
|
+
import { ProcessManager } from './process-manager.js';
|
|
23
|
+
import {
|
|
24
|
+
readProjects,
|
|
25
|
+
removeProject,
|
|
26
|
+
SLUG_RE,
|
|
27
|
+
synthesizeDefaultProject,
|
|
28
|
+
validateProjectEntry,
|
|
29
|
+
writeProject,
|
|
30
|
+
} from './project-registry.js';
|
|
31
|
+
import {
|
|
32
|
+
localPathFor,
|
|
33
|
+
readLocalSettings,
|
|
34
|
+
readMergedSettings,
|
|
35
|
+
} from './settings-merge.js';
|
|
36
|
+
import { validateSettingsPayload } from './settings-validator.js';
|
|
37
|
+
import { discoverRuns } from './watcher.js';
|
|
38
|
+
import { checkWorcaInstalled, runWorcaSetup } from './worca-setup.js';
|
|
39
|
+
|
|
40
|
+
/** Validate a runId — must not contain path traversal characters */
|
|
41
|
+
const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
42
|
+
function validateRunId(runId) {
|
|
43
|
+
return (
|
|
44
|
+
typeof runId === 'string' &&
|
|
45
|
+
runId.length > 0 &&
|
|
46
|
+
runId.length <= 128 &&
|
|
47
|
+
RUN_ID_RE.test(runId)
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Find the status.json path for a given run ID.
|
|
53
|
+
* Searches: runs/{id}/status.json → results/{id}/status.json → results/{id}.json
|
|
54
|
+
* Returns the first existing path, or null if none found.
|
|
55
|
+
*/
|
|
56
|
+
export function findRunStatusPath(worcaDir, runId) {
|
|
57
|
+
const candidates = [
|
|
58
|
+
join(worcaDir, 'runs', runId, 'status.json'),
|
|
59
|
+
join(worcaDir, 'results', runId, 'status.json'),
|
|
60
|
+
join(worcaDir, 'results', `${runId}.json`),
|
|
61
|
+
];
|
|
62
|
+
for (const p of candidates) {
|
|
63
|
+
if (existsSync(p)) return p;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Validate a branch name — alphanumeric, dots, hyphens, underscores, slashes */
|
|
69
|
+
const BRANCH_RE = /^[\w.\-/]+$/;
|
|
70
|
+
function validateBranch(branch) {
|
|
71
|
+
return (
|
|
72
|
+
typeof branch === 'string' && branch.length <= 200 && BRANCH_RE.test(branch)
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Validate a plan file path — relative, no traversal */
|
|
77
|
+
function validatePlanFile(planFile) {
|
|
78
|
+
if (typeof planFile !== 'string' || planFile.trim().length === 0)
|
|
79
|
+
return false;
|
|
80
|
+
const normalized = planFile.trim();
|
|
81
|
+
if (normalized.startsWith('/') || normalized.includes('..')) return false;
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Middleware that resolves :projectId to a project entry and attaches it to req.project.
|
|
87
|
+
* Falls back to synthesized default if no projects.d/ exists.
|
|
88
|
+
*/
|
|
89
|
+
export function projectResolver({ prefsDir, projectRoot }) {
|
|
90
|
+
return (req, res, next) => {
|
|
91
|
+
const projectId = req.params.projectId;
|
|
92
|
+
const projects = readProjects(prefsDir);
|
|
93
|
+
|
|
94
|
+
let project;
|
|
95
|
+
if (projects.length > 0) {
|
|
96
|
+
project = projects.find((p) => p.name === projectId);
|
|
97
|
+
} else {
|
|
98
|
+
// Single-project mode — synthesize from projectRoot
|
|
99
|
+
const synth = synthesizeDefaultProject(projectRoot);
|
|
100
|
+
if (synth.name === projectId) {
|
|
101
|
+
project = synth;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!project) {
|
|
106
|
+
return res
|
|
107
|
+
.status(404)
|
|
108
|
+
.json({ ok: false, error: `Project "${projectId}" not found` });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const worcaDir = project.worcaDir || join(project.path, '.worca');
|
|
112
|
+
const projRoot = project.path;
|
|
113
|
+
req.project = {
|
|
114
|
+
name: project.name,
|
|
115
|
+
path: project.path,
|
|
116
|
+
worcaDir,
|
|
117
|
+
settingsPath:
|
|
118
|
+
project.settingsPath || join(project.path, '.claude', 'settings.json'),
|
|
119
|
+
projectRoot: projRoot,
|
|
120
|
+
pm: new ProcessManager({ worcaDir, projectRoot: projRoot }),
|
|
121
|
+
};
|
|
122
|
+
next();
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Router for project CRUD: GET/POST/DELETE /api/projects[/:id]
|
|
128
|
+
*/
|
|
129
|
+
export function createProjectRoutes({ prefsDir, projectRoot }) {
|
|
130
|
+
const router = Router();
|
|
131
|
+
|
|
132
|
+
// GET /api/projects — list all projects (or synthesized default)
|
|
133
|
+
router.get('/', (_req, res) => {
|
|
134
|
+
const projects = readProjects(prefsDir);
|
|
135
|
+
if (projects.length > 0) {
|
|
136
|
+
return res.json({ ok: true, projects });
|
|
137
|
+
}
|
|
138
|
+
// No registered projects — synthesize from cwd
|
|
139
|
+
const synth = synthesizeDefaultProject(projectRoot);
|
|
140
|
+
res.json({ ok: true, projects: [synth] });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// POST /api/projects — create a new project
|
|
144
|
+
router.post('/', (req, res) => {
|
|
145
|
+
const entry = req.body;
|
|
146
|
+
const validation = validateProjectEntry(entry);
|
|
147
|
+
if (!validation.valid) {
|
|
148
|
+
return res.status(400).json({ ok: false, error: validation.error });
|
|
149
|
+
}
|
|
150
|
+
if (!existsSync(entry.path)) {
|
|
151
|
+
return res
|
|
152
|
+
.status(400)
|
|
153
|
+
.json({ ok: false, error: `directory does not exist: ${entry.path}` });
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
writeProject(prefsDir, entry);
|
|
157
|
+
res.status(201).json({ ok: true, project: entry });
|
|
158
|
+
} catch (err) {
|
|
159
|
+
res.status(400).json({ ok: false, error: err.message });
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// DELETE /api/projects/:id — remove a project
|
|
164
|
+
router.delete('/:id', (req, res) => {
|
|
165
|
+
const id = req.params.id;
|
|
166
|
+
if (!SLUG_RE.test(id)) {
|
|
167
|
+
return res.status(400).json({ ok: false, error: 'Invalid project id' });
|
|
168
|
+
}
|
|
169
|
+
removeProject(prefsDir, id);
|
|
170
|
+
res.json({ ok: true, removed: id });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
return router;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Router for project-scoped sub-routes.
|
|
178
|
+
* The projectResolver middleware must run before this to set req.project.
|
|
179
|
+
*/
|
|
180
|
+
export function createProjectScopedRoutes() {
|
|
181
|
+
const router = Router({ mergeParams: true });
|
|
182
|
+
|
|
183
|
+
// Guard: run-related, cost, and pipeline routes require worcaDir
|
|
184
|
+
function requireWorcaDir(req, res, next) {
|
|
185
|
+
if (!req.project?.worcaDir) {
|
|
186
|
+
return res
|
|
187
|
+
.status(501)
|
|
188
|
+
.json({ ok: false, error: 'worcaDir not configured' });
|
|
189
|
+
}
|
|
190
|
+
next();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// GET /api/projects/:projectId/info — project metadata
|
|
194
|
+
// Note: Plan specified /project-info but /info is preferred since
|
|
195
|
+
// the route is already scoped under /api/projects/:projectId/.
|
|
196
|
+
router.get('/info', (req, res) => {
|
|
197
|
+
res.json({ ok: true, project: req.project });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// GET /api/projects/:projectId/runs — list runs for this project
|
|
201
|
+
router.get('/runs', requireWorcaDir, (req, res) => {
|
|
202
|
+
try {
|
|
203
|
+
const runs = discoverRuns(req.project.worcaDir);
|
|
204
|
+
res.json({ ok: true, runs });
|
|
205
|
+
} catch (err) {
|
|
206
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// GET /api/projects/:projectId/branches — list git branches
|
|
211
|
+
router.get('/branches', (req, res) => {
|
|
212
|
+
const cwd = req.project.projectRoot;
|
|
213
|
+
try {
|
|
214
|
+
const out = execFileSync('git', ['branch', '--format=%(refname:short)'], {
|
|
215
|
+
cwd,
|
|
216
|
+
encoding: 'utf8',
|
|
217
|
+
timeout: 5000,
|
|
218
|
+
});
|
|
219
|
+
const branches = out.trim().split('\n').filter(Boolean);
|
|
220
|
+
res.json({ ok: true, branches });
|
|
221
|
+
} catch (err) {
|
|
222
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// GET /api/projects/:projectId/plan-files — list plan files
|
|
227
|
+
router.get('/plan-files', (req, res) => {
|
|
228
|
+
const root = req.project.projectRoot;
|
|
229
|
+
let dirs = ['docs/plans'];
|
|
230
|
+
let extensions = ['.md'];
|
|
231
|
+
|
|
232
|
+
const { settingsPath } = req.project;
|
|
233
|
+
if (settingsPath && existsSync(settingsPath)) {
|
|
234
|
+
try {
|
|
235
|
+
const settings = readMergedSettings(settingsPath);
|
|
236
|
+
const planFiles = settings.worca?.planFiles;
|
|
237
|
+
if (planFiles?.dirs && Array.isArray(planFiles.dirs))
|
|
238
|
+
dirs = planFiles.dirs;
|
|
239
|
+
if (planFiles?.extensions && Array.isArray(planFiles.extensions))
|
|
240
|
+
extensions = planFiles.extensions;
|
|
241
|
+
} catch {
|
|
242
|
+
/* use defaults */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const files = [];
|
|
247
|
+
for (const dir of dirs) {
|
|
248
|
+
const absDir = join(root, dir);
|
|
249
|
+
if (!existsSync(absDir)) continue;
|
|
250
|
+
try {
|
|
251
|
+
const entries = readdirSync(absDir);
|
|
252
|
+
for (const name of entries.sort()) {
|
|
253
|
+
if (extensions.some((ext) => name.endsWith(ext))) {
|
|
254
|
+
files.push({ path: join(dir, name), dir, name });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
/* skip */
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
res.json({ ok: true, files });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// --- Project-scoped settings endpoints ---
|
|
265
|
+
|
|
266
|
+
// GET /api/projects/:projectId/settings
|
|
267
|
+
router.get('/settings', (req, res) => {
|
|
268
|
+
const { settingsPath } = req.project;
|
|
269
|
+
if (!settingsPath || !existsSync(settingsPath)) {
|
|
270
|
+
return res.json({ worca: {}, permissions: {} });
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const merged = readMergedSettings(settingsPath);
|
|
274
|
+
res.json({
|
|
275
|
+
worca: merged.worca || {},
|
|
276
|
+
permissions: merged.permissions || {},
|
|
277
|
+
});
|
|
278
|
+
} catch (err) {
|
|
279
|
+
res
|
|
280
|
+
.status(500)
|
|
281
|
+
.json({ error: { code: 'read_error', message: err.message } });
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
// POST /api/projects/:projectId/settings
|
|
286
|
+
router.post('/settings', (req, res) => {
|
|
287
|
+
const { settingsPath } = req.project;
|
|
288
|
+
if (!settingsPath) {
|
|
289
|
+
return res.status(501).json({
|
|
290
|
+
error: {
|
|
291
|
+
code: 'not_configured',
|
|
292
|
+
message: 'settingsPath not configured',
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const body = req.body;
|
|
298
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
299
|
+
return res.status(400).json({
|
|
300
|
+
error: {
|
|
301
|
+
code: 'validation_error',
|
|
302
|
+
message: 'Request body must be a JSON object',
|
|
303
|
+
details: [],
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const validation = validateSettingsPayload(body);
|
|
309
|
+
if (!validation.valid) {
|
|
310
|
+
return res.status(400).json({
|
|
311
|
+
error: {
|
|
312
|
+
code: 'validation_error',
|
|
313
|
+
message: 'Invalid settings payload',
|
|
314
|
+
details: validation.details,
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const lp = localPathFor(settingsPath);
|
|
321
|
+
const local = readLocalSettings(settingsPath);
|
|
322
|
+
|
|
323
|
+
if (body.worca && typeof body.worca === 'object') {
|
|
324
|
+
if (!local.worca) local.worca = {};
|
|
325
|
+
for (const key of Object.keys(body.worca)) {
|
|
326
|
+
local.worca[key] = body.worca[key];
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
if (body.permissions !== undefined) {
|
|
330
|
+
local.permissions = body.permissions;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
writeFileSync(lp, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
|
|
334
|
+
|
|
335
|
+
const merged = readMergedSettings(settingsPath);
|
|
336
|
+
res.json({
|
|
337
|
+
worca: merged.worca || {},
|
|
338
|
+
permissions: merged.permissions || {},
|
|
339
|
+
});
|
|
340
|
+
} catch (err) {
|
|
341
|
+
res
|
|
342
|
+
.status(500)
|
|
343
|
+
.json({ error: { code: 'write_error', message: err.message } });
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// DELETE /api/projects/:projectId/settings/:section
|
|
348
|
+
const SECTION_KEYS = {
|
|
349
|
+
agents: { worca: ['agents'] },
|
|
350
|
+
pipeline: { worca: ['stages', 'loops', 'plan_path_template', 'defaults'] },
|
|
351
|
+
governance: { worca: ['governance'], top: ['permissions'] },
|
|
352
|
+
pricing: { worca: ['pricing'] },
|
|
353
|
+
webhooks: { worca: ['events', 'budget', 'webhooks'] },
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
router.delete('/settings/:section', (req, res) => {
|
|
357
|
+
const { settingsPath } = req.project;
|
|
358
|
+
if (!settingsPath) {
|
|
359
|
+
return res.status(501).json({
|
|
360
|
+
error: {
|
|
361
|
+
code: 'not_configured',
|
|
362
|
+
message: 'settingsPath not configured',
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const section = req.params.section;
|
|
368
|
+
const mapping = SECTION_KEYS[section];
|
|
369
|
+
if (!mapping) {
|
|
370
|
+
return res.status(400).json({
|
|
371
|
+
error: {
|
|
372
|
+
code: 'invalid_section',
|
|
373
|
+
message: `Unknown section: ${section}. Valid: ${Object.keys(SECTION_KEYS).join(', ')}`,
|
|
374
|
+
},
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
try {
|
|
379
|
+
const lp = localPathFor(settingsPath);
|
|
380
|
+
const local = readLocalSettings(settingsPath);
|
|
381
|
+
|
|
382
|
+
if (mapping.worca && local.worca) {
|
|
383
|
+
for (const key of mapping.worca) delete local.worca[key];
|
|
384
|
+
if (Object.keys(local.worca).length === 0) delete local.worca;
|
|
385
|
+
}
|
|
386
|
+
if (mapping.top) {
|
|
387
|
+
for (const key of mapping.top) delete local[key];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (Object.keys(local).length === 0) {
|
|
391
|
+
try {
|
|
392
|
+
unlinkSync(lp);
|
|
393
|
+
} catch {
|
|
394
|
+
/* file may not exist */
|
|
395
|
+
}
|
|
396
|
+
} else {
|
|
397
|
+
writeFileSync(lp, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const merged = readMergedSettings(settingsPath);
|
|
401
|
+
res.json({
|
|
402
|
+
worca: merged.worca || {},
|
|
403
|
+
permissions: merged.permissions || {},
|
|
404
|
+
});
|
|
405
|
+
} catch (err) {
|
|
406
|
+
res
|
|
407
|
+
.status(500)
|
|
408
|
+
.json({ error: { code: 'write_error', message: err.message } });
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// GET /api/projects/:projectId/runs/:runId/status — run status
|
|
413
|
+
router.get('/runs/:runId/status', requireWorcaDir, (req, res) => {
|
|
414
|
+
const { runId } = req.params;
|
|
415
|
+
if (!validateRunId(runId)) {
|
|
416
|
+
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
417
|
+
}
|
|
418
|
+
const { worcaDir, pm } = req.project;
|
|
419
|
+
const statusPath = findRunStatusPath(worcaDir, runId);
|
|
420
|
+
if (!statusPath) {
|
|
421
|
+
return res
|
|
422
|
+
.status(404)
|
|
423
|
+
.json({ ok: false, error: `Run "${runId}" not found` });
|
|
424
|
+
}
|
|
425
|
+
try {
|
|
426
|
+
let status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
427
|
+
// Reconcile stale "running" status when no process is alive
|
|
428
|
+
if (status.pipeline_status === 'running' && pm && !pm.getRunningPid()) {
|
|
429
|
+
try {
|
|
430
|
+
pm.reconcileStatus();
|
|
431
|
+
status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
432
|
+
} catch {
|
|
433
|
+
/* reconciliation is best-effort */
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const stage = status.stage ?? null;
|
|
437
|
+
const iteration =
|
|
438
|
+
stage != null ? (status.stages?.[stage]?.iteration ?? null) : null;
|
|
439
|
+
res.json({
|
|
440
|
+
ok: true,
|
|
441
|
+
pipeline_status: status.pipeline_status ?? null,
|
|
442
|
+
stage,
|
|
443
|
+
iteration,
|
|
444
|
+
});
|
|
445
|
+
} catch (err) {
|
|
446
|
+
res
|
|
447
|
+
.status(500)
|
|
448
|
+
.json({ ok: false, error: `Failed to read status: ${err.message}` });
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// POST /api/projects/:projectId/runs — start a new pipeline
|
|
453
|
+
router.post('/runs', requireWorcaDir, async (req, res) => {
|
|
454
|
+
const body = req.body || {};
|
|
455
|
+
|
|
456
|
+
let { sourceType, sourceValue, prompt, planFile, msize, mloops, branch } =
|
|
457
|
+
body;
|
|
458
|
+
if (body.inputType && sourceType === undefined) {
|
|
459
|
+
if (body.inputType === 'prompt') {
|
|
460
|
+
sourceType = 'none';
|
|
461
|
+
prompt = body.inputValue;
|
|
462
|
+
} else {
|
|
463
|
+
sourceType = body.inputType;
|
|
464
|
+
sourceValue = body.inputValue;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
sourceType = sourceType || 'none';
|
|
469
|
+
|
|
470
|
+
if (!['none', 'source', 'spec'].includes(sourceType)) {
|
|
471
|
+
return res.status(400).json({
|
|
472
|
+
ok: false,
|
|
473
|
+
error: 'sourceType must be "none", "source", or "spec"',
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (sourceType !== 'none') {
|
|
478
|
+
if (typeof sourceValue !== 'string' || sourceValue.trim().length === 0) {
|
|
479
|
+
return res.status(400).json({
|
|
480
|
+
ok: false,
|
|
481
|
+
error:
|
|
482
|
+
'sourceValue must be a non-empty string when sourceType is "source" or "spec"',
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
if (sourceValue.length > 50000) {
|
|
486
|
+
return res.status(400).json({
|
|
487
|
+
ok: false,
|
|
488
|
+
error: 'sourceValue must be 50,000 characters or less',
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
sourceValue = sourceValue.trim();
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (prompt != null && typeof prompt === 'string' && prompt.length > 50000) {
|
|
495
|
+
return res
|
|
496
|
+
.status(400)
|
|
497
|
+
.json({ ok: false, error: 'prompt must be 50,000 characters or less' });
|
|
498
|
+
}
|
|
499
|
+
if (typeof prompt === 'string') prompt = prompt.trim() || undefined;
|
|
500
|
+
|
|
501
|
+
if (planFile !== undefined && planFile !== null) {
|
|
502
|
+
if (!validatePlanFile(planFile)) {
|
|
503
|
+
return res.status(400).json({
|
|
504
|
+
ok: false,
|
|
505
|
+
error: 'planFile must be a relative path with no ".." segments',
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (branch !== undefined && branch !== null) {
|
|
511
|
+
if (!validateBranch(branch)) {
|
|
512
|
+
return res
|
|
513
|
+
.status(400)
|
|
514
|
+
.json({ ok: false, error: 'Invalid branch value' });
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const hasSource = sourceType !== 'none' && sourceValue;
|
|
519
|
+
const hasPlan = typeof planFile === 'string' && planFile.trim().length > 0;
|
|
520
|
+
const hasPrompt = typeof prompt === 'string' && prompt.length > 0;
|
|
521
|
+
|
|
522
|
+
if (!hasSource && !hasPlan && !hasPrompt) {
|
|
523
|
+
return res.status(400).json({
|
|
524
|
+
ok: false,
|
|
525
|
+
error: 'At least one of source, planFile, or prompt is required',
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const msizeVal =
|
|
530
|
+
msize != null ? Math.max(1, Math.min(10, Math.round(Number(msize)))) : 1;
|
|
531
|
+
const mloopsVal =
|
|
532
|
+
mloops != null
|
|
533
|
+
? Math.max(1, Math.min(10, Math.round(Number(mloops))))
|
|
534
|
+
: 1;
|
|
535
|
+
|
|
536
|
+
try {
|
|
537
|
+
const result = await req.project.pm.startPipeline({
|
|
538
|
+
sourceType,
|
|
539
|
+
sourceValue: hasSource ? sourceValue : undefined,
|
|
540
|
+
prompt: hasPrompt ? prompt : undefined,
|
|
541
|
+
msize: msizeVal,
|
|
542
|
+
mloops: mloopsVal,
|
|
543
|
+
planFile: hasPlan ? planFile.trim() : undefined,
|
|
544
|
+
branch: branch || undefined,
|
|
545
|
+
});
|
|
546
|
+
const { broadcast } = req.app.locals;
|
|
547
|
+
if (broadcast) broadcast('run-started', { pid: result.pid });
|
|
548
|
+
res.json({ ok: true, pid: result.pid, sourceType, prompt });
|
|
549
|
+
} catch (err) {
|
|
550
|
+
if (err.code === 'already_running') {
|
|
551
|
+
return res.status(409).json({ ok: false, error: err.message });
|
|
552
|
+
}
|
|
553
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// DELETE /api/projects/:projectId/runs/:id — stop a running pipeline
|
|
558
|
+
router.delete('/runs/:id', requireWorcaDir, (req, res) => {
|
|
559
|
+
try {
|
|
560
|
+
const result = req.project.pm.stopPipeline();
|
|
561
|
+
const { broadcast } = req.app.locals;
|
|
562
|
+
if (broadcast) broadcast('run-stopped', { pid: result.pid });
|
|
563
|
+
res.json({ ok: true, stopped: true, pid: result.pid });
|
|
564
|
+
} catch (err) {
|
|
565
|
+
if (err.code === 'not_running') {
|
|
566
|
+
return res.status(404).json({ ok: false, error: err.message });
|
|
567
|
+
}
|
|
568
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// POST /api/projects/:projectId/runs/:id/pause
|
|
573
|
+
router.post('/runs/:id/pause', requireWorcaDir, (req, res) => {
|
|
574
|
+
const runId = req.params.id;
|
|
575
|
+
if (!validateRunId(runId)) {
|
|
576
|
+
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
577
|
+
}
|
|
578
|
+
try {
|
|
579
|
+
const result = req.project.pm.pausePipeline(runId);
|
|
580
|
+
const { broadcast } = req.app.locals;
|
|
581
|
+
if (broadcast) broadcast('run-paused', { runId });
|
|
582
|
+
res.json({ ok: true, ...result });
|
|
583
|
+
} catch (err) {
|
|
584
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
585
|
+
}
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
// POST /api/projects/:projectId/runs/:id/resume
|
|
589
|
+
router.post('/runs/:id/resume', requireWorcaDir, async (req, res) => {
|
|
590
|
+
const runId = req.params.id;
|
|
591
|
+
if (!validateRunId(runId)) {
|
|
592
|
+
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
// Clear archived flag so the resumed run appears on the main dashboard
|
|
596
|
+
const { worcaDir } = req.project;
|
|
597
|
+
const statusPath = findRunStatusPath(worcaDir, runId);
|
|
598
|
+
if (statusPath) {
|
|
599
|
+
const status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
600
|
+
if (status.archived) {
|
|
601
|
+
delete status.archived;
|
|
602
|
+
delete status.archived_at;
|
|
603
|
+
writeFileSync(
|
|
604
|
+
statusPath,
|
|
605
|
+
`${JSON.stringify(status, null, 2)}\n`,
|
|
606
|
+
'utf8',
|
|
607
|
+
);
|
|
608
|
+
const { broadcast } = req.app.locals;
|
|
609
|
+
if (broadcast) broadcast('run-unarchived', { runId });
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
const result = await req.project.pm.startPipeline({
|
|
613
|
+
resume: true,
|
|
614
|
+
runId,
|
|
615
|
+
});
|
|
616
|
+
const { broadcast } = req.app.locals;
|
|
617
|
+
if (broadcast) broadcast('run-resumed', { runId, pid: result.pid });
|
|
618
|
+
res.json({ ok: true, pid: result.pid, runId });
|
|
619
|
+
} catch (err) {
|
|
620
|
+
if (err.code === 'already_running') {
|
|
621
|
+
return res.status(409).json({ ok: false, error: err.message });
|
|
622
|
+
}
|
|
623
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// POST /api/projects/:projectId/runs/:id/stop — control.json + SIGTERM
|
|
628
|
+
router.post('/runs/:id/stop', requireWorcaDir, (req, res) => {
|
|
629
|
+
const runId = req.params.id;
|
|
630
|
+
if (!validateRunId(runId)) {
|
|
631
|
+
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
632
|
+
}
|
|
633
|
+
const { worcaDir } = req.project;
|
|
634
|
+
try {
|
|
635
|
+
const controlDir = join(worcaDir, 'runs', runId);
|
|
636
|
+
mkdirSync(controlDir, { recursive: true });
|
|
637
|
+
writeFileSync(
|
|
638
|
+
join(controlDir, 'control.json'),
|
|
639
|
+
`${JSON.stringify(
|
|
640
|
+
{
|
|
641
|
+
action: 'stop',
|
|
642
|
+
requested_at: new Date().toISOString(),
|
|
643
|
+
source: 'ui',
|
|
644
|
+
},
|
|
645
|
+
null,
|
|
646
|
+
2,
|
|
647
|
+
)}\n`,
|
|
648
|
+
'utf8',
|
|
649
|
+
);
|
|
650
|
+
} catch {
|
|
651
|
+
/* non-fatal — SIGTERM follows */
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
const result = req.project.pm.stopPipeline();
|
|
655
|
+
const { broadcast } = req.app.locals;
|
|
656
|
+
if (broadcast) broadcast('run-stopped', { runId, pid: result.pid });
|
|
657
|
+
res.json({ ok: true, stopped: true, runId, pid: result.pid });
|
|
658
|
+
} catch (err) {
|
|
659
|
+
if (err.code === 'not_running') {
|
|
660
|
+
const statusPath = join(
|
|
661
|
+
req.project.worcaDir,
|
|
662
|
+
'runs',
|
|
663
|
+
runId,
|
|
664
|
+
'status.json',
|
|
665
|
+
);
|
|
666
|
+
if (existsSync(statusPath)) {
|
|
667
|
+
try {
|
|
668
|
+
const st = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
669
|
+
if (
|
|
670
|
+
st.pipeline_status === 'paused' ||
|
|
671
|
+
st.pipeline_status === 'running'
|
|
672
|
+
) {
|
|
673
|
+
st.pipeline_status = 'failed';
|
|
674
|
+
st.stop_reason = 'stopped';
|
|
675
|
+
writeFileSync(
|
|
676
|
+
statusPath,
|
|
677
|
+
`${JSON.stringify(st, null, 2)}\n`,
|
|
678
|
+
'utf8',
|
|
679
|
+
);
|
|
680
|
+
const { broadcast } = req.app.locals;
|
|
681
|
+
if (broadcast) broadcast('run-stopped', { runId, pid: null });
|
|
682
|
+
return res.json({ ok: true, stopped: true, runId, pid: null });
|
|
683
|
+
}
|
|
684
|
+
} catch {
|
|
685
|
+
/* fall through to 404 */
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return res.status(404).json({ ok: false, error: err.message });
|
|
689
|
+
}
|
|
690
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
// POST /api/projects/:projectId/runs/:id/archive
|
|
695
|
+
router.post('/runs/:id/archive', requireWorcaDir, (req, res) => {
|
|
696
|
+
const runId = req.params.id;
|
|
697
|
+
if (!validateRunId(runId)) {
|
|
698
|
+
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
699
|
+
}
|
|
700
|
+
const { worcaDir } = req.project;
|
|
701
|
+
const statusPath = findRunStatusPath(worcaDir, runId);
|
|
702
|
+
if (!statusPath) {
|
|
703
|
+
return res
|
|
704
|
+
.status(404)
|
|
705
|
+
.json({ ok: false, error: `Run "${runId}" not found` });
|
|
706
|
+
}
|
|
707
|
+
let tmpPath;
|
|
708
|
+
try {
|
|
709
|
+
const status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
710
|
+
if (status.pipeline_status === 'running') {
|
|
711
|
+
return res
|
|
712
|
+
.status(409)
|
|
713
|
+
.json({ ok: false, error: 'Cannot archive a running pipeline' });
|
|
714
|
+
}
|
|
715
|
+
if (status.archived === true) {
|
|
716
|
+
return res.json({ ok: true });
|
|
717
|
+
}
|
|
718
|
+
status.archived = true;
|
|
719
|
+
status.archived_at = new Date().toISOString();
|
|
720
|
+
tmpPath = join(
|
|
721
|
+
dirname(statusPath),
|
|
722
|
+
`.status.json.${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`,
|
|
723
|
+
);
|
|
724
|
+
writeFileSync(
|
|
725
|
+
tmpPath,
|
|
726
|
+
`${JSON.stringify(status, null, 2)}
|
|
727
|
+
`,
|
|
728
|
+
'utf8',
|
|
729
|
+
);
|
|
730
|
+
renameSync(tmpPath, statusPath);
|
|
731
|
+
const { broadcast } = req.app.locals;
|
|
732
|
+
if (broadcast)
|
|
733
|
+
broadcast('run-archived', { runId, archived_at: status.archived_at });
|
|
734
|
+
res.json({ ok: true });
|
|
735
|
+
} catch (err) {
|
|
736
|
+
if (tmpPath) {
|
|
737
|
+
try {
|
|
738
|
+
unlinkSync(tmpPath);
|
|
739
|
+
} catch {
|
|
740
|
+
/* ignore cleanup failure */
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
744
|
+
}
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// POST /api/projects/:projectId/runs/:id/unarchive
|
|
748
|
+
router.post('/runs/:id/unarchive', requireWorcaDir, (req, res) => {
|
|
749
|
+
const runId = req.params.id;
|
|
750
|
+
if (!validateRunId(runId)) {
|
|
751
|
+
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
752
|
+
}
|
|
753
|
+
const { worcaDir } = req.project;
|
|
754
|
+
const statusPath = findRunStatusPath(worcaDir, runId);
|
|
755
|
+
if (!statusPath) {
|
|
756
|
+
return res
|
|
757
|
+
.status(404)
|
|
758
|
+
.json({ ok: false, error: `Run "${runId}" not found` });
|
|
759
|
+
}
|
|
760
|
+
let tmpPath;
|
|
761
|
+
try {
|
|
762
|
+
const status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
763
|
+
if (status.archived !== true) {
|
|
764
|
+
return res.json({ ok: true });
|
|
765
|
+
}
|
|
766
|
+
delete status.archived;
|
|
767
|
+
delete status.archived_at;
|
|
768
|
+
tmpPath = join(
|
|
769
|
+
dirname(statusPath),
|
|
770
|
+
`.status.json.${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`,
|
|
771
|
+
);
|
|
772
|
+
writeFileSync(
|
|
773
|
+
tmpPath,
|
|
774
|
+
`${JSON.stringify(status, null, 2)}
|
|
775
|
+
`,
|
|
776
|
+
'utf8',
|
|
777
|
+
);
|
|
778
|
+
renameSync(tmpPath, statusPath);
|
|
779
|
+
const { broadcast } = req.app.locals;
|
|
780
|
+
if (broadcast) broadcast('run-unarchived', { runId });
|
|
781
|
+
res.json({ ok: true });
|
|
782
|
+
} catch (err) {
|
|
783
|
+
if (tmpPath) {
|
|
784
|
+
try {
|
|
785
|
+
unlinkSync(tmpPath);
|
|
786
|
+
} catch {
|
|
787
|
+
/* ignore cleanup failure */
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
// POST /api/projects/:projectId/runs/:id/stages/:stage/restart
|
|
795
|
+
router.post(
|
|
796
|
+
'/runs/:id/stages/:stage/restart',
|
|
797
|
+
requireWorcaDir,
|
|
798
|
+
async (req, res) => {
|
|
799
|
+
const { stage } = req.params;
|
|
800
|
+
try {
|
|
801
|
+
const result = await req.project.pm.restartStage(stage);
|
|
802
|
+
const { broadcast } = req.app.locals;
|
|
803
|
+
if (broadcast) broadcast('stage-restarted', { stage, pid: result.pid });
|
|
804
|
+
res.json({ ok: true, restarted: true, stage, pid: result.pid });
|
|
805
|
+
} catch (err) {
|
|
806
|
+
if (err.code === 'already_running') {
|
|
807
|
+
return res.status(409).json({ ok: false, error: err.message });
|
|
808
|
+
}
|
|
809
|
+
if (err.code === 'stage_not_found' || err.code === 'stage_not_error') {
|
|
810
|
+
return res.status(400).json({ ok: false, error: err.message });
|
|
811
|
+
}
|
|
812
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
813
|
+
}
|
|
814
|
+
},
|
|
815
|
+
);
|
|
816
|
+
|
|
817
|
+
// POST /api/projects/:projectId/runs/:id/learn
|
|
818
|
+
router.post('/runs/:id/learn', requireWorcaDir, (req, res) => {
|
|
819
|
+
const runId = req.params.id;
|
|
820
|
+
if (!validateRunId(runId)) {
|
|
821
|
+
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
822
|
+
}
|
|
823
|
+
const { worcaDir, projectRoot } = req.project;
|
|
824
|
+
|
|
825
|
+
const statusPath = findRunStatusPath(worcaDir, runId);
|
|
826
|
+
if (!statusPath) {
|
|
827
|
+
return res
|
|
828
|
+
.status(404)
|
|
829
|
+
.json({ ok: false, error: `Run "${runId}" not found` });
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const running = req.project.pm.getRunningPid();
|
|
833
|
+
if (running) {
|
|
834
|
+
return res.status(409).json({
|
|
835
|
+
ok: false,
|
|
836
|
+
error: `Pipeline is currently running (PID ${running.pid})`,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
let status;
|
|
841
|
+
try {
|
|
842
|
+
status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
843
|
+
} catch (err) {
|
|
844
|
+
return res
|
|
845
|
+
.status(500)
|
|
846
|
+
.json({ ok: false, error: `Failed to read status: ${err.message}` });
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const learnStage = status.stages?.learn;
|
|
850
|
+
if (learnStage?.status === 'in_progress' && learnStage.pid) {
|
|
851
|
+
try {
|
|
852
|
+
process.kill(learnStage.pid, 0);
|
|
853
|
+
return res
|
|
854
|
+
.status(409)
|
|
855
|
+
.json({ ok: false, error: 'Learning analysis is already running' });
|
|
856
|
+
} catch {
|
|
857
|
+
/* stale PID — allow re-run */
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
const cwd = projectRoot || process.cwd();
|
|
862
|
+
const env = { ...process.env };
|
|
863
|
+
delete env.CLAUDECODE;
|
|
864
|
+
|
|
865
|
+
const child = spawn(
|
|
866
|
+
'python3',
|
|
867
|
+
['.claude/worca/scripts/run_learn.py', '--run-id', runId],
|
|
868
|
+
{ detached: true, stdio: 'ignore', cwd, env },
|
|
869
|
+
);
|
|
870
|
+
child.unref();
|
|
871
|
+
|
|
872
|
+
const now = new Date().toISOString();
|
|
873
|
+
if (!status.stages) status.stages = {};
|
|
874
|
+
status.stages.learn = {
|
|
875
|
+
status: 'in_progress',
|
|
876
|
+
pid: child.pid,
|
|
877
|
+
started_at: now,
|
|
878
|
+
iterations: [
|
|
879
|
+
{
|
|
880
|
+
number: 1,
|
|
881
|
+
status: 'in_progress',
|
|
882
|
+
started_at: now,
|
|
883
|
+
agent: 'learner',
|
|
884
|
+
model: 'sonnet',
|
|
885
|
+
trigger: 'manual',
|
|
886
|
+
},
|
|
887
|
+
],
|
|
888
|
+
};
|
|
889
|
+
try {
|
|
890
|
+
writeFileSync(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
|
|
891
|
+
} catch {
|
|
892
|
+
/* non-fatal */
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const { broadcast, scheduleRefresh } = req.app.locals;
|
|
896
|
+
if (broadcast) broadcast('learn-started', { runId });
|
|
897
|
+
|
|
898
|
+
const pollInterval = setInterval(() => {
|
|
899
|
+
try {
|
|
900
|
+
const fresh = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
901
|
+
if (scheduleRefresh) scheduleRefresh();
|
|
902
|
+
const ls = fresh.stages?.learn?.status;
|
|
903
|
+
if (ls !== 'in_progress' && ls !== 'pending')
|
|
904
|
+
clearInterval(pollInterval);
|
|
905
|
+
} catch {
|
|
906
|
+
clearInterval(pollInterval);
|
|
907
|
+
}
|
|
908
|
+
}, 3000);
|
|
909
|
+
setTimeout(() => clearInterval(pollInterval), 15 * 60 * 1000);
|
|
910
|
+
if (pollInterval.unref) pollInterval.unref();
|
|
911
|
+
|
|
912
|
+
res.json({ ok: true, runId, pid: child.pid });
|
|
913
|
+
});
|
|
914
|
+
|
|
915
|
+
// POST /api/projects/:projectId/multi-pipeline — launch parallel pipelines
|
|
916
|
+
router.post('/multi-pipeline', requireWorcaDir, (req, res) => {
|
|
917
|
+
const { projectRoot } = req.project;
|
|
918
|
+
const body = req.body || {};
|
|
919
|
+
const { requests, baseBranch, maxParallel, cleanupPolicy, msize, mloops } =
|
|
920
|
+
body;
|
|
921
|
+
|
|
922
|
+
if (!Array.isArray(requests) || requests.length < 1) {
|
|
923
|
+
return res.status(400).json({
|
|
924
|
+
ok: false,
|
|
925
|
+
error: 'requests array required (at least 1 item)',
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
if (requests.length > 20) {
|
|
929
|
+
return res
|
|
930
|
+
.status(400)
|
|
931
|
+
.json({ ok: false, error: 'Too many requests (max 20)' });
|
|
932
|
+
}
|
|
933
|
+
for (const r of requests) {
|
|
934
|
+
if (typeof r !== 'string' || r.trim().length === 0) {
|
|
935
|
+
return res.status(400).json({
|
|
936
|
+
ok: false,
|
|
937
|
+
error: 'Each request must be a non-empty string',
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
if (r.length > 50000) {
|
|
941
|
+
return res.status(400).json({
|
|
942
|
+
ok: false,
|
|
943
|
+
error: 'Each request must be 50,000 characters or less',
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
if (baseBranch !== undefined) {
|
|
948
|
+
if (
|
|
949
|
+
typeof baseBranch !== 'string' ||
|
|
950
|
+
baseBranch.length > 200 ||
|
|
951
|
+
!/^[\w.\-/]+$/.test(baseBranch)
|
|
952
|
+
) {
|
|
953
|
+
return res
|
|
954
|
+
.status(400)
|
|
955
|
+
.json({ ok: false, error: 'Invalid baseBranch value' });
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const maxP = Math.max(1, Math.min(5, Math.round(Number(maxParallel) || 3)));
|
|
960
|
+
const msizeVal = Math.max(1, Math.min(10, Math.round(Number(msize) || 1)));
|
|
961
|
+
const mloopsVal = Math.max(
|
|
962
|
+
1,
|
|
963
|
+
Math.min(10, Math.round(Number(mloops) || 1)),
|
|
964
|
+
);
|
|
965
|
+
const cleanup = ['on-success', 'always', 'never'].includes(cleanupPolicy)
|
|
966
|
+
? cleanupPolicy
|
|
967
|
+
: 'on-success';
|
|
968
|
+
|
|
969
|
+
const args = ['.claude/worca/scripts/run_multi.py'];
|
|
970
|
+
args.push('--max-parallel', String(maxP));
|
|
971
|
+
args.push('--cleanup', cleanup);
|
|
972
|
+
args.push('--msize', String(msizeVal));
|
|
973
|
+
args.push('--mloops', String(mloopsVal));
|
|
974
|
+
if (baseBranch) args.push('--base-branch', baseBranch);
|
|
975
|
+
args.push('--requests', ...requests.map((r) => r.trim()));
|
|
976
|
+
|
|
977
|
+
const env = { ...process.env };
|
|
978
|
+
delete env.CLAUDECODE;
|
|
979
|
+
|
|
980
|
+
try {
|
|
981
|
+
const child = spawn('python3', args, {
|
|
982
|
+
detached: true,
|
|
983
|
+
stdio: 'ignore',
|
|
984
|
+
cwd: projectRoot,
|
|
985
|
+
env,
|
|
986
|
+
});
|
|
987
|
+
child.unref();
|
|
988
|
+
|
|
989
|
+
const { broadcast } = req.app.locals;
|
|
990
|
+
if (broadcast)
|
|
991
|
+
broadcast('multi-pipeline-started', {
|
|
992
|
+
pid: child.pid,
|
|
993
|
+
count: requests.length,
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
res.json({ ok: true, pid: child.pid, count: requests.length });
|
|
997
|
+
} catch (err) {
|
|
998
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
// POST /api/projects/:projectId/pipelines/:runId/stop — stop a parallel pipeline
|
|
1003
|
+
router.post('/pipelines/:runId/stop', requireWorcaDir, (req, res) => {
|
|
1004
|
+
const runId = req.params.runId;
|
|
1005
|
+
if (!validateRunId(runId)) {
|
|
1006
|
+
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
1007
|
+
}
|
|
1008
|
+
const { worcaDir } = req.project;
|
|
1009
|
+
|
|
1010
|
+
const pipelineFile = join(
|
|
1011
|
+
worcaDir,
|
|
1012
|
+
'multi',
|
|
1013
|
+
'pipelines.d',
|
|
1014
|
+
`${runId}.json`,
|
|
1015
|
+
);
|
|
1016
|
+
if (!existsSync(pipelineFile)) {
|
|
1017
|
+
return res
|
|
1018
|
+
.status(404)
|
|
1019
|
+
.json({ ok: false, error: `Pipeline ${runId} not found` });
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
let pipeline;
|
|
1023
|
+
try {
|
|
1024
|
+
pipeline = JSON.parse(readFileSync(pipelineFile, 'utf8'));
|
|
1025
|
+
} catch {
|
|
1026
|
+
return res
|
|
1027
|
+
.status(500)
|
|
1028
|
+
.json({ ok: false, error: 'Failed to read pipeline registry' });
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
if (!pipeline.worktree_path) {
|
|
1032
|
+
return res
|
|
1033
|
+
.status(400)
|
|
1034
|
+
.json({ ok: false, error: 'Pipeline has no worktree path' });
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
const worktreePm = new ProcessManager({
|
|
1038
|
+
worcaDir: join(pipeline.worktree_path, '.worca'),
|
|
1039
|
+
});
|
|
1040
|
+
try {
|
|
1041
|
+
const result = worktreePm.stopPipeline();
|
|
1042
|
+
res.json({ ok: true, stopped: true, runId, pid: result.pid });
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
if (err.code === 'not_running') {
|
|
1045
|
+
return res.status(404).json({ ok: false, error: err.message });
|
|
1046
|
+
}
|
|
1047
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
|
|
1051
|
+
// POST /api/projects/:projectId/pipelines/:runId/pause — pause a parallel pipeline
|
|
1052
|
+
router.post('/pipelines/:runId/pause', requireWorcaDir, (req, res) => {
|
|
1053
|
+
const runId = req.params.runId;
|
|
1054
|
+
if (!validateRunId(runId)) {
|
|
1055
|
+
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
1056
|
+
}
|
|
1057
|
+
const { worcaDir } = req.project;
|
|
1058
|
+
|
|
1059
|
+
const pipelineFile = join(
|
|
1060
|
+
worcaDir,
|
|
1061
|
+
'multi',
|
|
1062
|
+
'pipelines.d',
|
|
1063
|
+
`${runId}.json`,
|
|
1064
|
+
);
|
|
1065
|
+
if (!existsSync(pipelineFile)) {
|
|
1066
|
+
return res
|
|
1067
|
+
.status(404)
|
|
1068
|
+
.json({ ok: false, error: `Pipeline ${runId} not found` });
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
let pipeline;
|
|
1072
|
+
try {
|
|
1073
|
+
pipeline = JSON.parse(readFileSync(pipelineFile, 'utf8'));
|
|
1074
|
+
} catch {
|
|
1075
|
+
return res
|
|
1076
|
+
.status(500)
|
|
1077
|
+
.json({ ok: false, error: 'Failed to read pipeline registry' });
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
if (!pipeline.worktree_path) {
|
|
1081
|
+
return res
|
|
1082
|
+
.status(400)
|
|
1083
|
+
.json({ ok: false, error: 'Pipeline has no worktree path' });
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
const worktreePm = new ProcessManager({
|
|
1087
|
+
worcaDir: join(pipeline.worktree_path, '.worca'),
|
|
1088
|
+
});
|
|
1089
|
+
try {
|
|
1090
|
+
const result = worktreePm.pausePipeline(runId);
|
|
1091
|
+
res.json({ ok: true, ...result });
|
|
1092
|
+
} catch (err) {
|
|
1093
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
|
|
1097
|
+
// GET /api/projects/:projectId/worca-status — check worca installation state
|
|
1098
|
+
router.get('/worca-status', (req, res) => {
|
|
1099
|
+
const installed = checkWorcaInstalled(req.project.projectRoot);
|
|
1100
|
+
res.json({ ok: true, installed });
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
// POST /api/projects/:projectId/worca-setup — install or update worca
|
|
1104
|
+
router.post('/worca-setup', (req, res) => {
|
|
1105
|
+
const { settingsPath, projectRoot } = req.project;
|
|
1106
|
+
let source = req.body?.source;
|
|
1107
|
+
|
|
1108
|
+
// Fall back to worca.source_repo from merged settings
|
|
1109
|
+
if (!source && settingsPath) {
|
|
1110
|
+
try {
|
|
1111
|
+
const settings = readMergedSettings(settingsPath);
|
|
1112
|
+
source = settings?.worca?.source_repo;
|
|
1113
|
+
} catch {
|
|
1114
|
+
/* ignore — worca init will use its own resolution chain */
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
try {
|
|
1119
|
+
const { pid } = runWorcaSetup(projectRoot, { source });
|
|
1120
|
+
res.json({ ok: true, pid });
|
|
1121
|
+
} catch (err) {
|
|
1122
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
// GET /api/projects/:projectId/costs — token & cost data
|
|
1127
|
+
router.get('/costs', requireWorcaDir, (req, res) => {
|
|
1128
|
+
const { worcaDir } = req.project;
|
|
1129
|
+
const resultsDir = join(worcaDir, 'results');
|
|
1130
|
+
if (!existsSync(resultsDir)) return res.json({ ok: true, tokenData: {} });
|
|
1131
|
+
|
|
1132
|
+
const tokenData = {};
|
|
1133
|
+
|
|
1134
|
+
for (const entry of readdirSync(resultsDir, { withFileTypes: true })) {
|
|
1135
|
+
if (!entry.isDirectory()) continue;
|
|
1136
|
+
const runDir = join(resultsDir, entry.name);
|
|
1137
|
+
const stageNames = [];
|
|
1138
|
+
try {
|
|
1139
|
+
for (const sub of readdirSync(runDir, { withFileTypes: true })) {
|
|
1140
|
+
if (sub.isDirectory()) stageNames.push(sub.name);
|
|
1141
|
+
}
|
|
1142
|
+
} catch {
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (stageNames.length === 0) continue;
|
|
1147
|
+
tokenData[entry.name] = {};
|
|
1148
|
+
|
|
1149
|
+
for (const stage of stageNames) {
|
|
1150
|
+
const stageDir = join(runDir, stage);
|
|
1151
|
+
const iters = [];
|
|
1152
|
+
try {
|
|
1153
|
+
const files = readdirSync(stageDir)
|
|
1154
|
+
.filter((f) => f.startsWith('iter-') && f.endsWith('.json'))
|
|
1155
|
+
.sort();
|
|
1156
|
+
for (const file of files) {
|
|
1157
|
+
try {
|
|
1158
|
+
const data = JSON.parse(
|
|
1159
|
+
readFileSync(join(stageDir, file), 'utf8'),
|
|
1160
|
+
);
|
|
1161
|
+
const mu = data.modelUsage || {};
|
|
1162
|
+
let inputTokens = 0,
|
|
1163
|
+
outputTokens = 0,
|
|
1164
|
+
cacheReadInputTokens = 0,
|
|
1165
|
+
cacheCreationInputTokens = 0;
|
|
1166
|
+
const models = [];
|
|
1167
|
+
for (const [model, usage] of Object.entries(mu)) {
|
|
1168
|
+
inputTokens += usage.inputTokens || 0;
|
|
1169
|
+
outputTokens += usage.outputTokens || 0;
|
|
1170
|
+
cacheReadInputTokens += usage.cacheReadInputTokens || 0;
|
|
1171
|
+
cacheCreationInputTokens += usage.cacheCreationInputTokens || 0;
|
|
1172
|
+
models.push(model);
|
|
1173
|
+
}
|
|
1174
|
+
iters.push({
|
|
1175
|
+
inputTokens,
|
|
1176
|
+
outputTokens,
|
|
1177
|
+
cacheReadInputTokens,
|
|
1178
|
+
cacheCreationInputTokens,
|
|
1179
|
+
models,
|
|
1180
|
+
});
|
|
1181
|
+
} catch {
|
|
1182
|
+
/* skip bad files */
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
} catch {
|
|
1186
|
+
/* skip */
|
|
1187
|
+
}
|
|
1188
|
+
if (iters.length > 0) tokenData[entry.name][stage] = iters;
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
res.json({ ok: true, tokenData });
|
|
1193
|
+
});
|
|
1194
|
+
|
|
1195
|
+
// ─── Beads (project-scoped) ─────────────────────────────────────────
|
|
1196
|
+
router.get('/beads/issues', requireWorcaDir, (req, res) => {
|
|
1197
|
+
const beadsDbPath = join(req.project.worcaDir, '..', '.beads', 'beads.db');
|
|
1198
|
+
if (!dbExists(beadsDbPath)) {
|
|
1199
|
+
return res.json({
|
|
1200
|
+
ok: true,
|
|
1201
|
+
issues: [],
|
|
1202
|
+
dbExists: false,
|
|
1203
|
+
dbPath: beadsDbPath,
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
try {
|
|
1207
|
+
const issues = listIssues(beadsDbPath);
|
|
1208
|
+
res.json({ ok: true, issues, dbExists: true, dbPath: beadsDbPath });
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
router.post('/beads/issues/:id/start', requireWorcaDir, async (req, res) => {
|
|
1215
|
+
const issueId = parseInt(req.params.id, 10);
|
|
1216
|
+
if (!Number.isInteger(issueId) || issueId <= 0) {
|
|
1217
|
+
return res
|
|
1218
|
+
.status(400)
|
|
1219
|
+
.json({ ok: false, error: 'Issue ID must be a positive integer' });
|
|
1220
|
+
}
|
|
1221
|
+
const beadsDbPath = join(req.project.worcaDir, '..', '.beads', 'beads.db');
|
|
1222
|
+
const issue = getIssue(beadsDbPath, issueId);
|
|
1223
|
+
if (!issue) {
|
|
1224
|
+
return res
|
|
1225
|
+
.status(404)
|
|
1226
|
+
.json({ ok: false, error: `Issue ${issueId} not found` });
|
|
1227
|
+
}
|
|
1228
|
+
if (issue.status !== 'ready') {
|
|
1229
|
+
return res.status(409).json({
|
|
1230
|
+
ok: false,
|
|
1231
|
+
error: `Issue ${issueId} is not in 'ready' state (current: ${issue.status})`,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
if (issue.blocked_by.length > 0) {
|
|
1235
|
+
return res.status(409).json({
|
|
1236
|
+
ok: false,
|
|
1237
|
+
error: `Issue ${issueId} is blocked by issues: ${issue.blocked_by.join(', ')}`,
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
try {
|
|
1241
|
+
const pm =
|
|
1242
|
+
req.project.pm ||
|
|
1243
|
+
new ProcessManager({
|
|
1244
|
+
worcaDir: req.project.worcaDir,
|
|
1245
|
+
projectRoot: req.project.projectRoot,
|
|
1246
|
+
});
|
|
1247
|
+
const prompt =
|
|
1248
|
+
`[Beads #${issue.id}] ${issue.title}\n\n${(issue.body || '').trim()}`.trim();
|
|
1249
|
+
const result = await pm.startPipeline({
|
|
1250
|
+
inputType: 'prompt',
|
|
1251
|
+
inputValue: prompt,
|
|
1252
|
+
msize: 1,
|
|
1253
|
+
mloops: 1,
|
|
1254
|
+
});
|
|
1255
|
+
res.json({ ok: true, pid: result.pid, issueId, prompt });
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
const status = (err.message || '').includes('already running')
|
|
1258
|
+
? 409
|
|
1259
|
+
: 500;
|
|
1260
|
+
res.status(status).json({ ok: false, error: err.message });
|
|
1261
|
+
}
|
|
1262
|
+
});
|
|
1263
|
+
|
|
1264
|
+
return router;
|
|
1265
|
+
}
|