@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.
@@ -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
+ }