@worca/ui 0.13.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 +836 -716
- package/app/main.bundle.js.map +3 -3
- package/app/styles.css +9 -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 +90 -9
- package/server/process-registry.js +92 -0
- package/server/project-routes.js +222 -142
- 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 +8 -10
- package/server/worktree-ops.js +72 -0
- package/server/worktrees-routes.js +3 -80
- package/server/ws-log-watcher.js +33 -24
- package/server/ws-message-router.js +76 -65
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';
|
|
@@ -61,22 +68,11 @@ function validateRunId(runId) {
|
|
|
61
68
|
);
|
|
62
69
|
}
|
|
63
70
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
export function findRunStatusPath(worcaDir, runId) {
|
|
70
|
-
const candidates = [
|
|
71
|
-
join(worcaDir, 'runs', runId, 'status.json'),
|
|
72
|
-
join(worcaDir, 'results', runId, 'status.json'),
|
|
73
|
-
join(worcaDir, 'results', `${runId}.json`),
|
|
74
|
-
];
|
|
75
|
-
for (const p of candidates) {
|
|
76
|
-
if (existsSync(p)) return p;
|
|
77
|
-
}
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
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 };
|
|
80
76
|
|
|
81
77
|
/** Validate a branch name — alphanumeric, dots, hyphens, underscores, slashes */
|
|
82
78
|
const BRANCH_RE = /^[\w.\-/]+$/;
|
|
@@ -139,6 +135,7 @@ export function projectResolver({ prefsDir, projectRoot }) {
|
|
|
139
135
|
settingsPath:
|
|
140
136
|
project.settingsPath ||
|
|
141
137
|
join(project.path, '.claude', 'settings.json'),
|
|
138
|
+
prefsDir,
|
|
142
139
|
}),
|
|
143
140
|
};
|
|
144
141
|
next();
|
|
@@ -323,13 +320,17 @@ export function createProjectRoutes({
|
|
|
323
320
|
/**
|
|
324
321
|
* Router for project-scoped sub-routes.
|
|
325
322
|
* The projectResolver middleware must run before this to set req.project.
|
|
326
|
-
* @param {{ prefsDir?: string|null }} [options] —
|
|
327
|
-
* 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).
|
|
328
328
|
*/
|
|
329
329
|
export function createProjectScopedRoutes({
|
|
330
330
|
prefsDir = null,
|
|
331
331
|
serverHost,
|
|
332
332
|
serverPort,
|
|
333
|
+
launchLock = new LaunchLock(),
|
|
333
334
|
} = {}) {
|
|
334
335
|
const router = Router({ mergeParams: true });
|
|
335
336
|
const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
|
|
@@ -447,7 +448,7 @@ export function createProjectScopedRoutes({
|
|
|
447
448
|
});
|
|
448
449
|
|
|
449
450
|
// POST /api/projects/:projectId/settings
|
|
450
|
-
router.post('/settings', (req, res) => {
|
|
451
|
+
router.post('/settings', async (req, res) => {
|
|
451
452
|
const { settingsPath } = req.project;
|
|
452
453
|
if (!settingsPath) {
|
|
453
454
|
return res.status(501).json({
|
|
@@ -481,25 +482,87 @@ export function createProjectScopedRoutes({
|
|
|
481
482
|
}
|
|
482
483
|
|
|
483
484
|
try {
|
|
484
|
-
|
|
485
|
-
|
|
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
|
+
}
|
|
486
494
|
|
|
487
495
|
if (body.worca && typeof body.worca === 'object') {
|
|
488
|
-
if (!
|
|
489
|
-
|
|
490
|
-
|
|
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();
|
|
491
544
|
}
|
|
492
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
|
+
|
|
493
553
|
if (body.permissions !== undefined) {
|
|
554
|
+
const lp = localPathFor(settingsPath);
|
|
555
|
+
const local = readLocalSettings(settingsPath);
|
|
494
556
|
local.permissions = body.permissions;
|
|
557
|
+
mkdirSync(dirname(lp), { recursive: true });
|
|
558
|
+
writeFileSync(lp, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
|
|
495
559
|
}
|
|
496
560
|
|
|
497
|
-
writeFileSync(lp, `${JSON.stringify(local, null, 2)}\n`, 'utf8');
|
|
498
|
-
|
|
499
561
|
const merged = readMergedSettings(settingsPath);
|
|
500
562
|
res.json({
|
|
501
563
|
worca: merged.worca || {},
|
|
502
564
|
permissions: merged.permissions || {},
|
|
565
|
+
autoMigrated,
|
|
503
566
|
});
|
|
504
567
|
} catch (err) {
|
|
505
568
|
res
|
|
@@ -540,17 +603,52 @@ export function createProjectScopedRoutes({
|
|
|
540
603
|
}
|
|
541
604
|
|
|
542
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.
|
|
543
611
|
const lp = localPathFor(settingsPath);
|
|
544
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
|
+
}
|
|
545
622
|
|
|
546
|
-
if (mapping.worca
|
|
547
|
-
|
|
548
|
-
|
|
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
|
+
}
|
|
549
638
|
}
|
|
550
639
|
if (mapping.top) {
|
|
551
640
|
for (const key of mapping.top) delete local[key];
|
|
552
641
|
}
|
|
553
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
|
+
|
|
554
652
|
if (Object.keys(local).length === 0) {
|
|
555
653
|
try {
|
|
556
654
|
unlinkSync(lp);
|
|
@@ -725,26 +823,47 @@ export function createProjectScopedRoutes({
|
|
|
725
823
|
? Math.max(1, Math.min(10, Math.round(Number(mloops))))
|
|
726
824
|
: 1;
|
|
727
825
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
+
}
|
|
745
843
|
}
|
|
746
|
-
|
|
747
|
-
|
|
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
|
+
});
|
|
748
867
|
});
|
|
749
868
|
|
|
750
869
|
// POST /api/projects/:projectId/runs/:id/pause
|
|
@@ -986,6 +1105,57 @@ export function createProjectScopedRoutes({
|
|
|
986
1105
|
}
|
|
987
1106
|
});
|
|
988
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
|
+
|
|
989
1159
|
// POST /api/projects/:projectId/runs/:id/archive
|
|
990
1160
|
router.post('/runs/:id/archive', requireWorcaDir, (req, res) => {
|
|
991
1161
|
const runId = req.params.id;
|
|
@@ -1250,100 +1420,10 @@ export function createProjectScopedRoutes({
|
|
|
1250
1420
|
res.json({ ok: true, runId, pid: child.pid });
|
|
1251
1421
|
});
|
|
1252
1422
|
|
|
1253
|
-
//
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
1258
|
-
}
|
|
1259
|
-
const { worcaDir } = req.project;
|
|
1260
|
-
|
|
1261
|
-
const pipelineFile = join(
|
|
1262
|
-
worcaDir,
|
|
1263
|
-
'multi',
|
|
1264
|
-
'pipelines.d',
|
|
1265
|
-
`${runId}.json`,
|
|
1266
|
-
);
|
|
1267
|
-
if (!existsSync(pipelineFile)) {
|
|
1268
|
-
return res
|
|
1269
|
-
.status(404)
|
|
1270
|
-
.json({ ok: false, error: `Pipeline ${runId} not found` });
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
let pipeline;
|
|
1274
|
-
try {
|
|
1275
|
-
pipeline = JSON.parse(readFileSync(pipelineFile, 'utf8'));
|
|
1276
|
-
} catch {
|
|
1277
|
-
return res
|
|
1278
|
-
.status(500)
|
|
1279
|
-
.json({ ok: false, error: 'Failed to read pipeline registry' });
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
if (!pipeline.worktree_path) {
|
|
1283
|
-
return res
|
|
1284
|
-
.status(400)
|
|
1285
|
-
.json({ ok: false, error: 'Pipeline has no worktree path' });
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
const worktreePm = new ProcessManager({
|
|
1289
|
-
worcaDir: join(pipeline.worktree_path, '.worca'),
|
|
1290
|
-
});
|
|
1291
|
-
try {
|
|
1292
|
-
const result = worktreePm.stopPipeline(runId);
|
|
1293
|
-
res.json({ ok: true, stopped: true, runId, pid: result.pid });
|
|
1294
|
-
} catch (err) {
|
|
1295
|
-
if (err.code === 'not_running') {
|
|
1296
|
-
return res.status(404).json({ ok: false, error: err.message });
|
|
1297
|
-
}
|
|
1298
|
-
res.status(500).json({ ok: false, error: err.message });
|
|
1299
|
-
}
|
|
1300
|
-
});
|
|
1301
|
-
|
|
1302
|
-
// POST /api/projects/:projectId/pipelines/:runId/pause — pause a worktree pipeline
|
|
1303
|
-
router.post('/pipelines/:runId/pause', requireWorcaDir, (req, res) => {
|
|
1304
|
-
const runId = req.params.runId;
|
|
1305
|
-
if (!validateRunId(runId)) {
|
|
1306
|
-
return res.status(400).json({ ok: false, error: 'Invalid runId' });
|
|
1307
|
-
}
|
|
1308
|
-
const { worcaDir } = req.project;
|
|
1309
|
-
|
|
1310
|
-
const pipelineFile = join(
|
|
1311
|
-
worcaDir,
|
|
1312
|
-
'multi',
|
|
1313
|
-
'pipelines.d',
|
|
1314
|
-
`${runId}.json`,
|
|
1315
|
-
);
|
|
1316
|
-
if (!existsSync(pipelineFile)) {
|
|
1317
|
-
return res
|
|
1318
|
-
.status(404)
|
|
1319
|
-
.json({ ok: false, error: `Pipeline ${runId} not found` });
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
let pipeline;
|
|
1323
|
-
try {
|
|
1324
|
-
pipeline = JSON.parse(readFileSync(pipelineFile, 'utf8'));
|
|
1325
|
-
} catch {
|
|
1326
|
-
return res
|
|
1327
|
-
.status(500)
|
|
1328
|
-
.json({ ok: false, error: 'Failed to read pipeline registry' });
|
|
1329
|
-
}
|
|
1330
|
-
|
|
1331
|
-
if (!pipeline.worktree_path) {
|
|
1332
|
-
return res
|
|
1333
|
-
.status(400)
|
|
1334
|
-
.json({ ok: false, error: 'Pipeline has no worktree path' });
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
const worktreePm = new ProcessManager({
|
|
1338
|
-
worcaDir: join(pipeline.worktree_path, '.worca'),
|
|
1339
|
-
});
|
|
1340
|
-
try {
|
|
1341
|
-
const result = worktreePm.pausePipeline(runId);
|
|
1342
|
-
res.json({ ok: true, ...result });
|
|
1343
|
-
} catch (err) {
|
|
1344
|
-
res.status(500).json({ ok: false, error: err.message });
|
|
1345
|
-
}
|
|
1346
|
-
});
|
|
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.
|
|
1347
1427
|
|
|
1348
1428
|
// GET /api/projects/:projectId/templates — list available pipeline templates
|
|
1349
1429
|
router.get('/templates', (req, res) => {
|
|
@@ -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
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { atomicWriteSync } from './atomic-write.js';
|
|
3
|
+
import { GLOBAL_DEFAULTS } from './keys-schema.js';
|
|
4
|
+
import { deepMerge, readMergedSettings } from './settings-merge.js';
|
|
2
5
|
|
|
3
6
|
export function readSettings(path) {
|
|
4
7
|
try {
|
|
@@ -21,3 +24,30 @@ export function readSettings(path) {
|
|
|
21
24
|
};
|
|
22
25
|
}
|
|
23
26
|
}
|
|
27
|
+
|
|
28
|
+
export function readGlobalSettings(globalSettingsPath) {
|
|
29
|
+
let raw = {};
|
|
30
|
+
try {
|
|
31
|
+
raw = JSON.parse(readFileSync(globalSettingsPath, 'utf-8'));
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err.code === 'ENOENT') {
|
|
34
|
+
// First-run: file doesn't exist yet — return defaults
|
|
35
|
+
} else if (err instanceof SyntaxError) {
|
|
36
|
+
console.error(
|
|
37
|
+
`Invalid JSON in ${globalSettingsPath}: ${err.message}; falling back to defaults`,
|
|
38
|
+
);
|
|
39
|
+
} else {
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) raw = {};
|
|
44
|
+
raw.worca = deepMerge(GLOBAL_DEFAULTS, raw.worca || {});
|
|
45
|
+
return raw;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function writeGlobalSettings(globalSettingsPath, partial) {
|
|
49
|
+
const existing = readGlobalSettings(globalSettingsPath);
|
|
50
|
+
const merged = deepMerge(existing, partial);
|
|
51
|
+
atomicWriteSync(globalSettingsPath, `${JSON.stringify(merged, null, 2)}\n`);
|
|
52
|
+
return merged;
|
|
53
|
+
}
|