@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.
- package/app/main.bundle.js +1192 -940
- package/app/main.bundle.js.map +4 -4
- package/app/protocol.js +1 -7
- package/app/styles.css +67 -1
- package/package.json +2 -1
- package/server/app.js +24 -2
- package/server/atomic-write.js +18 -0
- package/server/beads-reader.js +29 -4
- package/server/global-keys.js +49 -0
- package/server/keys-schema.js +16 -0
- package/server/launch-lock.js +25 -0
- package/server/preferences-routes.js +143 -0
- package/server/process-manager.js +208 -88
- package/server/process-registry.js +92 -0
- package/server/project-routes.js +226 -230
- package/server/run-dir-resolver.js +79 -0
- package/server/settings-reader.js +31 -1
- package/server/settings-validator.js +112 -1
- package/server/status-routes.js +23 -0
- package/server/watcher-set.js +11 -54
- package/server/watcher.js +112 -43
- package/server/worktree-ops.js +72 -0
- package/server/worktrees-routes.js +272 -0
- package/server/ws-log-watcher.js +36 -27
- package/server/ws-message-router.js +76 -115
- package/server/ws-modular.js +11 -2
- package/server/ws-status-watcher.js +187 -23
- package/server/ws.js +1 -1
- package/server/multi-watcher.js +0 -237
package/server/project-routes.js
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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] —
|
|
326
|
-
* worca-cc version lookup for /worca-status'
|
|
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
|
-
|
|
484
|
-
|
|
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 (!
|
|
488
|
-
|
|
489
|
-
|
|
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
|
|
546
|
-
|
|
547
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
+
}
|