@worca/ui 0.22.0 → 0.24.0

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,1554 @@
1
+ /**
2
+ * Workspace REST endpoints (W-047 §10.10).
3
+ *
4
+ * Two routers: `workspaces` for workspace definitions (workspace.json)
5
+ * and `workspaceRuns` for workspace run lifecycle (manifests, plans, guides).
6
+ *
7
+ * Pointer files live at ~/.worca/workspace-runs/<workspace_id>.json.
8
+ * Workspace definitions live at <workspace_root>/workspace.json.
9
+ * Manifests live at <workspace_root>/.worca/workspace-runs/<ws_id>/workspace-manifest.json.
10
+ */
11
+
12
+ import { execFileSync } from 'node:child_process';
13
+ import {
14
+ existsSync,
15
+ mkdirSync,
16
+ readdirSync,
17
+ readFileSync,
18
+ renameSync,
19
+ statSync,
20
+ unlinkSync,
21
+ writeFileSync,
22
+ } from 'node:fs';
23
+ import { basename, join } from 'node:path';
24
+ import { Router } from 'express';
25
+ import { WORKSPACE_TERMINAL } from '../app/utils/status-constants.js';
26
+ import {
27
+ workspaceRunsDir as resolveWorkspaceRunsDir,
28
+ workspacesDir as resolveWorkspacesDir,
29
+ } from './paths.js';
30
+
31
+ const GUIDE_CAP_BYTES_DEFAULT = 64 * 1024;
32
+
33
+ const WS_ID_RE = /^ws_\d{12}_[0-9a-f]{1,32}$/;
34
+
35
+ const ACTIVE_STATUSES = new Set([
36
+ 'planning',
37
+ 'running',
38
+ 'integration_testing',
39
+ 'blocked',
40
+ ]);
41
+
42
+ const RESUMABLE_STATUSES = new Set([
43
+ 'halted',
44
+ 'failed',
45
+ 'integration_failed',
46
+ 'paused',
47
+ ]);
48
+
49
+ const PLAN_EDITABLE_STATUSES = new Set(['planning', 'halted', 'failed']);
50
+
51
+ // ─── helpers ───────────────────────────────────────────────────────────────
52
+
53
+ function validateWsId(id) {
54
+ return typeof id === 'string' && WS_ID_RE.test(id);
55
+ }
56
+
57
+ function readPointer(wsRunsDir, wsId) {
58
+ const p = join(wsRunsDir, `${wsId}.json`);
59
+ if (!existsSync(p)) return null;
60
+ try {
61
+ return JSON.parse(readFileSync(p, 'utf8'));
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ function readManifest(wsRunsDir, wsId) {
68
+ const pointer = readPointer(wsRunsDir, wsId);
69
+ if (!pointer?.workspace_root) return null;
70
+ const manifestPath = join(
71
+ pointer.workspace_root,
72
+ '.worca',
73
+ 'workspace-runs',
74
+ wsId,
75
+ 'workspace-manifest.json',
76
+ );
77
+ if (!existsSync(manifestPath)) return null;
78
+ try {
79
+ return JSON.parse(readFileSync(manifestPath, 'utf8'));
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
85
+ function runDir(manifest) {
86
+ return join(
87
+ manifest.workspace_root,
88
+ '.worca',
89
+ 'workspace-runs',
90
+ manifest.workspace_id,
91
+ );
92
+ }
93
+
94
+ function saveManifest(manifest) {
95
+ const dir = runDir(manifest);
96
+ mkdirSync(dir, { recursive: true });
97
+ const p = join(dir, 'workspace-manifest.json');
98
+ const tmp = `${p}.tmp.${process.pid}.${Date.now()}`;
99
+ try {
100
+ writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
101
+ renameSync(tmp, p);
102
+ } catch (err) {
103
+ try {
104
+ unlinkSync(tmp);
105
+ } catch {
106
+ /* best-effort */
107
+ }
108
+ throw err;
109
+ }
110
+ }
111
+
112
+ function listPointers(wsRunsDir) {
113
+ if (!existsSync(wsRunsDir)) return [];
114
+ const out = [];
115
+ for (const file of readdirSync(wsRunsDir)) {
116
+ if (!file.endsWith('.json')) continue;
117
+ try {
118
+ const p = JSON.parse(readFileSync(join(wsRunsDir, file), 'utf8'));
119
+ if (p?.workspace_id) out.push(p);
120
+ } catch {
121
+ // skip
122
+ }
123
+ }
124
+ return out;
125
+ }
126
+
127
+ function listWorkspaceRegistrations(workspacesDir) {
128
+ if (!existsSync(workspacesDir)) return [];
129
+ const out = [];
130
+ for (const file of readdirSync(workspacesDir)) {
131
+ if (!file.endsWith('.json')) continue;
132
+ try {
133
+ const entry = JSON.parse(readFileSync(join(workspacesDir, file), 'utf8'));
134
+ if (entry?.name) out.push(entry);
135
+ } catch {
136
+ // skip
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+
142
+ function readWorkspaceJson(wsRoot) {
143
+ const p = join(wsRoot, 'workspace.json');
144
+ if (!existsSync(p)) return null;
145
+ try {
146
+ return JSON.parse(readFileSync(p, 'utf8'));
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ function detectCycle(projects) {
153
+ const inDegree = {};
154
+ const dependents = {};
155
+ for (const r of projects) {
156
+ inDegree[r.name] = 0;
157
+ dependents[r.name] = [];
158
+ }
159
+ for (const r of projects) {
160
+ for (const dep of r.depends_on || []) {
161
+ if (!(dep in inDegree)) return `unknown dependency '${dep}'`;
162
+ inDegree[r.name]++;
163
+ dependents[dep].push(r.name);
164
+ }
165
+ }
166
+ const queue = Object.keys(inDegree).filter((n) => inDegree[n] === 0);
167
+ let processed = 0;
168
+ const next = [...queue];
169
+ while (next.length > 0) {
170
+ const name = next.shift();
171
+ processed++;
172
+ for (const dep of dependents[name]) {
173
+ inDegree[dep]--;
174
+ if (inDegree[dep] === 0) next.push(dep);
175
+ }
176
+ }
177
+ if (processed !== projects.length) {
178
+ const remaining = Object.keys(inDegree)
179
+ .filter((n) => inDegree[n] > 0)
180
+ .sort();
181
+ return `dependency cycle detected among projects: ${remaining.join(', ')}`;
182
+ }
183
+ return null;
184
+ }
185
+
186
+ function computeTiers(projects) {
187
+ const inDegree = {};
188
+ const dependents = {};
189
+ for (const r of projects) {
190
+ inDegree[r.name] = 0;
191
+ dependents[r.name] = [];
192
+ }
193
+ for (const r of projects) {
194
+ for (const dep of r.depends_on || []) {
195
+ inDegree[r.name]++;
196
+ dependents[dep].push(r.name);
197
+ }
198
+ }
199
+ const tiers = [];
200
+ let queue = Object.keys(inDegree)
201
+ .filter((n) => inDegree[n] === 0)
202
+ .sort();
203
+ while (queue.length > 0) {
204
+ tiers.push([...queue]);
205
+ const nextQueue = [];
206
+ for (const name of queue) {
207
+ for (const dep of dependents[name]) {
208
+ inDegree[dep]--;
209
+ if (inDegree[dep] === 0) nextQueue.push(dep);
210
+ }
211
+ }
212
+ queue = nextQueue.sort();
213
+ }
214
+ return tiers;
215
+ }
216
+
217
+ function generateWorkspaceId() {
218
+ const now = new Date();
219
+ const ts = [
220
+ now.getUTCFullYear(),
221
+ String(now.getUTCMonth() + 1).padStart(2, '0'),
222
+ String(now.getUTCDate()).padStart(2, '0'),
223
+ String(now.getUTCHours()).padStart(2, '0'),
224
+ String(now.getUTCMinutes()).padStart(2, '0'),
225
+ ].join('');
226
+ const rand = Math.random().toString(16).slice(2, 10).padStart(8, '0');
227
+ return { workspace_id: `ws_${ts}_${rand}`, workspace_id_short: rand };
228
+ }
229
+
230
+ function sanitizeFilename(raw) {
231
+ const name = basename(raw || 'guide')
232
+ .replace(/[/\\]/g, '')
233
+ .replace(/[^A-Za-z0-9._-]/g, '_');
234
+ return name || 'guide';
235
+ }
236
+
237
+ function scanForProjects(parentPath) {
238
+ const projects = [];
239
+ let entries;
240
+ try {
241
+ entries = readdirSync(parentPath);
242
+ } catch {
243
+ return projects;
244
+ }
245
+ for (const entry of entries) {
246
+ const full = join(parentPath, entry);
247
+ try {
248
+ if (!statSync(full).isDirectory()) continue;
249
+ } catch {
250
+ continue;
251
+ }
252
+ if (existsSync(join(full, '.git'))) {
253
+ projects.push({
254
+ name: entry,
255
+ path: entry,
256
+ role_hint: null,
257
+ });
258
+ }
259
+ }
260
+ return projects;
261
+ }
262
+
263
+ function enrichChildStatus(child) {
264
+ if (!child.run_id) return child;
265
+ // pipelines.d lives in the project root, NOT inside the worktree.
266
+ // Prefer `project_path` (set by DagExecutor on every child entry);
267
+ // fall back to deriving the project root from `worktree_path` for older
268
+ // manifests that didn't carry it. The worktree path is structured as
269
+ // `<project_root>/.worktrees/pipeline-<run_id>`, so the project root is
270
+ // two parent segments up.
271
+ let projectRoot = child.project_path;
272
+ if (!projectRoot && child.worktree_path) {
273
+ const idx = child.worktree_path.lastIndexOf('/.worktrees/');
274
+ if (idx > 0) projectRoot = child.worktree_path.slice(0, idx);
275
+ }
276
+ if (!projectRoot) return child;
277
+ const regPath = join(
278
+ projectRoot,
279
+ '.worca',
280
+ 'multi',
281
+ 'pipelines.d',
282
+ `${child.run_id}.json`,
283
+ );
284
+ if (!existsSync(regPath)) return child;
285
+ try {
286
+ const reg = JSON.parse(readFileSync(regPath, 'utf8'));
287
+ return { ...child, status: reg.status ?? child.status };
288
+ } catch {
289
+ return child;
290
+ }
291
+ }
292
+
293
+ // Read a child run's status.json. The `.worca/multi/pipelines.d/<id>.json`
294
+ // file is a lightweight pointer with `status / pid / branch / worktree_path`
295
+ // — it does NOT contain stages. The actual per-stage costs and PR fields
296
+ // live in `<worktree>/.worca/runs/<run_id>/status.json`.
297
+ function _readChildStatus(child) {
298
+ if (!child.run_id || !child.worktree_path) return null;
299
+ const statusPath = join(
300
+ child.worktree_path,
301
+ '.worca',
302
+ 'runs',
303
+ child.run_id,
304
+ 'status.json',
305
+ );
306
+ if (!existsSync(statusPath)) return null;
307
+ try {
308
+ return JSON.parse(readFileSync(statusPath, 'utf8'));
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ // ─── workspace status derivation ────────────────────────────────────────
315
+ // Mirrors fleet-routes.js deriveFleetStatus / effectiveFleetStatus /
316
+ // reconcileFleetStatus. The workspace orchestrator only writes status at
317
+ // a handful of fixed points (DagExecutor finishing, integration test
318
+ // finishing). If anything happens to child states between those writes —
319
+ // a child re-launches, a sibling pipeline lands late, the orchestrator
320
+ // process dies after marking COMPLETED — the stored manifest goes stale
321
+ // and the badge lies. Re-derive on every GET, persist on change, treat
322
+ // sticky states as already-decided.
323
+ //
324
+ // The workspace state machine has more phases than fleet:
325
+ // - `planning` and `integration_testing` are orchestrator-driven phases
326
+ // that should NOT be overridden by child polling (the children's
327
+ // statuses don't change during these phases, but the orchestrator's
328
+ // phase semantics do)
329
+ // - `integration_failed` is sticky like `failed` — children all done,
330
+ // integration test was the failure
331
+ // Only `running` reconciles against live children, same as fleet's
332
+ // `running` / `resuming`.
333
+
334
+ const _CHILD_RUNNING_STATES = new Set(['running', 'resuming', 'paused']);
335
+ const _CHILD_FAILURE_STATES = new Set([
336
+ 'failed',
337
+ 'setup_failed',
338
+ 'unrecoverable',
339
+ ]);
340
+ const _CHILD_TERMINAL_STATES = new Set([
341
+ 'completed',
342
+ 'interrupted',
343
+ 'cancelled',
344
+ 'blocked',
345
+ ..._CHILD_FAILURE_STATES,
346
+ ]);
347
+
348
+ // Statuses we never re-derive. Only orchestrator-phase markers
349
+ // (planning / integration_testing) and operator/circuit-breaker decisions
350
+ // (halted / paused / integration_failed / blocked) are sticky — the
351
+ // workspace can't re-derive its way out of those without an explicit
352
+ // Resume / Re-run.
353
+ //
354
+ // `running`, `completed`, and `failed` are NOT sticky. The orchestrator
355
+ // uses run_worktree.py as a fire-and-forget launcher, so it can write
356
+ // `completed` long before the actual pipeline finishes; re-deriving from
357
+ // the live registry on every read is the only way to keep the badge
358
+ // honest. (Fleet leaves completed/failed sticky because fleet runs
359
+ // terminate atomically; workspace is a coordination layer over
360
+ // independently-running pipelines and needs the looser semantics.)
361
+ const _STICKY_WORKSPACE_STATES = new Set([
362
+ 'planning',
363
+ 'integration_testing',
364
+ 'integration_failed',
365
+ 'halted',
366
+ 'paused',
367
+ 'blocked',
368
+ ]);
369
+
370
+ /**
371
+ * Pure derivation of workspace status from a flat list of child statuses.
372
+ * Used only when the manifest's current status is `running` — every other
373
+ * state is sticky.
374
+ *
375
+ * @param {string[]} childStatuses
376
+ * @returns {string} 'running' | 'completed' | 'failed'
377
+ */
378
+ export function deriveWorkspaceStatus(childStatuses) {
379
+ if (!childStatuses.length) return 'running';
380
+ const total = childStatuses.length;
381
+ const runningCount = childStatuses.filter((s) =>
382
+ _CHILD_RUNNING_STATES.has(s),
383
+ ).length;
384
+ const completedCount = childStatuses.filter((s) => s === 'completed').length;
385
+ const failedCount = childStatuses.filter((s) =>
386
+ _CHILD_FAILURE_STATES.has(s),
387
+ ).length;
388
+ const terminalCount = childStatuses.filter((s) =>
389
+ _CHILD_TERMINAL_STATES.has(s),
390
+ ).length;
391
+
392
+ // Any child still in flight → workspace is still running.
393
+ if (runningCount > 0) return 'running';
394
+
395
+ // All dispatched children are terminal.
396
+ if (terminalCount === total) {
397
+ // `failed` wins over `completed` even if just one child failed —
398
+ // matches fleet behaviour and the orchestrator's own DAG executor
399
+ // logic (any child failure in any tier flips the workspace to
400
+ // failed).
401
+ if (failedCount > 0 || completedCount < total) return 'failed';
402
+ return 'completed';
403
+ }
404
+
405
+ // Pending / untracked children not yet dispatched.
406
+ return 'running';
407
+ }
408
+
409
+ /**
410
+ * Combine stored manifest status with live child statuses to get the
411
+ * value the API should report. Sticky states pass through unchanged.
412
+ * Persists nothing — see reconcileWorkspaceStatus for the write variant.
413
+ *
414
+ * @param {object} manifest
415
+ * @param {string[]} childStatuses
416
+ * @returns {{ status: string, halt_reason: string|null }}
417
+ */
418
+ export function effectiveWorkspaceStatus(manifest, childStatuses) {
419
+ const current = manifest.status ?? 'running';
420
+ if (_STICKY_WORKSPACE_STATES.has(current)) {
421
+ return { status: current, halt_reason: manifest.halt_reason ?? null };
422
+ }
423
+ // current is running / completed / failed — re-derive against live
424
+ // child statuses. If the orchestrator wrote `completed` based on the
425
+ // fire-and-forget launcher exit but the actual pipelines are still
426
+ // running, this flips the badge back to `running` to match reality.
427
+ return {
428
+ status: deriveWorkspaceStatus(childStatuses),
429
+ halt_reason: manifest.halt_reason ?? null,
430
+ };
431
+ }
432
+
433
+ /**
434
+ * Reconcile manifest.status against live child statuses and persist when
435
+ * the derived value differs from what's stored. Returns the effective
436
+ * status so callers can fold it into their response.
437
+ *
438
+ * @param {object} manifest
439
+ * @returns {{ status: string, halt_reason: string|null }}
440
+ */
441
+ function reconcileWorkspaceStatus(manifest) {
442
+ const childStatuses = (manifest.children ?? [])
443
+ .map((c) => enrichChildStatus(c).status)
444
+ .filter(Boolean);
445
+ const current = manifest.status ?? 'running';
446
+ const { status, halt_reason } = effectiveWorkspaceStatus(
447
+ manifest,
448
+ childStatuses,
449
+ );
450
+ const storedHalt = manifest.halt_reason ?? null;
451
+ if (status !== current || halt_reason !== storedHalt) {
452
+ manifest.status = status;
453
+ if (halt_reason != null) {
454
+ manifest.halt_reason = halt_reason;
455
+ } else if (status !== 'halted') {
456
+ manifest.halt_reason = null;
457
+ }
458
+ manifest.updated_at = new Date().toISOString();
459
+ try {
460
+ saveManifest(manifest);
461
+ } catch {
462
+ // Best-effort persistence — the derived value is still returned,
463
+ // and the next read re-derives it anyway.
464
+ }
465
+ }
466
+ return { status, halt_reason };
467
+ }
468
+
469
+ function aggregateCost(manifest) {
470
+ let cost = 0;
471
+ for (const child of manifest.children ?? []) {
472
+ const st = _readChildStatus(child);
473
+ if (!st) continue;
474
+ for (const stage of Object.values(st.stages ?? {})) {
475
+ for (const iter of stage.iterations ?? []) {
476
+ cost += iter.cost_usd ?? 0;
477
+ }
478
+ }
479
+ }
480
+ return cost;
481
+ }
482
+
483
+ // Synthesize a workspace `finished_at` when the manifest is in a terminal
484
+ // state but no explicit field was written. We use the maximum
485
+ // `updated_at` across the child status.json files — closest available
486
+ // real timestamp for "when this workspace stopped progressing". Returns
487
+ // null if no children have status files yet (rare for terminal states).
488
+ function _synthesizeFinishedAt(manifest) {
489
+ if (manifest.finished_at) return manifest.finished_at;
490
+ if (!WORKSPACE_TERMINAL.has(manifest.status)) return null;
491
+ let latest = null;
492
+ for (const child of manifest.children ?? []) {
493
+ const st = _readChildStatus(child);
494
+ // child status.json carries `completed_at` for the run's wall-end
495
+ // timestamp; `updated_at` is from a different shape and isn't set on
496
+ // these files. Take the maximum across children.
497
+ const ts = st?.completed_at || st?.updated_at;
498
+ if (!ts) continue;
499
+ if (!latest || ts > latest) latest = ts;
500
+ }
501
+ return latest;
502
+ }
503
+
504
+ // ─── multipart parser ──────────────────────────────────────────────────────
505
+
506
+ function readRawBody(req) {
507
+ return new Promise((resolve, reject) => {
508
+ const chunks = [];
509
+ req.on('data', (c) => chunks.push(c));
510
+ req.on('end', () => resolve(Buffer.concat(chunks)));
511
+ req.on('error', reject);
512
+ });
513
+ }
514
+
515
+ function parseMultipart(body, contentType) {
516
+ const m = /boundary=([^\s;,]+)/.exec(contentType);
517
+ if (!m) return null;
518
+ const boundary = m[1].replace(/^["']|["']$/g, '');
519
+ const delim = Buffer.from(`\r\n--${boundary}`);
520
+ const parts = [];
521
+ const openStr = `--${boundary}\r\n`;
522
+ let pos = body.indexOf(openStr);
523
+ if (pos === -1) return parts;
524
+ pos += openStr.length;
525
+
526
+ while (pos < body.length) {
527
+ const end = body.indexOf(delim, pos);
528
+ if (end === -1) break;
529
+ const partBuf = body.slice(pos, end);
530
+ const hdrEnd = partBuf.indexOf('\r\n\r\n');
531
+ if (hdrEnd !== -1) {
532
+ const headerStr = partBuf.slice(0, hdrEnd).toString('utf8');
533
+ const content = partBuf.slice(hdrEnd + 4);
534
+ const headers = {};
535
+ for (const line of headerStr.split('\r\n')) {
536
+ const ci = line.indexOf(':');
537
+ if (ci !== -1) {
538
+ headers[line.slice(0, ci).toLowerCase().trim()] = line
539
+ .slice(ci + 1)
540
+ .trim();
541
+ }
542
+ }
543
+ const cd = headers['content-disposition'] ?? '';
544
+ const nm = /\bname="([^"]+)"/.exec(cd);
545
+ const fn = /\bfilename="([^"]+)"/.exec(cd);
546
+ parts.push({
547
+ name: nm?.[1] ?? null,
548
+ filename: fn?.[1] ?? null,
549
+ content,
550
+ });
551
+ }
552
+ pos = end + delim.length;
553
+ const after = body.slice(pos, pos + 2).toString();
554
+ if (after === '--') break;
555
+ pos += 2;
556
+ }
557
+ return parts;
558
+ }
559
+
560
+ // ─── default injectables ──────────────────────────────────────────────────
561
+
562
+ function defaultValidateBaseBranch(repoPath, branch) {
563
+ try {
564
+ const out = execFileSync(
565
+ 'git',
566
+ ['-C', repoPath, 'branch', '--list', branch],
567
+ { encoding: 'utf8' },
568
+ );
569
+ return out.trim().length > 0;
570
+ } catch {
571
+ return false;
572
+ }
573
+ }
574
+
575
+ function defaultValidateGhAuth(_workspace) {
576
+ return Promise.resolve([]);
577
+ }
578
+
579
+ function defaultHaltWorkspace(_wsId) {
580
+ return true;
581
+ }
582
+
583
+ function defaultRunCleanup(_wsId) {
584
+ return {};
585
+ }
586
+
587
+ function defaultRunIntegrationTest(_manifest) {
588
+ return Promise.resolve({
589
+ status: 'passed',
590
+ exit_code: 0,
591
+ log_path: null,
592
+ });
593
+ }
594
+
595
+ // ─── router factory ────────────────────────────────────────────────────────
596
+
597
+ export function createWorkspaceRouter({
598
+ workspaceRunsDir: workspaceRunsDirArg,
599
+ workspacesDir: workspacesDirArg,
600
+ dispatchWorkspace = null,
601
+ haltWorkspace = defaultHaltWorkspace,
602
+ runCleanup = defaultRunCleanup,
603
+ validateBaseBranch = defaultValidateBaseBranch,
604
+ validateGhAuth = defaultValidateGhAuth,
605
+ runIntegrationTest = defaultRunIntegrationTest,
606
+ guideCapBytes = GUIDE_CAP_BYTES_DEFAULT,
607
+ } = {}) {
608
+ // Lazy resolution honors $WORCA_HOME (issue #162).
609
+ const workspaceRunsDir = resolveWorkspaceRunsDir(workspaceRunsDirArg);
610
+ const workspacesDir = resolveWorkspacesDir(workspacesDirArg);
611
+
612
+ const workspaces = Router();
613
+ const workspaceRuns = Router();
614
+
615
+ // ════════════════════════════════════════════════════════════════════════
616
+ // workspaces router — mounted at /api/workspaces
617
+ // ════════════════════════════════════════════════════════════════════════
618
+
619
+ // ── POST /api/workspaces/scan ─────────────────────────────────────────
620
+ workspaces.post('/scan', (req, res) => {
621
+ const { parent_path } = req.body ?? {};
622
+ if (!parent_path || typeof parent_path !== 'string') {
623
+ return res
624
+ .status(400)
625
+ .json({ ok: false, error: 'parent_path is required' });
626
+ }
627
+ if (!existsSync(parent_path)) {
628
+ return res
629
+ .status(400)
630
+ .json({ ok: false, error: `Path does not exist: ${parent_path}` });
631
+ }
632
+ try {
633
+ const projects = scanForProjects(parent_path);
634
+ res.json({ ok: true, projects });
635
+ } catch (err) {
636
+ res.status(500).json({ ok: false, error: err.message });
637
+ }
638
+ });
639
+
640
+ // ── POST /api/workspaces ──────────────────────────────────────────────
641
+ workspaces.post('/', (req, res) => {
642
+ const { name, parent_path, projects, integration_test, umbrella_repo } =
643
+ req.body ?? {};
644
+ if (!name || typeof name !== 'string') {
645
+ return res.status(400).json({ ok: false, error: 'name is required' });
646
+ }
647
+ if (!parent_path || typeof parent_path !== 'string') {
648
+ return res
649
+ .status(400)
650
+ .json({ ok: false, error: 'parent_path is required' });
651
+ }
652
+ if (!Array.isArray(projects) || projects.length === 0) {
653
+ return res
654
+ .status(400)
655
+ .json({ ok: false, error: 'projects must be a non-empty array' });
656
+ }
657
+
658
+ const cycleErr = detectCycle(projects);
659
+ if (cycleErr) {
660
+ return res.status(422).json({ ok: false, error: cycleErr });
661
+ }
662
+
663
+ const wsJson = { name, projects };
664
+ if (integration_test) wsJson.integration_test = integration_test;
665
+ if (umbrella_repo) wsJson.umbrella_repo = umbrella_repo;
666
+
667
+ try {
668
+ writeFileSync(
669
+ join(parent_path, 'workspace.json'),
670
+ `${JSON.stringify(wsJson, null, 2)}\n`,
671
+ 'utf8',
672
+ );
673
+
674
+ mkdirSync(workspacesDir, { recursive: true });
675
+ const safeName = sanitizeFilename(name);
676
+ writeFileSync(
677
+ join(workspacesDir, `${safeName}.json`),
678
+ `${JSON.stringify({ name, path: parent_path }, null, 2)}\n`,
679
+ 'utf8',
680
+ );
681
+
682
+ res.status(201).json({ ok: true });
683
+ } catch (err) {
684
+ res.status(500).json({ ok: false, error: err.message });
685
+ }
686
+ });
687
+
688
+ // ── GET /api/workspaces ───────────────────────────────────────────────
689
+ workspaces.get('/', (_req, res) => {
690
+ try {
691
+ const registrations = listWorkspaceRegistrations(workspacesDir);
692
+ const result = registrations.map((reg) => {
693
+ const ws = readWorkspaceJson(reg.path);
694
+ return {
695
+ name: reg.name,
696
+ path: reg.path,
697
+ projects: ws?.projects ?? [],
698
+ integration_test: ws?.integration_test ?? null,
699
+ umbrella_repo: ws?.umbrella_repo ?? null,
700
+ };
701
+ });
702
+ res.json({ ok: true, workspaces: result });
703
+ } catch (err) {
704
+ res.status(500).json({ ok: false, error: err.message });
705
+ }
706
+ });
707
+
708
+ // ── GET /api/workspaces/:name ─────────────────────────────────────────
709
+ workspaces.get('/:name', (req, res) => {
710
+ const name = sanitizeFilename(req.params.name);
711
+ const regPath = join(workspacesDir, `${name}.json`);
712
+ if (!existsSync(regPath)) {
713
+ return res
714
+ .status(404)
715
+ .json({ ok: false, error: `Workspace "${name}" not found` });
716
+ }
717
+
718
+ try {
719
+ const reg = JSON.parse(readFileSync(regPath, 'utf8'));
720
+ const ws = readWorkspaceJson(reg.path);
721
+ if (!ws) {
722
+ return res.status(404).json({
723
+ ok: false,
724
+ error: `workspace.json not found at ${reg.path}`,
725
+ });
726
+ }
727
+ // Include parent path so the edit view can scan the directory for
728
+ // currently-unselected repos and offer them as additions. Without
729
+ // this the form can only remove repos, not add new ones.
730
+ res.json({ ok: true, workspace: ws, path: reg.path });
731
+ } catch (err) {
732
+ res.status(500).json({ ok: false, error: err.message });
733
+ }
734
+ });
735
+
736
+ // ── PUT /api/workspaces/:name ─────────────────────────────────────────
737
+ workspaces.put('/:name', (req, res) => {
738
+ const name = sanitizeFilename(req.params.name);
739
+ const regPath = join(workspacesDir, `${name}.json`);
740
+ if (!existsSync(regPath)) {
741
+ return res
742
+ .status(404)
743
+ .json({ ok: false, error: `Workspace "${name}" not found` });
744
+ }
745
+
746
+ let reg;
747
+ try {
748
+ reg = JSON.parse(readFileSync(regPath, 'utf8'));
749
+ } catch (err) {
750
+ return res.status(500).json({ ok: false, error: err.message });
751
+ }
752
+
753
+ const pointers = listPointers(workspaceRunsDir);
754
+ const hasActiveRuns = pointers.some((p) => {
755
+ if (p.workspace_root !== reg.path) return false;
756
+ const m = readManifest(workspaceRunsDir, p.workspace_id);
757
+ return m && ACTIVE_STATUSES.has(m.status);
758
+ });
759
+
760
+ if (hasActiveRuns) {
761
+ return res.status(409).json({
762
+ ok: false,
763
+ error:
764
+ 'Cannot edit workspace while active runs exist. Halt or wait for completion.',
765
+ });
766
+ }
767
+
768
+ const updated = req.body ?? {};
769
+ const projects = updated.projects ?? [];
770
+ const cycleErr = detectCycle(projects);
771
+ if (cycleErr) {
772
+ return res.status(422).json({ ok: false, error: cycleErr });
773
+ }
774
+
775
+ try {
776
+ writeFileSync(
777
+ join(reg.path, 'workspace.json'),
778
+ `${JSON.stringify(updated, null, 2)}\n`,
779
+ 'utf8',
780
+ );
781
+ res.json({ ok: true });
782
+ } catch (err) {
783
+ res.status(500).json({ ok: false, error: err.message });
784
+ }
785
+ });
786
+
787
+ // ── DELETE /api/workspaces/:name ──────────────────────────────────────
788
+ workspaces.delete('/:name', (req, res) => {
789
+ const name = sanitizeFilename(req.params.name);
790
+ const regPath = join(workspacesDir, `${name}.json`);
791
+ if (!existsSync(regPath)) {
792
+ return res
793
+ .status(404)
794
+ .json({ ok: false, error: `Workspace "${name}" not found` });
795
+ }
796
+
797
+ let reg;
798
+ try {
799
+ reg = JSON.parse(readFileSync(regPath, 'utf8'));
800
+ } catch (err) {
801
+ return res.status(500).json({ ok: false, error: err.message });
802
+ }
803
+
804
+ // Refuse deletion while any non-terminal run still references the
805
+ // workspace root — deleting the topology mid-flight would orphan the
806
+ // children and leave the manifest pointing at a missing workspace.json.
807
+ const pointers = listPointers(workspaceRunsDir);
808
+ const activeRuns = pointers.filter((p) => {
809
+ if (p.workspace_root !== reg.path) return false;
810
+ const m = readManifest(workspaceRunsDir, p.workspace_id);
811
+ return m && ACTIVE_STATUSES.has(m.status);
812
+ });
813
+
814
+ if (activeRuns.length > 0) {
815
+ return res.status(409).json({
816
+ ok: false,
817
+ error: `Cannot delete workspace while ${activeRuns.length} active run(s) reference it. Halt them first.`,
818
+ });
819
+ }
820
+
821
+ // Remove both the registration and the topology file in the parent.
822
+ // Caller (UI) confirmed this is destructive before reaching here.
823
+ try {
824
+ unlinkSync(regPath);
825
+ } catch (err) {
826
+ return res.status(500).json({
827
+ ok: false,
828
+ error: `Failed to remove registration: ${err.message}`,
829
+ });
830
+ }
831
+
832
+ const wsJsonPath = join(reg.path, 'workspace.json');
833
+ if (existsSync(wsJsonPath)) {
834
+ try {
835
+ unlinkSync(wsJsonPath);
836
+ } catch (err) {
837
+ // Registration already gone — surface the partial failure but don't
838
+ // pretend success; user may want to clean up the leftover file.
839
+ return res.status(500).json({
840
+ ok: false,
841
+ error: `Registration removed, but failed to delete ${wsJsonPath}: ${err.message}`,
842
+ });
843
+ }
844
+ }
845
+
846
+ res.json({ ok: true });
847
+ });
848
+
849
+ // ════════════════════════════════════════════════════════════════════════
850
+ // workspaceRuns router — mounted at /api/workspace-runs
851
+ // ════════════════════════════════════════════════════════════════════════
852
+
853
+ // ── POST /api/workspace-runs/validate-gh-auth ─────────────────────────
854
+ workspaceRuns.post('/validate-gh-auth', async (req, res) => {
855
+ const { workspace_name } = req.body ?? {};
856
+ if (!workspace_name || typeof workspace_name !== 'string') {
857
+ return res
858
+ .status(400)
859
+ .json({ ok: false, error: 'workspace_name is required' });
860
+ }
861
+
862
+ const safeWsName = sanitizeFilename(workspace_name);
863
+ const regPath = join(workspacesDir, `${safeWsName}.json`);
864
+ if (!existsSync(regPath)) {
865
+ return res
866
+ .status(404)
867
+ .json({ ok: false, error: `Workspace "${workspace_name}" not found` });
868
+ }
869
+
870
+ try {
871
+ const reg = JSON.parse(readFileSync(regPath, 'utf8'));
872
+ const ws = readWorkspaceJson(reg.path);
873
+ const missing_orgs = await validateGhAuth(ws);
874
+ res.json({ ok: missing_orgs.length === 0, missing_orgs });
875
+ } catch (err) {
876
+ res.status(500).json({ ok: false, error: err.message });
877
+ }
878
+ });
879
+
880
+ // ── POST /api/workspace-runs/validate-base ────────────────────────────
881
+ workspaceRuns.post('/validate-base', async (req, res) => {
882
+ const { workspace_name, base_branch } = req.body ?? {};
883
+ if (!workspace_name || typeof workspace_name !== 'string') {
884
+ return res
885
+ .status(400)
886
+ .json({ ok: false, error: 'workspace_name is required' });
887
+ }
888
+ if (!base_branch || typeof base_branch !== 'string') {
889
+ return res
890
+ .status(400)
891
+ .json({ ok: false, error: 'base_branch is required' });
892
+ }
893
+
894
+ const safeWsName = sanitizeFilename(workspace_name);
895
+ const regPath = join(workspacesDir, `${safeWsName}.json`);
896
+ if (!existsSync(regPath)) {
897
+ return res
898
+ .status(404)
899
+ .json({ ok: false, error: `Workspace "${workspace_name}" not found` });
900
+ }
901
+
902
+ try {
903
+ const reg = JSON.parse(readFileSync(regPath, 'utf8'));
904
+ const ws = readWorkspaceJson(reg.path);
905
+ if (!ws) {
906
+ return res.status(404).json({
907
+ ok: false,
908
+ error: `workspace.json not found at ${reg.path}`,
909
+ });
910
+ }
911
+
912
+ const missing_in = [];
913
+ for (const project of ws.projects) {
914
+ const projectPath = join(reg.path, project.path);
915
+ const exists = await validateBaseBranch(projectPath, base_branch);
916
+ if (!exists) missing_in.push(project.name);
917
+ }
918
+ res.json({ ok: missing_in.length === 0, missing_in });
919
+ } catch (err) {
920
+ res.status(500).json({ ok: false, error: err.message });
921
+ }
922
+ });
923
+
924
+ // ── POST /api/workspace-runs ──────────────────────────────────────────
925
+ workspaceRuns.post('/', async (req, res) => {
926
+ try {
927
+ const contentType = req.headers['content-type'] ?? '';
928
+ const isMultipart = contentType.includes('multipart/form-data');
929
+
930
+ let fields = {};
931
+ const guideFiles = [];
932
+
933
+ if (isMultipart) {
934
+ const rawBody = await readRawBody(req);
935
+ const parts = parseMultipart(rawBody, contentType);
936
+ if (!parts) {
937
+ return res
938
+ .status(400)
939
+ .json({ ok: false, error: 'Failed to parse multipart body' });
940
+ }
941
+ for (const part of parts) {
942
+ if (part.filename != null) {
943
+ guideFiles.push({ filename: part.filename, content: part.content });
944
+ } else if (part.name) {
945
+ fields[part.name] = part.content.toString('utf8');
946
+ }
947
+ }
948
+ } else {
949
+ fields = req.body ?? {};
950
+ }
951
+
952
+ const {
953
+ workspace_name,
954
+ prompt,
955
+ source,
956
+ plan_mode,
957
+ branch_template,
958
+ max_parallel = 5,
959
+ } = fields;
960
+
961
+ if (!workspace_name) {
962
+ return res
963
+ .status(400)
964
+ .json({ ok: false, error: 'workspace_name is required' });
965
+ }
966
+ if (!prompt && !source) {
967
+ return res
968
+ .status(400)
969
+ .json({ ok: false, error: 'prompt or source is required' });
970
+ }
971
+
972
+ const safeWsName = sanitizeFilename(workspace_name);
973
+ const regPath = join(workspacesDir, `${safeWsName}.json`);
974
+ if (!existsSync(regPath)) {
975
+ return res.status(404).json({
976
+ ok: false,
977
+ error: `Workspace "${workspace_name}" not found`,
978
+ });
979
+ }
980
+
981
+ const reg = JSON.parse(readFileSync(regPath, 'utf8'));
982
+ const wsRoot = reg.path;
983
+ const ws = readWorkspaceJson(wsRoot);
984
+ if (!ws) {
985
+ return res.status(404).json({
986
+ ok: false,
987
+ error: `workspace.json not found at ${wsRoot}`,
988
+ });
989
+ }
990
+
991
+ const cycleErr = detectCycle(ws.projects);
992
+ if (cycleErr) {
993
+ return res.status(422).json({ ok: false, error: cycleErr });
994
+ }
995
+
996
+ const { workspace_id, workspace_id_short } = generateWorkspaceId();
997
+ const wsRunDir = join(wsRoot, '.worca', 'workspace-runs', workspace_id);
998
+ mkdirSync(wsRunDir, { recursive: true });
999
+
1000
+ mkdirSync(workspaceRunsDir, { recursive: true });
1001
+ writeFileSync(
1002
+ join(workspaceRunsDir, `${workspace_id}.json`),
1003
+ `${JSON.stringify({ workspace_root: wsRoot, workspace_id }, null, 2)}\n`,
1004
+ );
1005
+
1006
+ let guideEntry = null;
1007
+ if (guideFiles.length > 0) {
1008
+ const totalBytes = guideFiles.reduce((s, f) => s + f.content.length, 0);
1009
+ if (totalBytes > guideCapBytes) {
1010
+ return res.status(400).json({
1011
+ ok: false,
1012
+ error: `Guide files exceed size cap of ${guideCapBytes} bytes`,
1013
+ });
1014
+ }
1015
+ const guidesDir = join(wsRunDir, 'guides');
1016
+ mkdirSync(guidesDir, { recursive: true });
1017
+ const paths = [];
1018
+ const filenames = [];
1019
+ const usedNames = new Set();
1020
+ for (const { filename, content } of guideFiles) {
1021
+ let safe = sanitizeFilename(filename);
1022
+ if (usedNames.has(safe)) {
1023
+ const dot = safe.lastIndexOf('.');
1024
+ const nameBase = dot !== -1 ? safe.slice(0, dot) : safe;
1025
+ const ext = dot !== -1 ? safe.slice(dot) : '';
1026
+ let counter = 1;
1027
+ while (usedNames.has(`${nameBase}-${counter}${ext}`)) counter++;
1028
+ safe = `${nameBase}-${counter}${ext}`;
1029
+ }
1030
+ usedNames.add(safe);
1031
+ writeFileSync(join(guidesDir, safe), content);
1032
+ paths.push(join(guidesDir, safe));
1033
+ filenames.push(safe);
1034
+ }
1035
+ guideEntry = { paths, bytes: totalBytes, filenames, uploaded: true };
1036
+ }
1037
+
1038
+ const tiers = computeTiers(ws.projects);
1039
+ const dagTiers = tiers.map((projects, i) => ({
1040
+ tier: i,
1041
+ projects,
1042
+ status: 'pending',
1043
+ }));
1044
+
1045
+ const manifest = {
1046
+ workspace_id,
1047
+ workspace_id_short,
1048
+ workspace_name: ws.name,
1049
+ workspace_root: wsRoot,
1050
+ created_at: new Date().toISOString(),
1051
+ work_request: {
1052
+ title: (prompt || source || '').slice(0, 80),
1053
+ description: prompt ?? '',
1054
+ source: source ?? null,
1055
+ },
1056
+ guide: guideEntry,
1057
+ branch_template: branch_template ?? 'workspace/{slug}/{project}',
1058
+ max_parallel: Number(max_parallel) || 5,
1059
+ skip_integration: false,
1060
+ skip_planning: plan_mode === 'skip',
1061
+ status: 'planning',
1062
+ halt_reason: null,
1063
+ dag: { tiers: dagTiers },
1064
+ children: [],
1065
+ integration_test: {
1066
+ status: 'pending',
1067
+ exit_code: null,
1068
+ log_path: null,
1069
+ },
1070
+ };
1071
+
1072
+ saveManifest(manifest);
1073
+
1074
+ if (dispatchWorkspace) {
1075
+ try {
1076
+ await dispatchWorkspace({
1077
+ workspace_id,
1078
+ workspace_root: wsRoot,
1079
+ manifest,
1080
+ });
1081
+ } catch (err) {
1082
+ manifest.status = 'failed';
1083
+ saveManifest(manifest);
1084
+ return res
1085
+ .status(500)
1086
+ .json({ ok: false, error: `Dispatch failed: ${err.message}` });
1087
+ }
1088
+ }
1089
+
1090
+ res.status(201).json({ ok: true, workspace_id });
1091
+ } catch (err) {
1092
+ res.status(500).json({ ok: false, error: err.message });
1093
+ }
1094
+ });
1095
+
1096
+ // ── GET /api/workspace-runs ───────────────────────────────────────────
1097
+ //
1098
+ // Returns a list of workspace summaries. The payload includes a compact
1099
+ // `children` array (one slim record per dispatched child) so the card
1100
+ // can render `projectBadgesView` without a separate detail fetch per
1101
+ // workspace — mirrors fleet-routes.js.
1102
+ workspaceRuns.get('/', (_req, res) => {
1103
+ try {
1104
+ const pointers = listPointers(workspaceRunsDir);
1105
+ const runs = [];
1106
+ for (const pointer of pointers) {
1107
+ const m = readManifest(workspaceRunsDir, pointer.workspace_id);
1108
+ if (!m) continue;
1109
+ // Reconcile against live child statuses so the badge reflects
1110
+ // what's actually happening instead of the orchestrator's last
1111
+ // write. Sticky states (planning / integration_testing /
1112
+ // halted / paused / integration_failed) pass through unchanged.
1113
+ const { status, halt_reason } = reconcileWorkspaceStatus(m);
1114
+ // Slim child records for projectBadgesView. Pass project, project_path
1115
+ // (used by fleet-card's _shortRepoName fallback), and the live status
1116
+ // (reconcile already enriched it onto manifest.children via the
1117
+ // mutation inside reconcileWorkspaceStatus → enrichChildStatus).
1118
+ const children = (m.children ?? []).map((c) => {
1119
+ const enriched = enrichChildStatus(c);
1120
+ return {
1121
+ project: enriched.project,
1122
+ project_path: enriched.project_path,
1123
+ run_id: enriched.run_id,
1124
+ status: enriched.status,
1125
+ tier: enriched.tier,
1126
+ };
1127
+ });
1128
+ runs.push({
1129
+ workspace_id: m.workspace_id,
1130
+ workspace_name: m.workspace_name,
1131
+ workspace_root: m.workspace_root,
1132
+ status,
1133
+ halt_reason,
1134
+ work_request: m.work_request,
1135
+ created_at: m.created_at,
1136
+ finished_at: _synthesizeFinishedAt(m),
1137
+ dag: m.dag,
1138
+ children,
1139
+ children_count: children.length,
1140
+ });
1141
+ }
1142
+ res.json({ ok: true, workspace_runs: runs });
1143
+ } catch (err) {
1144
+ res.status(500).json({ ok: false, error: err.message });
1145
+ }
1146
+ });
1147
+
1148
+ // ── GET /api/workspace-runs/:id ───────────────────────────────────────
1149
+ workspaceRuns.get('/:id', (req, res) => {
1150
+ const { id } = req.params;
1151
+ if (!validateWsId(id)) {
1152
+ return res.status(400).json({ ok: false, error: 'Invalid workspace ID' });
1153
+ }
1154
+ const manifest = readManifest(workspaceRunsDir, id);
1155
+ if (!manifest) {
1156
+ return res
1157
+ .status(404)
1158
+ .json({ ok: false, error: `Workspace run "${id}" not found` });
1159
+ }
1160
+ // Reconcile status against live child statuses before responding so
1161
+ // detail-view consumers see the same effective status as the list.
1162
+ // reconcileWorkspaceStatus mutates manifest.status / halt_reason in
1163
+ // place and persists when changed.
1164
+ reconcileWorkspaceStatus(manifest);
1165
+ const children = (manifest.children ?? []).map(enrichChildStatus);
1166
+ const cost_usd = aggregateCost(manifest);
1167
+ // Synthesize finished_at for terminal manifests so the UI can compute
1168
+ // a stable duration. Older runs (pre-this-fix) and any run whose
1169
+ // status was set to terminal without updating the manifest field will
1170
+ // get a value derived from the child status.json updated_at maxima.
1171
+ const finished_at = _synthesizeFinishedAt(manifest);
1172
+ res.json({
1173
+ ok: true,
1174
+ manifest: { ...manifest, children, finished_at },
1175
+ cost_usd,
1176
+ });
1177
+ });
1178
+
1179
+ // ── DELETE /api/workspace-runs/:id ────────────────────────────────────
1180
+ workspaceRuns.delete('/:id', async (req, res) => {
1181
+ const { id } = req.params;
1182
+ if (!validateWsId(id)) {
1183
+ return res.status(400).json({ ok: false, error: 'Invalid workspace ID' });
1184
+ }
1185
+ const manifest = readManifest(workspaceRunsDir, id);
1186
+ if (!manifest) {
1187
+ return res
1188
+ .status(404)
1189
+ .json({ ok: false, error: `Workspace run "${id}" not found` });
1190
+ }
1191
+
1192
+ const { cleanup, force } = req.query;
1193
+ const status = manifest.status;
1194
+ const isResumable = RESUMABLE_STATUSES.has(status);
1195
+
1196
+ if (cleanup === '1' && isResumable && force !== '1') {
1197
+ return res.status(412).json({
1198
+ ok: false,
1199
+ error:
1200
+ 'Workspace is in a resumable state. Pass ?force=1 to confirm cleanup.',
1201
+ current_status: status,
1202
+ });
1203
+ }
1204
+
1205
+ if (cleanup !== '1' && isResumable) {
1206
+ return res.json({ ok: true, already_halted: true });
1207
+ }
1208
+
1209
+ haltWorkspace(id);
1210
+
1211
+ if (cleanup === '1') {
1212
+ try {
1213
+ const cleanResult = (await runCleanup(id)) ?? {};
1214
+ return res.json({ ok: true, ...cleanResult });
1215
+ } catch (err) {
1216
+ return res
1217
+ .status(500)
1218
+ .json({ ok: false, error: `Cleanup failed: ${err.message}` });
1219
+ }
1220
+ }
1221
+
1222
+ res.json({ ok: true });
1223
+ });
1224
+
1225
+ // ── POST /api/workspace-runs/:id/resume ───────────────────────────────
1226
+ workspaceRuns.post('/:id/resume', async (req, res) => {
1227
+ const { id } = req.params;
1228
+ if (!validateWsId(id)) {
1229
+ return res.status(400).json({ ok: false, error: 'Invalid workspace ID' });
1230
+ }
1231
+ const manifest = readManifest(workspaceRunsDir, id);
1232
+ if (!manifest) {
1233
+ return res
1234
+ .status(404)
1235
+ .json({ ok: false, error: `Workspace run "${id}" not found` });
1236
+ }
1237
+
1238
+ if (manifest.status === 'running' || manifest.status === 'planning') {
1239
+ return res
1240
+ .status(409)
1241
+ .json({ ok: false, error: 'Workspace is already running' });
1242
+ }
1243
+
1244
+ if (dispatchWorkspace) {
1245
+ try {
1246
+ await dispatchWorkspace({
1247
+ workspace_id: id,
1248
+ workspace_root: manifest.workspace_root,
1249
+ manifest,
1250
+ resume: true,
1251
+ });
1252
+ } catch (err) {
1253
+ return res
1254
+ .status(500)
1255
+ .json({ ok: false, error: `Resume failed: ${err.message}` });
1256
+ }
1257
+ }
1258
+
1259
+ manifest.status = 'running';
1260
+ manifest.halt_reason = null;
1261
+ saveManifest(manifest);
1262
+
1263
+ res.json({ ok: true });
1264
+ });
1265
+
1266
+ // ── POST /api/workspace-runs/:id/relaunch ─────────────────────────────
1267
+ workspaceRuns.post('/:id/relaunch', async (req, res) => {
1268
+ const { id } = req.params;
1269
+ if (!validateWsId(id)) {
1270
+ return res.status(400).json({ ok: false, error: 'Invalid workspace ID' });
1271
+ }
1272
+ const manifest = readManifest(workspaceRunsDir, id);
1273
+ if (!manifest) {
1274
+ return res
1275
+ .status(404)
1276
+ .json({ ok: false, error: `Workspace run "${id}" not found` });
1277
+ }
1278
+
1279
+ const overrides = req.body ?? {};
1280
+ const { workspace_id: newId, workspace_id_short: newShort } =
1281
+ generateWorkspaceId();
1282
+
1283
+ const newManifest = {
1284
+ ...manifest,
1285
+ workspace_id: newId,
1286
+ workspace_id_short: newShort,
1287
+ created_at: new Date().toISOString(),
1288
+ status: 'planning',
1289
+ halt_reason: null,
1290
+ children: [],
1291
+ work_request: {
1292
+ ...manifest.work_request,
1293
+ ...(overrides.prompt
1294
+ ? {
1295
+ description: overrides.prompt,
1296
+ title: overrides.prompt.slice(0, 80),
1297
+ }
1298
+ : {}),
1299
+ },
1300
+ integration_test: {
1301
+ status: 'pending',
1302
+ exit_code: null,
1303
+ log_path: null,
1304
+ },
1305
+ };
1306
+
1307
+ if (newManifest.dag?.tiers) {
1308
+ newManifest.dag.tiers = newManifest.dag.tiers.map((t) => ({
1309
+ ...t,
1310
+ status: 'pending',
1311
+ }));
1312
+ }
1313
+
1314
+ mkdirSync(workspaceRunsDir, { recursive: true });
1315
+ writeFileSync(
1316
+ join(workspaceRunsDir, `${newId}.json`),
1317
+ `${JSON.stringify({ workspace_root: manifest.workspace_root, workspace_id: newId }, null, 2)}\n`,
1318
+ );
1319
+
1320
+ const newRunDir = join(
1321
+ manifest.workspace_root,
1322
+ '.worca',
1323
+ 'workspace-runs',
1324
+ newId,
1325
+ );
1326
+ mkdirSync(newRunDir, { recursive: true });
1327
+ saveManifest(newManifest);
1328
+
1329
+ if (dispatchWorkspace) {
1330
+ try {
1331
+ await dispatchWorkspace({
1332
+ workspace_id: newId,
1333
+ workspace_root: manifest.workspace_root,
1334
+ manifest: newManifest,
1335
+ });
1336
+ } catch (err) {
1337
+ newManifest.status = 'failed';
1338
+ saveManifest(newManifest);
1339
+ return res
1340
+ .status(500)
1341
+ .json({ ok: false, error: `Relaunch failed: ${err.message}` });
1342
+ }
1343
+ }
1344
+
1345
+ res.status(201).json({ ok: true, new_workspace_id: newId });
1346
+ });
1347
+
1348
+ // ── POST /api/workspace-runs/:id/re-run-integration ───────────────────
1349
+ workspaceRuns.post('/:id/re-run-integration', async (req, res) => {
1350
+ const { id } = req.params;
1351
+ if (!validateWsId(id)) {
1352
+ return res.status(400).json({ ok: false, error: 'Invalid workspace ID' });
1353
+ }
1354
+ const manifest = readManifest(workspaceRunsDir, id);
1355
+ if (!manifest) {
1356
+ return res
1357
+ .status(404)
1358
+ .json({ ok: false, error: `Workspace run "${id}" not found` });
1359
+ }
1360
+
1361
+ try {
1362
+ const result = await runIntegrationTest(manifest);
1363
+ manifest.integration_test = result;
1364
+ if (result.status === 'passed') {
1365
+ manifest.status = 'completed';
1366
+ } else {
1367
+ manifest.status = 'integration_failed';
1368
+ }
1369
+ saveManifest(manifest);
1370
+ res.json({ ok: true, integration_test: result });
1371
+ } catch (err) {
1372
+ res.status(500).json({ ok: false, error: err.message });
1373
+ }
1374
+ });
1375
+
1376
+ // ── GET /api/workspace-runs/:id/plan ──────────────────────────────────
1377
+ workspaceRuns.get('/:id/plan', (req, res) => {
1378
+ const { id } = req.params;
1379
+ if (!validateWsId(id)) {
1380
+ return res.status(400).json({ ok: false, error: 'Invalid workspace ID' });
1381
+ }
1382
+ const manifest = readManifest(workspaceRunsDir, id);
1383
+ if (!manifest) {
1384
+ return res
1385
+ .status(404)
1386
+ .json({ ok: false, error: `Workspace run "${id}" not found` });
1387
+ }
1388
+
1389
+ const dir = runDir(manifest);
1390
+ const wantsJson = (req.headers.accept ?? '').includes('application/json');
1391
+
1392
+ if (wantsJson) {
1393
+ const jsonPath = join(dir, 'workspace-plan.json');
1394
+ if (!existsSync(jsonPath)) {
1395
+ return res
1396
+ .status(404)
1397
+ .json({ ok: false, error: 'No plan found for this workspace run' });
1398
+ }
1399
+ try {
1400
+ const plan = JSON.parse(readFileSync(jsonPath, 'utf8'));
1401
+ res.json(plan);
1402
+ } catch (err) {
1403
+ res.status(500).json({ ok: false, error: err.message });
1404
+ }
1405
+ } else {
1406
+ const mdPath = join(dir, 'workspace-plan.md');
1407
+ if (!existsSync(mdPath)) {
1408
+ return res
1409
+ .status(404)
1410
+ .json({ ok: false, error: 'No plan found for this workspace run' });
1411
+ }
1412
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
1413
+ res.send(readFileSync(mdPath, 'utf8'));
1414
+ }
1415
+ });
1416
+
1417
+ // ── PUT /api/workspace-runs/:id/plan ──────────────────────────────────
1418
+ workspaceRuns.put('/:id/plan', (req, res) => {
1419
+ const { id } = req.params;
1420
+ if (!validateWsId(id)) {
1421
+ return res.status(400).json({ ok: false, error: 'Invalid workspace ID' });
1422
+ }
1423
+ const manifest = readManifest(workspaceRunsDir, id);
1424
+ if (!manifest) {
1425
+ return res
1426
+ .status(404)
1427
+ .json({ ok: false, error: `Workspace run "${id}" not found` });
1428
+ }
1429
+
1430
+ if (!PLAN_EDITABLE_STATUSES.has(manifest.status)) {
1431
+ return res.status(409).json({
1432
+ ok: false,
1433
+ error: `Cannot edit plan in "${manifest.status}" state`,
1434
+ current_status: manifest.status,
1435
+ });
1436
+ }
1437
+
1438
+ const { plan_json } = req.body ?? {};
1439
+ if (!plan_json || typeof plan_json !== 'object') {
1440
+ return res
1441
+ .status(400)
1442
+ .json({ ok: false, error: 'plan_json is required' });
1443
+ }
1444
+
1445
+ try {
1446
+ const dir = runDir(manifest);
1447
+ mkdirSync(dir, { recursive: true });
1448
+ writeFileSync(
1449
+ join(dir, 'workspace-plan.json'),
1450
+ `${JSON.stringify(plan_json, null, 2)}\n`,
1451
+ 'utf8',
1452
+ );
1453
+ res.json({ ok: true });
1454
+ } catch (err) {
1455
+ res.status(500).json({ ok: false, error: err.message });
1456
+ }
1457
+ });
1458
+
1459
+ // ── GET /api/workspace-runs/:id/guide ─────────────────────────────────
1460
+ workspaceRuns.get('/:id/guide', (req, res) => {
1461
+ const { id } = req.params;
1462
+ if (!validateWsId(id)) {
1463
+ return res.status(400).json({ ok: false, error: 'Invalid workspace ID' });
1464
+ }
1465
+ const manifest = readManifest(workspaceRunsDir, id);
1466
+ if (!manifest) {
1467
+ return res
1468
+ .status(404)
1469
+ .json({ ok: false, error: `Workspace run "${id}" not found` });
1470
+ }
1471
+
1472
+ const guide = manifest.guide;
1473
+ if (!guide?.paths?.length) {
1474
+ return res
1475
+ .status(404)
1476
+ .json({ ok: false, error: 'No guide attached to this workspace run' });
1477
+ }
1478
+
1479
+ const chunks = [];
1480
+ for (const guidePath of guide.paths) {
1481
+ try {
1482
+ chunks.push(readFileSync(guidePath, 'utf8'));
1483
+ } catch (err) {
1484
+ if (err.code === 'ENOENT' || err.code === 'EACCES') {
1485
+ return res.status(404).json({
1486
+ ok: false,
1487
+ error: 'guide_not_retrievable',
1488
+ hint: 'Guide was supplied via CLI from a path the UI server cannot read.',
1489
+ });
1490
+ }
1491
+ return res.status(500).json({ ok: false, error: err.message });
1492
+ }
1493
+ }
1494
+
1495
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
1496
+ res.send(chunks.join('\n\n---\n\n'));
1497
+ });
1498
+
1499
+ // ── GET /api/workspace-runs/:id/integration-log ───────────────────────
1500
+ workspaceRuns.get('/:id/integration-log', (req, res) => {
1501
+ const { id } = req.params;
1502
+ if (!validateWsId(id)) {
1503
+ return res.status(400).json({ ok: false, error: 'Invalid workspace ID' });
1504
+ }
1505
+ const manifest = readManifest(workspaceRunsDir, id);
1506
+ if (!manifest) {
1507
+ return res
1508
+ .status(404)
1509
+ .json({ ok: false, error: `Workspace run "${id}" not found` });
1510
+ }
1511
+
1512
+ const logPath = manifest.integration_test?.log_path;
1513
+ if (!logPath || !existsSync(logPath)) {
1514
+ return res
1515
+ .status(404)
1516
+ .json({ ok: false, error: 'No integration test log available' });
1517
+ }
1518
+
1519
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8');
1520
+ res.send(readFileSync(logPath, 'utf8'));
1521
+ });
1522
+
1523
+ // ── GET /api/workspace-runs/:id/context/:project ─────────────────────────
1524
+ workspaceRuns.get('/:id/context/:project', (req, res) => {
1525
+ const { id, project } = req.params;
1526
+ if (!validateWsId(id)) {
1527
+ return res.status(400).json({ ok: false, error: 'Invalid workspace ID' });
1528
+ }
1529
+ const manifest = readManifest(workspaceRunsDir, id);
1530
+ if (!manifest) {
1531
+ return res
1532
+ .status(404)
1533
+ .json({ ok: false, error: `Workspace run "${id}" not found` });
1534
+ }
1535
+
1536
+ const safeProject = basename(project);
1537
+ const contextPath = join(
1538
+ runDir(manifest),
1539
+ 'context',
1540
+ `${safeProject}-diff.md`,
1541
+ );
1542
+ if (!existsSync(contextPath)) {
1543
+ return res.status(404).json({
1544
+ ok: false,
1545
+ error: `No context artifact found for project "${safeProject}"`,
1546
+ });
1547
+ }
1548
+
1549
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
1550
+ res.send(readFileSync(contextPath, 'utf8'));
1551
+ });
1552
+
1553
+ return { workspaces, workspaceRuns };
1554
+ }