@worca/ui 0.12.0 → 0.13.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 +984 -852
- package/app/main.bundle.js.map +4 -4
- package/app/protocol.js +1 -7
- package/app/styles.css +58 -0
- package/package.json +1 -1
- package/server/process-manager.js +118 -79
- package/server/project-routes.js +6 -90
- package/server/watcher-set.js +3 -44
- package/server/watcher.js +112 -43
- package/server/worktrees-routes.js +349 -0
- package/server/ws-log-watcher.js +3 -3
- package/server/ws-message-router.js +0 -50
- 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/app/protocol.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Protocol definitions for worca-ui WebSocket communication.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
/** @typedef {'subscribe-run'|'unsubscribe-run'|'subscribe-log'|'unsubscribe-log'|'list-runs'|'get-agent-prompt'|'get-preferences'|'set-preferences'|'stop-run'|'resume-run'|'list-beads-issues'|'start-beads-issue'|'list-beads-counts'|'list-beads-refs'|'list-beads-unlinked'|'run-snapshot'|'run-update'|'runs-list'|'log-line'|'log-bulk'|'preferences'|'run-started'|'run-stopped'|'stage-restarted'|'beads-update'} MessageType */
|
|
5
|
+
/** @typedef {'subscribe-run'|'unsubscribe-run'|'subscribe-log'|'unsubscribe-log'|'list-runs'|'get-agent-prompt'|'get-preferences'|'set-preferences'|'stop-run'|'resume-run'|'list-beads-issues'|'start-beads-issue'|'list-beads-counts'|'list-beads-refs'|'list-beads-unlinked'|'run-snapshot'|'run-update'|'runs-list'|'log-line'|'log-bulk'|'preferences'|'run-started'|'run-stopped'|'stage-restarted'|'beads-update'|'hello'|'hello-ack'} MessageType */
|
|
6
6
|
|
|
7
7
|
/** @type {MessageType[]} */
|
|
8
8
|
export const MESSAGE_TYPES = [
|
|
@@ -29,12 +29,6 @@ export const MESSAGE_TYPES = [
|
|
|
29
29
|
// Protocol handshake
|
|
30
30
|
'hello',
|
|
31
31
|
'hello-ack',
|
|
32
|
-
// Parallel pipelines
|
|
33
|
-
'list-pipelines',
|
|
34
|
-
'subscribe-pipeline',
|
|
35
|
-
'unsubscribe-pipeline',
|
|
36
|
-
'pipeline-status-changed',
|
|
37
|
-
'pipelines-list',
|
|
38
32
|
// Server → Client events
|
|
39
33
|
'run-snapshot',
|
|
40
34
|
'run-update',
|
package/app/styles.css
CHANGED
|
@@ -4525,3 +4525,61 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
4525
4525
|
padding: 2rem;
|
|
4526
4526
|
}
|
|
4527
4527
|
|
|
4528
|
+
|
|
4529
|
+
/* --- Worktrees view --- */
|
|
4530
|
+
.worktrees-view {
|
|
4531
|
+
display: flex;
|
|
4532
|
+
flex-direction: column;
|
|
4533
|
+
gap: 12px;
|
|
4534
|
+
}
|
|
4535
|
+
.worktrees-summary {
|
|
4536
|
+
display: flex;
|
|
4537
|
+
flex-wrap: wrap;
|
|
4538
|
+
gap: 4px 6px;
|
|
4539
|
+
align-items: baseline;
|
|
4540
|
+
padding: 6px 0 2px;
|
|
4541
|
+
font-size: 12px;
|
|
4542
|
+
color: var(--muted);
|
|
4543
|
+
font-variant-numeric: tabular-nums;
|
|
4544
|
+
}
|
|
4545
|
+
.worktrees-summary .meta-sep {
|
|
4546
|
+
color: var(--border-subtle);
|
|
4547
|
+
margin: 0 2px;
|
|
4548
|
+
}
|
|
4549
|
+
.worktrees-disk-alert {
|
|
4550
|
+
margin-bottom: 4px;
|
|
4551
|
+
}
|
|
4552
|
+
.worktrees-toolbar {
|
|
4553
|
+
display: flex;
|
|
4554
|
+
align-items: center;
|
|
4555
|
+
gap: 12px;
|
|
4556
|
+
flex-wrap: wrap;
|
|
4557
|
+
}
|
|
4558
|
+
.worktrees-toolbar .worktrees-filter {
|
|
4559
|
+
flex: 1;
|
|
4560
|
+
min-width: 240px;
|
|
4561
|
+
}
|
|
4562
|
+
.worktree-card-path {
|
|
4563
|
+
align-items: baseline;
|
|
4564
|
+
}
|
|
4565
|
+
.worktree-path-mono {
|
|
4566
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
4567
|
+
font-size: 11px;
|
|
4568
|
+
color: var(--muted);
|
|
4569
|
+
word-break: break-all;
|
|
4570
|
+
}
|
|
4571
|
+
.worktrees-bulk-groups {
|
|
4572
|
+
margin: 8px 0 0;
|
|
4573
|
+
padding-left: 18px;
|
|
4574
|
+
font-size: 13px;
|
|
4575
|
+
color: var(--muted);
|
|
4576
|
+
}
|
|
4577
|
+
.worktrees-bulk-groups li {
|
|
4578
|
+
margin-bottom: 2px;
|
|
4579
|
+
}
|
|
4580
|
+
.dialog-actions {
|
|
4581
|
+
display: flex;
|
|
4582
|
+
gap: 8px;
|
|
4583
|
+
justify-content: flex-end;
|
|
4584
|
+
width: 100%;
|
|
4585
|
+
}
|
package/package.json
CHANGED
|
@@ -84,6 +84,43 @@ export class ProcessManager {
|
|
|
84
84
|
this.settingsPath = settingsPath ?? null;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Resolve the worcaDir and runDir for a given run ID.
|
|
89
|
+
* Checks root runs/ first, then pipelines.d/ registry for worktree_path.
|
|
90
|
+
* @param {string} runId
|
|
91
|
+
* @returns {{ worcaDir: string, runDir: string } | null}
|
|
92
|
+
*/
|
|
93
|
+
resolveRunContext(runId) {
|
|
94
|
+
const rootPath = join(this.worcaDir, 'runs', runId, 'status.json');
|
|
95
|
+
if (existsSync(rootPath)) {
|
|
96
|
+
return {
|
|
97
|
+
worcaDir: this.worcaDir,
|
|
98
|
+
runDir: join(this.worcaDir, 'runs', runId),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const regPath = join(
|
|
102
|
+
this.worcaDir,
|
|
103
|
+
'multi',
|
|
104
|
+
'pipelines.d',
|
|
105
|
+
`${runId}.json`,
|
|
106
|
+
);
|
|
107
|
+
if (existsSync(regPath)) {
|
|
108
|
+
try {
|
|
109
|
+
const reg = JSON.parse(readFileSync(regPath, 'utf8'));
|
|
110
|
+
if (reg.worktree_path) {
|
|
111
|
+
const wtWorcaDir = join(reg.worktree_path, '.worca');
|
|
112
|
+
return {
|
|
113
|
+
worcaDir: wtWorcaDir,
|
|
114
|
+
runDir: join(wtWorcaDir, 'runs', runId),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
/* ignore */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
87
124
|
/**
|
|
88
125
|
* Check if a pipeline is currently running.
|
|
89
126
|
* @param {string} [runId] - If provided, check per-run PID first
|
|
@@ -125,7 +162,7 @@ export class ProcessManager {
|
|
|
125
162
|
|
|
126
163
|
/**
|
|
127
164
|
* Reconcile stale "running" status when the pipeline process is dead.
|
|
128
|
-
* Scans all runs with per-run PID files
|
|
165
|
+
* Scans all runs with per-run PID files.
|
|
129
166
|
* If pipeline_status is "running" but no process is alive, transitions
|
|
130
167
|
* to "failed" with stop_reason="stale".
|
|
131
168
|
* Preserves any existing stop_reason (e.g. "signal" set by Layer 1).
|
|
@@ -136,7 +173,7 @@ export class ProcessManager {
|
|
|
136
173
|
let fixed = false;
|
|
137
174
|
const dispatches = [];
|
|
138
175
|
|
|
139
|
-
// Collect run IDs to check: scan runs/*/pipeline.pid
|
|
176
|
+
// Collect run IDs to check: scan runs/*/pipeline.pid
|
|
140
177
|
const runIds = new Set();
|
|
141
178
|
const runsDir = join(this.worcaDir, 'runs');
|
|
142
179
|
if (existsSync(runsDir)) {
|
|
@@ -154,17 +191,6 @@ export class ProcessManager {
|
|
|
154
191
|
}
|
|
155
192
|
}
|
|
156
193
|
|
|
157
|
-
// Backward compat: also check active_run pointer
|
|
158
|
-
const activeRunPath = join(this.worcaDir, 'active_run');
|
|
159
|
-
if (existsSync(activeRunPath)) {
|
|
160
|
-
try {
|
|
161
|
-
const activeId = readFileSync(activeRunPath, 'utf8').trim();
|
|
162
|
-
if (activeId) runIds.add(activeId);
|
|
163
|
-
} catch {
|
|
164
|
-
/* ignore */
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
194
|
for (const runId of runIds) {
|
|
169
195
|
// Check if this run's process is alive
|
|
170
196
|
const alive = this.getRunningPid(runId);
|
|
@@ -272,20 +298,47 @@ export class ProcessManager {
|
|
|
272
298
|
*/
|
|
273
299
|
async startPipeline(opts = {}) {
|
|
274
300
|
const cwd = opts.projectRoot || this.projectRoot;
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
301
|
+
const pipelineScriptRel = '.claude/worca/scripts/run_pipeline.py';
|
|
302
|
+
const worktreeScriptRel = '.claude/worca/scripts/run_worktree.py';
|
|
303
|
+
|
|
304
|
+
let scriptRel;
|
|
305
|
+
if (opts.resume) {
|
|
306
|
+
const pipelinePath = join(cwd, pipelineScriptRel);
|
|
307
|
+
if (!existsSync(pipelinePath)) {
|
|
308
|
+
const err = new Error(`Pipeline script not found at ${pipelinePath}`);
|
|
309
|
+
err.code = 'script_not_found';
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
scriptRel = pipelineScriptRel;
|
|
313
|
+
} else {
|
|
314
|
+
const worktreePath = join(cwd, worktreeScriptRel);
|
|
315
|
+
if (existsSync(worktreePath)) {
|
|
316
|
+
scriptRel = worktreeScriptRel;
|
|
317
|
+
} else {
|
|
318
|
+
const pipelinePath = join(cwd, pipelineScriptRel);
|
|
319
|
+
if (!existsSync(pipelinePath)) {
|
|
320
|
+
const err = new Error(`Pipeline script not found at ${pipelinePath}`);
|
|
321
|
+
err.code = 'script_not_found';
|
|
322
|
+
throw err;
|
|
323
|
+
}
|
|
324
|
+
console.warn(
|
|
325
|
+
'[worca] run_worktree.py not found, falling back to run_pipeline.py',
|
|
326
|
+
);
|
|
327
|
+
scriptRel = pipelineScriptRel;
|
|
328
|
+
}
|
|
280
329
|
}
|
|
281
330
|
|
|
282
|
-
const args = [
|
|
331
|
+
const args = [scriptRel];
|
|
283
332
|
let promptFilePath = null; // track for cleanup on spawn failure
|
|
284
333
|
|
|
285
334
|
if (opts.resume) {
|
|
286
335
|
args.push('--resume');
|
|
287
336
|
if (opts.runId) {
|
|
288
|
-
|
|
337
|
+
const ctx = this.resolveRunContext(opts.runId);
|
|
338
|
+
const statusDir = ctx
|
|
339
|
+
? ctx.runDir
|
|
340
|
+
: join(this.worcaDir, 'runs', opts.runId);
|
|
341
|
+
args.push('--status-dir', statusDir);
|
|
289
342
|
}
|
|
290
343
|
} else if (opts.sourceType !== undefined) {
|
|
291
344
|
// New format: separate source and prompt args
|
|
@@ -400,10 +453,13 @@ export class ProcessManager {
|
|
|
400
453
|
let pid = null;
|
|
401
454
|
let foundPidPath = null;
|
|
402
455
|
|
|
456
|
+
const ctx = runId ? this.resolveRunContext(runId) : null;
|
|
457
|
+
const effectiveWorcaDir = ctx ? ctx.worcaDir : this.worcaDir;
|
|
458
|
+
|
|
403
459
|
// Check per-run PID file first, then project-level fallback
|
|
404
460
|
const candidates = [];
|
|
405
461
|
if (runId) {
|
|
406
|
-
candidates.push(join(
|
|
462
|
+
candidates.push(join(effectiveWorcaDir, 'runs', runId, 'pipeline.pid'));
|
|
407
463
|
}
|
|
408
464
|
candidates.push(join(this.worcaDir, 'pipeline.pid'));
|
|
409
465
|
|
|
@@ -431,10 +487,10 @@ export class ProcessManager {
|
|
|
431
487
|
}
|
|
432
488
|
|
|
433
489
|
// Belt-and-suspenders: write control.json so the orchestrator gets a clean signal
|
|
434
|
-
const effectiveRunId = runId
|
|
490
|
+
const effectiveRunId = runId;
|
|
435
491
|
if (effectiveRunId) {
|
|
436
492
|
try {
|
|
437
|
-
const controlDir = join(
|
|
493
|
+
const controlDir = join(effectiveWorcaDir, 'runs', effectiveRunId);
|
|
438
494
|
mkdirSync(controlDir, { recursive: true });
|
|
439
495
|
writeFileSync(
|
|
440
496
|
join(controlDir, 'control.json'),
|
|
@@ -514,7 +570,9 @@ export class ProcessManager {
|
|
|
514
570
|
}
|
|
515
571
|
const { pid } = running;
|
|
516
572
|
|
|
517
|
-
const
|
|
573
|
+
const ctx = this.resolveRunContext(runId);
|
|
574
|
+
const effectiveWorcaDir = ctx ? ctx.worcaDir : this.worcaDir;
|
|
575
|
+
const controlDir = join(effectiveWorcaDir, 'runs', runId);
|
|
518
576
|
mkdirSync(controlDir, { recursive: true });
|
|
519
577
|
writeFileSync(
|
|
520
578
|
join(controlDir, 'control.json'),
|
|
@@ -569,21 +627,6 @@ export class ProcessManager {
|
|
|
569
627
|
}
|
|
570
628
|
}
|
|
571
629
|
|
|
572
|
-
/**
|
|
573
|
-
* Read the active_run file to get the current run ID.
|
|
574
|
-
* @returns {string|null}
|
|
575
|
-
*/
|
|
576
|
-
_readActiveRunId() {
|
|
577
|
-
const activeRunPath = join(this.worcaDir, 'active_run');
|
|
578
|
-
if (!existsSync(activeRunPath)) return null;
|
|
579
|
-
try {
|
|
580
|
-
const id = readFileSync(activeRunPath, 'utf8').trim();
|
|
581
|
-
return id || null;
|
|
582
|
-
} catch {
|
|
583
|
-
return null;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
|
|
587
630
|
/**
|
|
588
631
|
* Delete a run directory and clean up references.
|
|
589
632
|
* Refuses if the pipeline is currently running.
|
|
@@ -600,8 +643,10 @@ export class ProcessManager {
|
|
|
600
643
|
throw err;
|
|
601
644
|
}
|
|
602
645
|
|
|
603
|
-
const
|
|
604
|
-
const
|
|
646
|
+
const ctx = this.resolveRunContext(runId);
|
|
647
|
+
const effectiveWorcaDir = ctx ? ctx.worcaDir : this.worcaDir;
|
|
648
|
+
const runsParent = resolve(effectiveWorcaDir, 'runs');
|
|
649
|
+
const runDir = ctx ? resolve(ctx.runDir) : resolve(runsParent, runId);
|
|
605
650
|
if (!runDir.startsWith(runsParent)) {
|
|
606
651
|
const err = new Error('Invalid runId');
|
|
607
652
|
err.code = 'invalid_id';
|
|
@@ -615,17 +660,6 @@ export class ProcessManager {
|
|
|
615
660
|
|
|
616
661
|
rmSync(runDir, { recursive: true, force: true });
|
|
617
662
|
|
|
618
|
-
// Clear active_run pointer if it references this run
|
|
619
|
-
const activeRunPath = join(this.worcaDir, 'active_run');
|
|
620
|
-
if (existsSync(activeRunPath)) {
|
|
621
|
-
try {
|
|
622
|
-
const activeId = readFileSync(activeRunPath, 'utf8').trim();
|
|
623
|
-
if (activeId === runId) unlinkSync(activeRunPath);
|
|
624
|
-
} catch {
|
|
625
|
-
/* ignore */
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
|
|
629
663
|
return { deleted: true };
|
|
630
664
|
}
|
|
631
665
|
|
|
@@ -635,7 +669,8 @@ export class ProcessManager {
|
|
|
635
669
|
* @returns {{ runId: string, paused: boolean }}
|
|
636
670
|
*/
|
|
637
671
|
pausePipeline(runId) {
|
|
638
|
-
const
|
|
672
|
+
const ctx = this.resolveRunContext(runId);
|
|
673
|
+
const controlDir = ctx ? ctx.runDir : join(this.worcaDir, 'runs', runId);
|
|
639
674
|
mkdirSync(controlDir, { recursive: true });
|
|
640
675
|
writeFileSync(
|
|
641
676
|
join(controlDir, 'control.json'),
|
|
@@ -655,19 +690,34 @@ export class ProcessManager {
|
|
|
655
690
|
|
|
656
691
|
/**
|
|
657
692
|
* Restart a failed stage by resetting it and spawning with --resume.
|
|
693
|
+
*
|
|
694
|
+
* Internal API — only called from worca-ui (project-routes.js). The signature
|
|
695
|
+
* changed in W-048 from (stageKey, opts) to (runId, stageKey, opts) because
|
|
696
|
+
* runs are now per-worktree and the manager can no longer infer "the active
|
|
697
|
+
* run". Callers must pass an explicit runId.
|
|
698
|
+
*
|
|
699
|
+
* @param {string} runId - Run identifier to restart
|
|
658
700
|
* @param {string} stageKey - The stage key to restart
|
|
659
701
|
* @param {{ projectRoot?: string }} opts
|
|
660
702
|
* @returns {Promise<{ pid: number, stage: string }>}
|
|
661
703
|
*/
|
|
662
|
-
async restartStage(stageKey, opts = {}) {
|
|
663
|
-
const running = this.getRunningPid();
|
|
704
|
+
async restartStage(runId, stageKey, opts = {}) {
|
|
705
|
+
const running = this.getRunningPid(runId);
|
|
664
706
|
if (running) {
|
|
665
707
|
const err = new Error(`Pipeline already running (PID ${running.pid})`);
|
|
666
708
|
err.code = 'already_running';
|
|
667
709
|
throw err;
|
|
668
710
|
}
|
|
669
711
|
|
|
670
|
-
const
|
|
712
|
+
const ctx = this.resolveRunContext(runId);
|
|
713
|
+
const runDir = ctx ? ctx.runDir : join(this.worcaDir, 'runs', runId);
|
|
714
|
+
// For worktree runs derive projectRoot from worcaDir parent (.worca/..)
|
|
715
|
+
const cwd =
|
|
716
|
+
opts.projectRoot ||
|
|
717
|
+
(ctx && ctx.worcaDir !== this.worcaDir
|
|
718
|
+
? join(ctx.worcaDir, '..')
|
|
719
|
+
: this.projectRoot);
|
|
720
|
+
|
|
671
721
|
const scriptPath = join(cwd, '.claude/worca/scripts/run_pipeline.py');
|
|
672
722
|
if (!existsSync(scriptPath)) {
|
|
673
723
|
const err = new Error(`Pipeline script not found at ${scriptPath}`);
|
|
@@ -675,24 +725,8 @@ export class ProcessManager {
|
|
|
675
725
|
throw err;
|
|
676
726
|
}
|
|
677
727
|
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
const activeRunPath = join(this.worcaDir, 'active_run');
|
|
681
|
-
if (existsSync(activeRunPath)) {
|
|
682
|
-
try {
|
|
683
|
-
const runId = readFileSync(activeRunPath, 'utf8').trim();
|
|
684
|
-
const candidate = join(this.worcaDir, 'runs', runId, 'status.json');
|
|
685
|
-
if (existsSync(candidate)) statusPath = candidate;
|
|
686
|
-
} catch {
|
|
687
|
-
/* ignore */
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
if (!statusPath) {
|
|
691
|
-
const legacy = join(this.worcaDir, 'status.json');
|
|
692
|
-
if (existsSync(legacy)) statusPath = legacy;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
if (!statusPath) {
|
|
728
|
+
const statusPath = join(runDir, 'status.json');
|
|
729
|
+
if (!existsSync(statusPath)) {
|
|
696
730
|
const err = new Error('No status.json found');
|
|
697
731
|
err.code = 'no_status';
|
|
698
732
|
throw err;
|
|
@@ -720,14 +754,19 @@ export class ProcessManager {
|
|
|
720
754
|
delete status.stages[stageKey].completed_at;
|
|
721
755
|
writeFileSync(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
|
|
722
756
|
|
|
723
|
-
// Spawn with --resume
|
|
757
|
+
// Spawn with --resume --status-dir so the pipeline finds the right run
|
|
724
758
|
const env = { ...process.env };
|
|
725
759
|
delete env.CLAUDECODE;
|
|
726
760
|
|
|
727
761
|
return new Promise((resolve, reject) => {
|
|
728
762
|
const child = spawn(
|
|
729
763
|
'python3',
|
|
730
|
-
[
|
|
764
|
+
[
|
|
765
|
+
'.claude/worca/scripts/run_pipeline.py',
|
|
766
|
+
'--resume',
|
|
767
|
+
'--status-dir',
|
|
768
|
+
runDir,
|
|
769
|
+
],
|
|
731
770
|
{
|
|
732
771
|
detached: true,
|
|
733
772
|
stdio: 'ignore',
|
|
@@ -807,10 +846,10 @@ export function pausePipeline(worcaDir, runId) {
|
|
|
807
846
|
return new ProcessManager({ worcaDir }).pausePipeline(runId);
|
|
808
847
|
}
|
|
809
848
|
|
|
810
|
-
/** @param {string} worcaDir @param {string} stageKey @param {object} opts */
|
|
811
|
-
export async function restartStage(worcaDir, stageKey, opts = {}) {
|
|
849
|
+
/** @param {string} worcaDir @param {string} runId @param {string} stageKey @param {object} opts */
|
|
850
|
+
export async function restartStage(worcaDir, runId, stageKey, opts = {}) {
|
|
812
851
|
return new ProcessManager({
|
|
813
852
|
worcaDir,
|
|
814
853
|
projectRoot: opts.projectRoot,
|
|
815
|
-
}).restartStage(stageKey, opts);
|
|
854
|
+
}).restartStage(runId, stageKey, opts);
|
|
816
855
|
}
|
package/server/project-routes.js
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
readProjectWorcaVersion,
|
|
49
49
|
runWorcaSetup,
|
|
50
50
|
} from './worca-setup.js';
|
|
51
|
+
import { createWorktreesRouter } from './worktrees-routes.js';
|
|
51
52
|
|
|
52
53
|
/** Validate a runId — must not contain path traversal characters */
|
|
53
54
|
const RUN_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
@@ -1135,7 +1136,7 @@ export function createProjectScopedRoutes({
|
|
|
1135
1136
|
async (req, res) => {
|
|
1136
1137
|
const { stage } = req.params;
|
|
1137
1138
|
try {
|
|
1138
|
-
const result = await req.project.pm.restartStage(stage);
|
|
1139
|
+
const result = await req.project.pm.restartStage(req.params.id, stage);
|
|
1139
1140
|
const { broadcast } = req.app.locals;
|
|
1140
1141
|
if (broadcast) broadcast('stage-restarted', { stage, pid: result.pid });
|
|
1141
1142
|
res.json({ ok: true, restarted: true, stage, pid: result.pid });
|
|
@@ -1249,94 +1250,7 @@ export function createProjectScopedRoutes({
|
|
|
1249
1250
|
res.json({ ok: true, runId, pid: child.pid });
|
|
1250
1251
|
});
|
|
1251
1252
|
|
|
1252
|
-
// POST /api/projects/:projectId/
|
|
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
|
|
1253
|
+
// POST /api/projects/:projectId/pipelines/:runId/stop — stop a worktree pipeline
|
|
1340
1254
|
router.post('/pipelines/:runId/stop', requireWorcaDir, (req, res) => {
|
|
1341
1255
|
const runId = req.params.runId;
|
|
1342
1256
|
if (!validateRunId(runId)) {
|
|
@@ -1385,7 +1299,7 @@ export function createProjectScopedRoutes({
|
|
|
1385
1299
|
}
|
|
1386
1300
|
});
|
|
1387
1301
|
|
|
1388
|
-
// POST /api/projects/:projectId/pipelines/:runId/pause — pause a
|
|
1302
|
+
// POST /api/projects/:projectId/pipelines/:runId/pause — pause a worktree pipeline
|
|
1389
1303
|
router.post('/pipelines/:runId/pause', requireWorcaDir, (req, res) => {
|
|
1390
1304
|
const runId = req.params.runId;
|
|
1391
1305
|
if (!validateRunId(runId)) {
|
|
@@ -1642,5 +1556,7 @@ export function createProjectScopedRoutes({
|
|
|
1642
1556
|
}
|
|
1643
1557
|
});
|
|
1644
1558
|
|
|
1559
|
+
router.use('/worktrees', requireWorcaDir, createWorktreesRouter());
|
|
1560
|
+
|
|
1645
1561
|
return router;
|
|
1646
1562
|
}
|
package/server/watcher-set.js
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
-
import { MultiWatcher } from './multi-watcher.js';
|
|
14
13
|
import { createBeadsWatcher } from './ws-beads-watcher.js';
|
|
15
14
|
import { createEventWatcher } from './ws-event-watcher.js';
|
|
16
15
|
import { createLogWatcher } from './ws-log-watcher.js';
|
|
@@ -32,14 +31,12 @@ export class WatcherSet {
|
|
|
32
31
|
this._deps = deps;
|
|
33
32
|
this._closed = false;
|
|
34
33
|
this._tier = TIER_POLLING;
|
|
35
|
-
this._skipMultiWatcher = !!factoryOverrides._skipMultiWatcher;
|
|
36
|
-
const { _skipMultiWatcher, ...factories } = factoryOverrides;
|
|
37
34
|
this._factories = {
|
|
38
35
|
createStatusWatcher,
|
|
39
36
|
createLogWatcher,
|
|
40
37
|
createBeadsWatcher,
|
|
41
38
|
createEventWatcher,
|
|
42
|
-
...
|
|
39
|
+
...factoryOverrides,
|
|
43
40
|
};
|
|
44
41
|
|
|
45
42
|
/** @type {ReturnType<typeof createStatusWatcher> | null} */
|
|
@@ -50,8 +47,6 @@ export class WatcherSet {
|
|
|
50
47
|
this.beadsWatcher = null;
|
|
51
48
|
/** @type {ReturnType<typeof createEventWatcher> | null} */
|
|
52
49
|
this.eventWatcher = null;
|
|
53
|
-
/** @type {MultiWatcher | null} */
|
|
54
|
-
this.multiWatcher = null;
|
|
55
50
|
}
|
|
56
51
|
|
|
57
52
|
get worcaDir() {
|
|
@@ -93,28 +88,6 @@ export class WatcherSet {
|
|
|
93
88
|
if (this._tier === TIER_FULL) {
|
|
94
89
|
this._createSecondaryWatchers();
|
|
95
90
|
}
|
|
96
|
-
// Start multi-pipeline watcher (skip for pipeline-level WatcherSets to avoid recursion)
|
|
97
|
-
if (!this._skipMultiWatcher) {
|
|
98
|
-
this._createMultiWatcher();
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Create multi-pipeline watcher for this project's .worca/multi/pipelines.d/. */
|
|
103
|
-
_createMultiWatcher() {
|
|
104
|
-
try {
|
|
105
|
-
this.multiWatcher = new MultiWatcher(
|
|
106
|
-
this.projectId,
|
|
107
|
-
this._worcaDir,
|
|
108
|
-
this._deps,
|
|
109
|
-
);
|
|
110
|
-
this.multiWatcher.start();
|
|
111
|
-
} catch (err) {
|
|
112
|
-
console.error(
|
|
113
|
-
`[WatcherSet:${this.projectId}] multiWatcher failed:`,
|
|
114
|
-
err.message,
|
|
115
|
-
);
|
|
116
|
-
this.multiWatcher = null;
|
|
117
|
-
}
|
|
118
91
|
}
|
|
119
92
|
|
|
120
93
|
/** Create status watcher (always needed). */
|
|
@@ -153,8 +126,8 @@ export class WatcherSet {
|
|
|
153
126
|
try {
|
|
154
127
|
this.logWatcher = this._factories.createLogWatcher({
|
|
155
128
|
broadcaster,
|
|
156
|
-
|
|
157
|
-
? this.statusWatcher.
|
|
129
|
+
resolveLatestRunDir: this.statusWatcher
|
|
130
|
+
? this.statusWatcher.resolveLatestRunDir
|
|
158
131
|
: () => worcaDir,
|
|
159
132
|
worcaDir,
|
|
160
133
|
currentActiveRunId: this.statusWatcher
|
|
@@ -236,15 +209,6 @@ export class WatcherSet {
|
|
|
236
209
|
if (this._closed) return;
|
|
237
210
|
this._closed = true;
|
|
238
211
|
|
|
239
|
-
if (this.multiWatcher) {
|
|
240
|
-
try {
|
|
241
|
-
this.multiWatcher.destroy();
|
|
242
|
-
} catch {
|
|
243
|
-
// ignore cleanup errors
|
|
244
|
-
}
|
|
245
|
-
this.multiWatcher = null;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
212
|
for (const w of [
|
|
249
213
|
this.statusWatcher,
|
|
250
214
|
this.logWatcher,
|
|
@@ -274,11 +238,6 @@ export class WatcherSet {
|
|
|
274
238
|
return count;
|
|
275
239
|
}
|
|
276
240
|
|
|
277
|
-
/** Get multi-pipeline watcher (may be null for pipeline-level WatcherSets). */
|
|
278
|
-
getMultiWatcher() {
|
|
279
|
-
return this.multiWatcher;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
241
|
/** Delegate to status watcher's scheduleRefresh. */
|
|
283
242
|
scheduleRefresh() {
|
|
284
243
|
this.statusWatcher?.scheduleRefresh();
|