@worca/ui 0.12.0 → 0.14.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.
@@ -19,12 +19,17 @@ import {
19
19
  import { homedir } from 'node:os';
20
20
  import { dirname, join } from 'node:path';
21
21
  import { Router } from 'express';
22
+ import lockfile from 'proper-lockfile';
22
23
  import { actionAllowed } from '../app/utils/state-actions.js';
24
+ import { atomicWriteSync } from './atomic-write.js';
23
25
  import { dbExists, getIssue, listIssues } from './beads-reader.js';
24
26
  import { dispatchExternal } from './dispatch-external.js';
25
27
  import { ensureWebhookForUi } from './ensure-webhook.js';
28
+ import { extractAndStripGlobalKeys } from './global-keys.js';
29
+ import { LaunchLock } from './launch-lock.js';
26
30
  import { readPreferences } from './preferences.js';
27
31
  import { ProcessManager } from './process-manager.js';
32
+ import { countRunningPipelinesAcrossProjects } from './process-registry.js';
28
33
  import {
29
34
  getMaxProjects,
30
35
  readProjects,
@@ -35,10 +40,12 @@ import {
35
40
  writeProject,
36
41
  } from './project-registry.js';
37
42
  import {
43
+ deepMerge,
38
44
  localPathFor,
39
45
  readLocalSettings,
40
46
  readMergedSettings,
41
47
  } from './settings-merge.js';
48
+ import { readGlobalSettings, writeGlobalSettings } from './settings-reader.js';
42
49
  import { validateSettingsPayload } from './settings-validator.js';
43
50
  import { isVersionBehind } from './version-check.js';
44
51
  import { getVersionInfo } from './versions.js';
@@ -48,6 +55,7 @@ import {
48
55
  readProjectWorcaVersion,
49
56
  runWorcaSetup,
50
57
  } from './worca-setup.js';
58
+ import { createWorktreesRouter } from './worktrees-routes.js';
51
59
 
52
60
  /** Validate a runId — must not contain path traversal characters */
53
61
  const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
@@ -60,22 +68,11 @@ function validateRunId(runId) {
60
68
  );
61
69
  }
62
70
 
63
- /**
64
- * Find the status.json path for a given run ID.
65
- * Searches: runs/{id}/status.json results/{id}/status.json → results/{id}.json
66
- * Returns the first existing path, or null if none found.
67
- */
68
- export function findRunStatusPath(worcaDir, runId) {
69
- const candidates = [
70
- join(worcaDir, 'runs', runId, 'status.json'),
71
- join(worcaDir, 'results', runId, 'status.json'),
72
- join(worcaDir, 'results', `${runId}.json`),
73
- ];
74
- for (const p of candidates) {
75
- if (existsSync(p)) return p;
76
- }
77
- return null;
78
- }
71
+ // Re-exported from run-dir-resolver so callers (including older tests) can
72
+ // continue importing from project-routes. The implementation now overlays
73
+ // worktree runs registered in <worcaDir>/multi/pipelines.d/.
74
+ import { findRunStatusPath } from './run-dir-resolver.js';
75
+ export { findRunStatusPath };
79
76
 
80
77
  /** Validate a branch name — alphanumeric, dots, hyphens, underscores, slashes */
81
78
  const BRANCH_RE = /^[\w.\-/]+$/;
@@ -138,6 +135,7 @@ export function projectResolver({ prefsDir, projectRoot }) {
138
135
  settingsPath:
139
136
  project.settingsPath ||
140
137
  join(project.path, '.claude', 'settings.json'),
138
+ prefsDir,
141
139
  }),
142
140
  };
143
141
  next();
@@ -322,13 +320,17 @@ export function createProjectRoutes({
322
320
  /**
323
321
  * Router for project-scoped sub-routes.
324
322
  * The projectResolver middleware must run before this to set req.project.
325
- * @param {{ prefsDir?: string|null }} [options] — prefsDir enables active
326
- * worca-cc version lookup for /worca-status' `outdated` flag.
323
+ * @param {{ prefsDir?: string|null, launchLock?: LaunchLock }} [options] —
324
+ * prefsDir enables active worca-cc version lookup for /worca-status'
325
+ * `outdated` flag and gates the global max_concurrent_pipelines check.
326
+ * launchLock should be injected by createApp so all routers share the
327
+ * same mutex; falls back to a per-router instance if omitted (tests).
327
328
  */
328
329
  export function createProjectScopedRoutes({
329
330
  prefsDir = null,
330
331
  serverHost,
331
332
  serverPort,
333
+ launchLock = new LaunchLock(),
332
334
  } = {}) {
333
335
  const router = Router({ mergeParams: true });
334
336
  const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
@@ -446,7 +448,7 @@ export function createProjectScopedRoutes({
446
448
  });
447
449
 
448
450
  // POST /api/projects/:projectId/settings
449
- router.post('/settings', (req, res) => {
451
+ router.post('/settings', async (req, res) => {
450
452
  const { settingsPath } = req.project;
451
453
  if (!settingsPath) {
452
454
  return res.status(501).json({
@@ -480,25 +482,87 @@ export function createProjectScopedRoutes({
480
482
  }
481
483
 
482
484
  try {
483
- const lp = localPathFor(settingsPath);
484
- const local = readLocalSettings(settingsPath);
485
+ let baseChanged = false;
486
+ let base = {};
487
+ try {
488
+ if (existsSync(settingsPath)) {
489
+ base = JSON.parse(readFileSync(settingsPath, 'utf8'));
490
+ }
491
+ } catch {
492
+ base = {};
493
+ }
485
494
 
486
495
  if (body.worca && typeof body.worca === 'object') {
487
- if (!local.worca) local.worca = {};
488
- for (const key of Object.keys(body.worca)) {
489
- local.worca[key] = body.worca[key];
496
+ if (!base.worca || typeof base.worca !== 'object') base.worca = {};
497
+ base.worca = deepMerge(base.worca, body.worca);
498
+
499
+ if (
500
+ body.worca.governance &&
501
+ typeof body.worca.governance === 'object' &&
502
+ body.worca.governance.subagent_dispatch !== undefined &&
503
+ base.worca.governance &&
504
+ typeof base.worca.governance === 'object' &&
505
+ 'dispatch' in base.worca.governance
506
+ ) {
507
+ delete base.worca.governance.dispatch;
508
+ }
509
+ baseChanged = true;
510
+ }
511
+
512
+ // STEP 1: extract misplaced global keys + inert milestone keys
513
+ const autoMigrated = extractAndStripGlobalKeys(base);
514
+
515
+ // STEP 2: write extracted global keys to ~/.worca/settings.json
516
+ const globalSettingsPath = prefsDir
517
+ ? join(prefsDir, 'settings.json')
518
+ : null;
519
+
520
+ if (
521
+ globalSettingsPath &&
522
+ Object.keys(autoMigrated.globalExtracted).length > 0
523
+ ) {
524
+ let release;
525
+ try {
526
+ release = await lockfile.lock(globalSettingsPath, {
527
+ retries: { retries: 5, minTimeout: 50, maxTimeout: 500 },
528
+ realpath: false,
529
+ });
530
+ writeGlobalSettings(globalSettingsPath, {
531
+ worca: autoMigrated.globalExtracted,
532
+ });
533
+ } catch (err) {
534
+ return res.status(500).json({
535
+ error: {
536
+ code: 'global_write_error',
537
+ message:
538
+ 'Failed to migrate global keys; project settings not saved.',
539
+ detail: err.message,
540
+ },
541
+ });
542
+ } finally {
543
+ if (release) await release();
490
544
  }
491
545
  }
546
+
547
+ // STEP 3: atomic project write
548
+ if (baseChanged) {
549
+ mkdirSync(dirname(settingsPath), { recursive: true });
550
+ atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
551
+ }
552
+
492
553
  if (body.permissions !== undefined) {
554
+ const lp = localPathFor(settingsPath);
555
+ const local = readLocalSettings(settingsPath);
493
556
  local.permissions = body.permissions;
557
+ mkdirSync(dirname(lp), { recursive: true });
558
+ writeFileSync(lp, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
494
559
  }
495
560
 
496
- writeFileSync(lp, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
497
-
498
561
  const merged = readMergedSettings(settingsPath);
499
562
  res.json({
500
563
  worca: merged.worca || {},
501
564
  permissions: merged.permissions || {},
565
+ autoMigrated,
502
566
  });
503
567
  } catch (err) {
504
568
  res
@@ -539,17 +603,52 @@ export function createProjectScopedRoutes({
539
603
  }
540
604
 
541
605
  try {
606
+ // Mirror the persistence split used in POST: worca-namespace keys live
607
+ // in settings.json; top-level keys (permissions) live in
608
+ // settings.local.json. Reset removes from both as needed and also
609
+ // clears any legacy worca-namespace overrides that may still exist in
610
+ // settings.local.json from before the split.
542
611
  const lp = localPathFor(settingsPath);
543
612
  const local = readLocalSettings(settingsPath);
613
+ let baseChanged = false;
614
+ let base = {};
615
+ try {
616
+ if (existsSync(settingsPath)) {
617
+ base = JSON.parse(readFileSync(settingsPath, 'utf8'));
618
+ }
619
+ } catch {
620
+ base = {};
621
+ }
544
622
 
545
- if (mapping.worca && local.worca) {
546
- for (const key of mapping.worca) delete local.worca[key];
547
- if (Object.keys(local.worca).length === 0) delete local.worca;
623
+ if (mapping.worca) {
624
+ if (base.worca) {
625
+ for (const key of mapping.worca) {
626
+ if (key in base.worca) {
627
+ delete base.worca[key];
628
+ baseChanged = true;
629
+ }
630
+ }
631
+ if (Object.keys(base.worca).length === 0) delete base.worca;
632
+ }
633
+ // Strip any legacy local override at the same paths.
634
+ if (local.worca) {
635
+ for (const key of mapping.worca) delete local.worca[key];
636
+ if (Object.keys(local.worca).length === 0) delete local.worca;
637
+ }
548
638
  }
549
639
  if (mapping.top) {
550
640
  for (const key of mapping.top) delete local[key];
551
641
  }
552
642
 
643
+ if (baseChanged) {
644
+ mkdirSync(dirname(settingsPath), { recursive: true });
645
+ writeFileSync(
646
+ settingsPath,
647
+ `${JSON.stringify(base, null, 2)}\n`,
648
+ 'utf8',
649
+ );
650
+ }
651
+
553
652
  if (Object.keys(local).length === 0) {
554
653
  try {
555
654
  unlinkSync(lp);
@@ -724,26 +823,47 @@ export function createProjectScopedRoutes({
724
823
  ? Math.max(1, Math.min(10, Math.round(Number(mloops))))
725
824
  : 1;
726
825
 
727
- try {
728
- const result = await req.project.pm.startPipeline({
729
- sourceType,
730
- sourceValue: hasSource ? sourceValue : undefined,
731
- prompt: hasPrompt ? prompt : undefined,
732
- msize: msizeVal,
733
- mloops: mloopsVal,
734
- planFile: hasPlan ? planFile.trim() : undefined,
735
- branch: branch || undefined,
736
- template: template || undefined,
737
- });
738
- const { broadcast } = req.app.locals;
739
- if (broadcast) broadcast('run-started', { pid: result.pid });
740
- res.json({ ok: true, pid: result.pid, sourceType, prompt });
741
- } catch (err) {
742
- if (err.code === 'already_running') {
743
- return res.status(409).json({ ok: false, error: err.message });
826
+ // Atomically check global cap and start pipeline under lock
827
+ await launchLock.withLock(async () => {
828
+ if (prefsDir) {
829
+ const globalSettings = readGlobalSettings(
830
+ join(prefsDir, 'settings.json'),
831
+ );
832
+ const cap =
833
+ globalSettings.worca?.parallel?.max_concurrent_pipelines ?? 10;
834
+ const totalRunning = countRunningPipelinesAcrossProjects(prefsDir);
835
+ if (totalRunning >= cap) {
836
+ res.status(409).json({
837
+ ok: false,
838
+ error: `Maximum concurrent pipelines reached (${cap}). Stop a running pipeline or increase the limit in global preferences.`,
839
+ code: 'max_concurrent_exceeded',
840
+ });
841
+ return;
842
+ }
744
843
  }
745
- res.status(500).json({ ok: false, error: err.message });
746
- }
844
+
845
+ try {
846
+ const result = await req.project.pm.startPipeline({
847
+ sourceType,
848
+ sourceValue: hasSource ? sourceValue : undefined,
849
+ prompt: hasPrompt ? prompt : undefined,
850
+ msize: msizeVal,
851
+ mloops: mloopsVal,
852
+ planFile: hasPlan ? planFile.trim() : undefined,
853
+ branch: branch || undefined,
854
+ template: template || undefined,
855
+ });
856
+ const { broadcast } = req.app.locals;
857
+ if (broadcast) broadcast('run-started', { pid: result.pid });
858
+ res.json({ ok: true, pid: result.pid, sourceType, prompt });
859
+ } catch (err) {
860
+ if (err.code === 'already_running') {
861
+ res.status(409).json({ ok: false, error: err.message });
862
+ return;
863
+ }
864
+ res.status(500).json({ ok: false, error: err.message });
865
+ }
866
+ });
747
867
  });
748
868
 
749
869
  // POST /api/projects/:projectId/runs/:id/pause
@@ -985,6 +1105,57 @@ export function createProjectScopedRoutes({
985
1105
  }
986
1106
  });
987
1107
 
1108
+ // POST /api/projects/:projectId/runs/:id/control — generic run control action
1109
+ const VALID_CONTROL_ACTIONS = new Set(['approve', 'reject']);
1110
+ router.post('/runs/:id/control', requireWorcaDir, (req, res) => {
1111
+ const runId = req.params.id;
1112
+ if (!validateRunId(runId)) {
1113
+ return res.status(400).json({ ok: false, error: 'Invalid runId' });
1114
+ }
1115
+ const { action, source } = req.body || {};
1116
+ if (!action || !VALID_CONTROL_ACTIONS.has(action)) {
1117
+ return res.status(400).json({
1118
+ ok: false,
1119
+ error: `Invalid action. Must be one of: ${[...VALID_CONTROL_ACTIONS].join(', ')}`,
1120
+ });
1121
+ }
1122
+ const { worcaDir } = req.project;
1123
+ const statusPath = findRunStatusPath(worcaDir, runId);
1124
+ if (!statusPath) {
1125
+ return res
1126
+ .status(404)
1127
+ .json({ ok: false, error: `Run "${runId}" not found` });
1128
+ }
1129
+ try {
1130
+ const st = JSON.parse(readFileSync(statusPath, 'utf8'));
1131
+ if (st.pipeline_status !== 'paused') {
1132
+ return res.status(409).json({
1133
+ ok: false,
1134
+ error: 'Run is not paused',
1135
+ pipeline_status: st.pipeline_status,
1136
+ });
1137
+ }
1138
+ const controlDir = join(worcaDir, 'runs', runId);
1139
+ mkdirSync(controlDir, { recursive: true });
1140
+ writeFileSync(
1141
+ join(controlDir, 'control.json'),
1142
+ `${JSON.stringify(
1143
+ {
1144
+ action,
1145
+ requested_at: new Date().toISOString(),
1146
+ source: source || 'ui',
1147
+ },
1148
+ null,
1149
+ 2,
1150
+ )}\n`,
1151
+ 'utf8',
1152
+ );
1153
+ res.json({ ok: true, action, runId });
1154
+ } catch (err) {
1155
+ res.status(500).json({ ok: false, error: err.message });
1156
+ }
1157
+ });
1158
+
988
1159
  // POST /api/projects/:projectId/runs/:id/archive
989
1160
  router.post('/runs/:id/archive', requireWorcaDir, (req, res) => {
990
1161
  const runId = req.params.id;
@@ -1135,7 +1306,7 @@ export function createProjectScopedRoutes({
1135
1306
  async (req, res) => {
1136
1307
  const { stage } = req.params;
1137
1308
  try {
1138
- const result = await req.project.pm.restartStage(stage);
1309
+ const result = await req.project.pm.restartStage(req.params.id, stage);
1139
1310
  const { broadcast } = req.app.locals;
1140
1311
  if (broadcast) broadcast('stage-restarted', { stage, pid: result.pid });
1141
1312
  res.json({ ok: true, restarted: true, stage, pid: result.pid });
@@ -1249,187 +1420,10 @@ export function createProjectScopedRoutes({
1249
1420
  res.json({ ok: true, runId, pid: child.pid });
1250
1421
  });
1251
1422
 
1252
- // POST /api/projects/:projectId/multi-pipeline launch parallel pipelines
1253
- router.post('/multi-pipeline', requireWorcaDir, (req, res) => {
1254
- const { projectRoot } = req.project;
1255
- const body = req.body || {};
1256
- const { requests, baseBranch, maxParallel, cleanupPolicy, msize, mloops } =
1257
- body;
1258
-
1259
- if (!Array.isArray(requests) || requests.length < 1) {
1260
- return res.status(400).json({
1261
- ok: false,
1262
- error: 'requests array required (at least 1 item)',
1263
- });
1264
- }
1265
- if (requests.length > 20) {
1266
- return res
1267
- .status(400)
1268
- .json({ ok: false, error: 'Too many requests (max 20)' });
1269
- }
1270
- for (const r of requests) {
1271
- if (typeof r !== 'string' || r.trim().length === 0) {
1272
- return res.status(400).json({
1273
- ok: false,
1274
- error: 'Each request must be a non-empty string',
1275
- });
1276
- }
1277
- if (r.length > 50000) {
1278
- return res.status(400).json({
1279
- ok: false,
1280
- error: 'Each request must be 50,000 characters or less',
1281
- });
1282
- }
1283
- }
1284
- if (baseBranch !== undefined) {
1285
- if (
1286
- typeof baseBranch !== 'string' ||
1287
- baseBranch.length > 200 ||
1288
- !/^[\w.\-/]+$/.test(baseBranch)
1289
- ) {
1290
- return res
1291
- .status(400)
1292
- .json({ ok: false, error: 'Invalid baseBranch value' });
1293
- }
1294
- }
1295
-
1296
- const maxP = Math.max(1, Math.min(5, Math.round(Number(maxParallel) || 3)));
1297
- const msizeVal = Math.max(1, Math.min(10, Math.round(Number(msize) || 1)));
1298
- const mloopsVal = Math.max(
1299
- 1,
1300
- Math.min(10, Math.round(Number(mloops) || 1)),
1301
- );
1302
- const cleanup = ['on-success', 'always', 'never'].includes(cleanupPolicy)
1303
- ? cleanupPolicy
1304
- : 'on-success';
1305
-
1306
- const args = ['.claude/worca/scripts/run_multi.py'];
1307
- args.push('--max-parallel', String(maxP));
1308
- args.push('--cleanup', cleanup);
1309
- args.push('--msize', String(msizeVal));
1310
- args.push('--mloops', String(mloopsVal));
1311
- if (baseBranch) args.push('--base-branch', baseBranch);
1312
- args.push('--requests', ...requests.map((r) => r.trim()));
1313
-
1314
- const env = { ...process.env };
1315
- delete env.CLAUDECODE;
1316
-
1317
- try {
1318
- const child = spawn('python3', args, {
1319
- detached: true,
1320
- stdio: 'ignore',
1321
- cwd: projectRoot,
1322
- env,
1323
- });
1324
- child.unref();
1325
-
1326
- const { broadcast } = req.app.locals;
1327
- if (broadcast)
1328
- broadcast('multi-pipeline-started', {
1329
- pid: child.pid,
1330
- count: requests.length,
1331
- });
1332
-
1333
- res.json({ ok: true, pid: child.pid, count: requests.length });
1334
- } catch (err) {
1335
- res.status(500).json({ ok: false, error: err.message });
1336
- }
1337
- });
1338
-
1339
- // POST /api/projects/:projectId/pipelines/:runId/stop — stop a parallel pipeline
1340
- router.post('/pipelines/:runId/stop', requireWorcaDir, (req, res) => {
1341
- const runId = req.params.runId;
1342
- if (!validateRunId(runId)) {
1343
- return res.status(400).json({ ok: false, error: 'Invalid runId' });
1344
- }
1345
- const { worcaDir } = req.project;
1346
-
1347
- const pipelineFile = join(
1348
- worcaDir,
1349
- 'multi',
1350
- 'pipelines.d',
1351
- `${runId}.json`,
1352
- );
1353
- if (!existsSync(pipelineFile)) {
1354
- return res
1355
- .status(404)
1356
- .json({ ok: false, error: `Pipeline ${runId} not found` });
1357
- }
1358
-
1359
- let pipeline;
1360
- try {
1361
- pipeline = JSON.parse(readFileSync(pipelineFile, 'utf8'));
1362
- } catch {
1363
- return res
1364
- .status(500)
1365
- .json({ ok: false, error: 'Failed to read pipeline registry' });
1366
- }
1367
-
1368
- if (!pipeline.worktree_path) {
1369
- return res
1370
- .status(400)
1371
- .json({ ok: false, error: 'Pipeline has no worktree path' });
1372
- }
1373
-
1374
- const worktreePm = new ProcessManager({
1375
- worcaDir: join(pipeline.worktree_path, '.worca'),
1376
- });
1377
- try {
1378
- const result = worktreePm.stopPipeline(runId);
1379
- res.json({ ok: true, stopped: true, runId, pid: result.pid });
1380
- } catch (err) {
1381
- if (err.code === 'not_running') {
1382
- return res.status(404).json({ ok: false, error: err.message });
1383
- }
1384
- res.status(500).json({ ok: false, error: err.message });
1385
- }
1386
- });
1387
-
1388
- // POST /api/projects/:projectId/pipelines/:runId/pause — pause a parallel pipeline
1389
- router.post('/pipelines/:runId/pause', requireWorcaDir, (req, res) => {
1390
- const runId = req.params.runId;
1391
- if (!validateRunId(runId)) {
1392
- return res.status(400).json({ ok: false, error: 'Invalid runId' });
1393
- }
1394
- const { worcaDir } = req.project;
1395
-
1396
- const pipelineFile = join(
1397
- worcaDir,
1398
- 'multi',
1399
- 'pipelines.d',
1400
- `${runId}.json`,
1401
- );
1402
- if (!existsSync(pipelineFile)) {
1403
- return res
1404
- .status(404)
1405
- .json({ ok: false, error: `Pipeline ${runId} not found` });
1406
- }
1407
-
1408
- let pipeline;
1409
- try {
1410
- pipeline = JSON.parse(readFileSync(pipelineFile, 'utf8'));
1411
- } catch {
1412
- return res
1413
- .status(500)
1414
- .json({ ok: false, error: 'Failed to read pipeline registry' });
1415
- }
1416
-
1417
- if (!pipeline.worktree_path) {
1418
- return res
1419
- .status(400)
1420
- .json({ ok: false, error: 'Pipeline has no worktree path' });
1421
- }
1422
-
1423
- const worktreePm = new ProcessManager({
1424
- worcaDir: join(pipeline.worktree_path, '.worca'),
1425
- });
1426
- try {
1427
- const result = worktreePm.pausePipeline(runId);
1428
- res.json({ ok: true, ...result });
1429
- } catch (err) {
1430
- res.status(500).json({ ok: false, error: err.message });
1431
- }
1432
- });
1423
+ // NOTE: The /pipelines/:runId/{stop,pause} routes were removed in favor of
1424
+ // unifying on /runs/:id/{stop,pause,cancel,resume}. The ProcessManager now
1425
+ // overlays worktree pipelines via pipelines.d/, so the same /runs/:id/*
1426
+ // family handles both local and worktree-hosted runs.
1433
1427
 
1434
1428
  // GET /api/projects/:projectId/templates — list available pipeline templates
1435
1429
  router.get('/templates', (req, res) => {
@@ -1642,5 +1636,7 @@ export function createProjectScopedRoutes({
1642
1636
  }
1643
1637
  });
1644
1638
 
1639
+ router.use('/worktrees', requireWorcaDir, createWorktreesRouter());
1640
+
1645
1641
  return router;
1646
1642
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Resolve a runId to its on-disk run directory and status file path.
3
+ *
4
+ * Worktree runs live under `<worktree_path>/.worca/runs/<runId>/`, but they
5
+ * are registered in the parent project's `<worcaDir>/multi/pipelines.d/<runId>.json`.
6
+ * Callers that only know the parent worcaDir + runId need this overlay to
7
+ * find the actual run files. Local (non-worktree) runs continue to live in
8
+ * `<worcaDir>/runs/<runId>/` or `<worcaDir>/results/<runId>/`.
9
+ *
10
+ * Resolution order:
11
+ * 1. `<worcaDir>/runs/<runId>/` (local active)
12
+ * 2. `<worcaDir>/results/<runId>/` (local archived)
13
+ * 3. `<worcaDir>/multi/pipelines.d/<runId>.json` → `<worktree_path>/.worca/runs/<runId>/`
14
+ */
15
+
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ /**
20
+ * Resolve a runId to its on-disk run directory.
21
+ * @param {string} worcaDir - the parent project's .worca directory
22
+ * @param {string} runId
23
+ * @returns {string|null} absolute path to the run dir, or null if not found
24
+ */
25
+ export function resolveRunDir(worcaDir, runId) {
26
+ if (!worcaDir || !runId) return null;
27
+
28
+ const localRunDir = join(worcaDir, 'runs', runId);
29
+ if (existsSync(localRunDir)) return localRunDir;
30
+
31
+ const localResultsDir = join(worcaDir, 'results', runId);
32
+ if (existsSync(localResultsDir)) return localResultsDir;
33
+
34
+ const overlay = readPipelineOverlay(worcaDir, runId);
35
+ if (overlay) {
36
+ const wtRunDir = join(overlay.worktree_path, '.worca', 'runs', runId);
37
+ if (existsSync(wtRunDir)) return wtRunDir;
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Resolve a runId to its status.json path.
45
+ * @param {string} worcaDir
46
+ * @param {string} runId
47
+ * @returns {string|null} absolute path to status.json, or null
48
+ */
49
+ export function findRunStatusPath(worcaDir, runId) {
50
+ const runDir = resolveRunDir(worcaDir, runId);
51
+ if (runDir) {
52
+ const sp = join(runDir, 'status.json');
53
+ if (existsSync(sp)) return sp;
54
+ }
55
+
56
+ // Legacy file format: results/<id>.json
57
+ const legacyPath = join(worcaDir, 'results', `${runId}.json`);
58
+ if (existsSync(legacyPath)) return legacyPath;
59
+
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Read the worktree overlay for a runId, if registered.
65
+ * @param {string} worcaDir
66
+ * @param {string} runId
67
+ * @returns {{ run_id: string, worktree_path: string, pid?: number } | null}
68
+ */
69
+ export function readPipelineOverlay(worcaDir, runId) {
70
+ const regPath = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
71
+ if (!existsSync(regPath)) return null;
72
+ try {
73
+ const data = JSON.parse(readFileSync(regPath, 'utf8'));
74
+ if (!data || typeof data.worktree_path !== 'string') return null;
75
+ return data;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }