@worca/ui 0.23.0 → 0.25.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.
- package/app/main.bundle.js +2341 -1205
- package/app/main.bundle.js.map +4 -4
- package/app/protocol.js +4 -1
- package/app/styles.css +446 -8
- package/app/utils/state-actions.js +21 -3
- package/app/utils/status-constants.js +11 -0
- package/bin/worca-ui.js +2 -2
- package/package.json +2 -1
- package/scripts/build-frontend.js +48 -1
- package/server/app.js +92 -1
- package/server/fleet-routes.js +5 -3
- package/server/index.js +4 -3
- package/server/integrations/commands/fleet.js +1 -1
- package/server/integrations/commands/global.js +9 -0
- package/server/integrations/commands/workspace.js +295 -0
- package/server/integrations/index.js +6 -0
- package/server/integrations/renderers.js +291 -3
- package/server/paths.js +78 -0
- package/server/project-routes.js +68 -5
- package/server/workspace-routes.js +1554 -0
- package/server/worktree-ops.js +12 -1
- package/server/ws-fleet-manifest-watcher.js +4 -3
- package/server/ws-modular.js +10 -2
- package/server/ws-workspace-manifest-watcher.js +136 -0
|
@@ -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
|
+
}
|