@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
|
@@ -22,6 +22,8 @@ import { tmpdir } from 'node:os';
|
|
|
22
22
|
import { join, resolve } from 'node:path';
|
|
23
23
|
|
|
24
24
|
import { dispatchExternal } from './dispatch-external.js';
|
|
25
|
+
import { readGlobalSettings } from './settings-reader.js';
|
|
26
|
+
import { removeWorktree } from './worktree-ops.js';
|
|
25
27
|
|
|
26
28
|
/** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
|
|
27
29
|
const ARG_INLINE_LIMIT = 128 * 1024;
|
|
@@ -78,10 +80,48 @@ export class ProcessManager {
|
|
|
78
80
|
/**
|
|
79
81
|
* @param {{ worcaDir: string, projectRoot?: string, settingsPath?: string }} options
|
|
80
82
|
*/
|
|
81
|
-
constructor({ worcaDir, projectRoot, settingsPath }) {
|
|
83
|
+
constructor({ worcaDir, projectRoot, settingsPath, prefsDir }) {
|
|
82
84
|
this.worcaDir = worcaDir;
|
|
83
85
|
this.projectRoot = projectRoot || process.cwd();
|
|
84
86
|
this.settingsPath = settingsPath ?? null;
|
|
87
|
+
this.prefsDir = prefsDir ?? null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the worcaDir and runDir for a given run ID.
|
|
92
|
+
* Checks root runs/ first, then pipelines.d/ registry for worktree_path.
|
|
93
|
+
* @param {string} runId
|
|
94
|
+
* @returns {{ worcaDir: string, runDir: string } | null}
|
|
95
|
+
*/
|
|
96
|
+
resolveRunContext(runId) {
|
|
97
|
+
const rootPath = join(this.worcaDir, 'runs', runId, 'status.json');
|
|
98
|
+
if (existsSync(rootPath)) {
|
|
99
|
+
return {
|
|
100
|
+
worcaDir: this.worcaDir,
|
|
101
|
+
runDir: join(this.worcaDir, 'runs', runId),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
const regPath = join(
|
|
105
|
+
this.worcaDir,
|
|
106
|
+
'multi',
|
|
107
|
+
'pipelines.d',
|
|
108
|
+
`${runId}.json`,
|
|
109
|
+
);
|
|
110
|
+
if (existsSync(regPath)) {
|
|
111
|
+
try {
|
|
112
|
+
const reg = JSON.parse(readFileSync(regPath, 'utf8'));
|
|
113
|
+
if (reg.worktree_path) {
|
|
114
|
+
const wtWorcaDir = join(reg.worktree_path, '.worca');
|
|
115
|
+
return {
|
|
116
|
+
worcaDir: wtWorcaDir,
|
|
117
|
+
runDir: join(wtWorcaDir, 'runs', runId),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
/* ignore */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
85
125
|
}
|
|
86
126
|
|
|
87
127
|
/**
|
|
@@ -90,10 +130,17 @@ export class ProcessManager {
|
|
|
90
130
|
* @returns {{ pid: number } | null}
|
|
91
131
|
*/
|
|
92
132
|
getRunningPid(runId) {
|
|
93
|
-
// Build candidate PID paths: per-run first
|
|
133
|
+
// Build candidate PID paths: per-run first (with worktree overlay),
|
|
134
|
+
// then project-level fallback. Worktree runs live under
|
|
135
|
+
// <worktree_path>/.worca/runs/<id>/ and are routed via pipelines.d/.
|
|
94
136
|
const candidates = [];
|
|
95
137
|
if (runId) {
|
|
96
|
-
|
|
138
|
+
const ctx = this.resolveRunContext(runId);
|
|
139
|
+
if (ctx) {
|
|
140
|
+
candidates.push(join(ctx.runDir, 'pipeline.pid'));
|
|
141
|
+
} else {
|
|
142
|
+
candidates.push(join(this.worcaDir, 'runs', runId, 'pipeline.pid'));
|
|
143
|
+
}
|
|
97
144
|
}
|
|
98
145
|
candidates.push(join(this.worcaDir, 'pipeline.pid'));
|
|
99
146
|
|
|
@@ -125,7 +172,7 @@ export class ProcessManager {
|
|
|
125
172
|
|
|
126
173
|
/**
|
|
127
174
|
* Reconcile stale "running" status when the pipeline process is dead.
|
|
128
|
-
* Scans all runs with per-run PID files
|
|
175
|
+
* Scans all runs with per-run PID files.
|
|
129
176
|
* If pipeline_status is "running" but no process is alive, transitions
|
|
130
177
|
* to "failed" with stop_reason="stale".
|
|
131
178
|
* Preserves any existing stop_reason (e.g. "signal" set by Layer 1).
|
|
@@ -136,7 +183,7 @@ export class ProcessManager {
|
|
|
136
183
|
let fixed = false;
|
|
137
184
|
const dispatches = [];
|
|
138
185
|
|
|
139
|
-
// Collect run IDs to check: scan runs/*/pipeline.pid
|
|
186
|
+
// Collect run IDs to check: scan runs/*/pipeline.pid
|
|
140
187
|
const runIds = new Set();
|
|
141
188
|
const runsDir = join(this.worcaDir, 'runs');
|
|
142
189
|
if (existsSync(runsDir)) {
|
|
@@ -154,17 +201,6 @@ export class ProcessManager {
|
|
|
154
201
|
}
|
|
155
202
|
}
|
|
156
203
|
|
|
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
204
|
for (const runId of runIds) {
|
|
169
205
|
// Check if this run's process is alive
|
|
170
206
|
const alive = this.getRunningPid(runId);
|
|
@@ -259,12 +295,76 @@ export class ProcessManager {
|
|
|
259
295
|
}
|
|
260
296
|
}
|
|
261
297
|
}
|
|
298
|
+
|
|
299
|
+
this.maybeAutoCleanup(runId);
|
|
262
300
|
}
|
|
263
301
|
|
|
264
302
|
await Promise.all(dispatches);
|
|
265
303
|
return fixed;
|
|
266
304
|
}
|
|
267
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Post-completion cleanup hook (§5b).
|
|
308
|
+
* When cleanup_policy is 'on-success' and the run completed cleanly,
|
|
309
|
+
* removes the worktree via worktree-ops and emits a worktree.auto_cleanup
|
|
310
|
+
* event. 'never' (default) and 'manual-only' are both no-ops.
|
|
311
|
+
* @param {string} runId
|
|
312
|
+
* @returns {{ cleaned: boolean, runId?: string, path?: string, reason?: string }}
|
|
313
|
+
*/
|
|
314
|
+
maybeAutoCleanup(runId) {
|
|
315
|
+
const ctx = this.resolveRunContext(runId);
|
|
316
|
+
const runDir = ctx ? ctx.runDir : join(this.worcaDir, 'runs', runId);
|
|
317
|
+
const statusPath = join(runDir, 'status.json');
|
|
318
|
+
|
|
319
|
+
if (!existsSync(statusPath)) return { cleaned: false };
|
|
320
|
+
|
|
321
|
+
let status;
|
|
322
|
+
try {
|
|
323
|
+
status = JSON.parse(readFileSync(statusPath, 'utf8'));
|
|
324
|
+
} catch {
|
|
325
|
+
return { cleaned: false };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const worktreePath = status.worktree_path;
|
|
329
|
+
if (!worktreePath) return { cleaned: false };
|
|
330
|
+
|
|
331
|
+
const exitOk = status.pipeline_status === 'completed';
|
|
332
|
+
if (!exitOk) return { cleaned: false };
|
|
333
|
+
|
|
334
|
+
let policy = 'never';
|
|
335
|
+
if (this.prefsDir) {
|
|
336
|
+
try {
|
|
337
|
+
const globalPrefs = readGlobalSettings(
|
|
338
|
+
join(this.prefsDir, 'settings.json'),
|
|
339
|
+
);
|
|
340
|
+
policy = globalPrefs?.worca?.parallel?.cleanup_policy ?? 'never';
|
|
341
|
+
} catch {
|
|
342
|
+
// Fall back to default 'never'
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (policy !== 'on-success') return { cleaned: false };
|
|
347
|
+
|
|
348
|
+
removeWorktree(this.worcaDir, runId);
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
const eventsPath = join(runDir, 'events.jsonl');
|
|
352
|
+
const evt = {
|
|
353
|
+
schema_version: '1',
|
|
354
|
+
event_id: randomUUID(),
|
|
355
|
+
event_type: 'worktree.auto_cleanup',
|
|
356
|
+
timestamp: new Date().toISOString(),
|
|
357
|
+
run_id: status.run_id ?? runId,
|
|
358
|
+
payload: { runId, path: worktreePath, reason: 'on-success' },
|
|
359
|
+
};
|
|
360
|
+
appendFileSync(eventsPath, `${JSON.stringify(evt)}\n`, 'utf8');
|
|
361
|
+
} catch {
|
|
362
|
+
/* non-fatal */
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return { cleaned: true, runId, path: worktreePath, reason: 'on-success' };
|
|
366
|
+
}
|
|
367
|
+
|
|
268
368
|
/**
|
|
269
369
|
* Start a new pipeline run.
|
|
270
370
|
* @param {{ inputType?: string, inputValue?: string, msize?: number, mloops?: number, planFile?: string, resume?: boolean, projectRoot?: string }} opts
|
|
@@ -272,20 +372,47 @@ export class ProcessManager {
|
|
|
272
372
|
*/
|
|
273
373
|
async startPipeline(opts = {}) {
|
|
274
374
|
const cwd = opts.projectRoot || this.projectRoot;
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
375
|
+
const pipelineScriptRel = '.claude/worca/scripts/run_pipeline.py';
|
|
376
|
+
const worktreeScriptRel = '.claude/worca/scripts/run_worktree.py';
|
|
377
|
+
|
|
378
|
+
let scriptRel;
|
|
379
|
+
if (opts.resume) {
|
|
380
|
+
const pipelinePath = join(cwd, pipelineScriptRel);
|
|
381
|
+
if (!existsSync(pipelinePath)) {
|
|
382
|
+
const err = new Error(`Pipeline script not found at ${pipelinePath}`);
|
|
383
|
+
err.code = 'script_not_found';
|
|
384
|
+
throw err;
|
|
385
|
+
}
|
|
386
|
+
scriptRel = pipelineScriptRel;
|
|
387
|
+
} else {
|
|
388
|
+
const worktreePath = join(cwd, worktreeScriptRel);
|
|
389
|
+
if (existsSync(worktreePath)) {
|
|
390
|
+
scriptRel = worktreeScriptRel;
|
|
391
|
+
} else {
|
|
392
|
+
const pipelinePath = join(cwd, pipelineScriptRel);
|
|
393
|
+
if (!existsSync(pipelinePath)) {
|
|
394
|
+
const err = new Error(`Pipeline script not found at ${pipelinePath}`);
|
|
395
|
+
err.code = 'script_not_found';
|
|
396
|
+
throw err;
|
|
397
|
+
}
|
|
398
|
+
console.warn(
|
|
399
|
+
'[worca] run_worktree.py not found, falling back to run_pipeline.py',
|
|
400
|
+
);
|
|
401
|
+
scriptRel = pipelineScriptRel;
|
|
402
|
+
}
|
|
280
403
|
}
|
|
281
404
|
|
|
282
|
-
const args = [
|
|
405
|
+
const args = [scriptRel];
|
|
283
406
|
let promptFilePath = null; // track for cleanup on spawn failure
|
|
284
407
|
|
|
285
408
|
if (opts.resume) {
|
|
286
409
|
args.push('--resume');
|
|
287
410
|
if (opts.runId) {
|
|
288
|
-
|
|
411
|
+
const ctx = this.resolveRunContext(opts.runId);
|
|
412
|
+
const statusDir = ctx
|
|
413
|
+
? ctx.runDir
|
|
414
|
+
: join(this.worcaDir, 'runs', opts.runId);
|
|
415
|
+
args.push('--status-dir', statusDir);
|
|
289
416
|
}
|
|
290
417
|
} else if (opts.sourceType !== undefined) {
|
|
291
418
|
// New format: separate source and prompt args
|
|
@@ -400,10 +527,13 @@ export class ProcessManager {
|
|
|
400
527
|
let pid = null;
|
|
401
528
|
let foundPidPath = null;
|
|
402
529
|
|
|
530
|
+
const ctx = runId ? this.resolveRunContext(runId) : null;
|
|
531
|
+
const effectiveWorcaDir = ctx ? ctx.worcaDir : this.worcaDir;
|
|
532
|
+
|
|
403
533
|
// Check per-run PID file first, then project-level fallback
|
|
404
534
|
const candidates = [];
|
|
405
535
|
if (runId) {
|
|
406
|
-
candidates.push(join(
|
|
536
|
+
candidates.push(join(effectiveWorcaDir, 'runs', runId, 'pipeline.pid'));
|
|
407
537
|
}
|
|
408
538
|
candidates.push(join(this.worcaDir, 'pipeline.pid'));
|
|
409
539
|
|
|
@@ -431,10 +561,10 @@ export class ProcessManager {
|
|
|
431
561
|
}
|
|
432
562
|
|
|
433
563
|
// Belt-and-suspenders: write control.json so the orchestrator gets a clean signal
|
|
434
|
-
const effectiveRunId = runId
|
|
564
|
+
const effectiveRunId = runId;
|
|
435
565
|
if (effectiveRunId) {
|
|
436
566
|
try {
|
|
437
|
-
const controlDir = join(
|
|
567
|
+
const controlDir = join(effectiveWorcaDir, 'runs', effectiveRunId);
|
|
438
568
|
mkdirSync(controlDir, { recursive: true });
|
|
439
569
|
writeFileSync(
|
|
440
570
|
join(controlDir, 'control.json'),
|
|
@@ -471,14 +601,17 @@ export class ProcessManager {
|
|
|
471
601
|
// Fire-and-forget: reconcileStatus is async but we intentionally don't
|
|
472
602
|
// await it — this is a background cleanup path after the response is sent.
|
|
473
603
|
const worcaDir = this.worcaDir;
|
|
474
|
-
const { settingsPath } = this;
|
|
604
|
+
const { settingsPath, prefsDir } = this;
|
|
475
605
|
const watchdog = setTimeout(() => {
|
|
476
606
|
try {
|
|
477
607
|
process.kill(pid, 0); // check alive
|
|
478
608
|
process.kill(pid, 'SIGKILL');
|
|
479
|
-
setTimeout(
|
|
609
|
+
setTimeout(
|
|
610
|
+
() => reconcileStatus(worcaDir, settingsPath, prefsDir),
|
|
611
|
+
500,
|
|
612
|
+
);
|
|
480
613
|
} catch {
|
|
481
|
-
reconcileStatus(worcaDir, settingsPath);
|
|
614
|
+
reconcileStatus(worcaDir, settingsPath, prefsDir);
|
|
482
615
|
}
|
|
483
616
|
}, 10000);
|
|
484
617
|
watchdog.unref();
|
|
@@ -514,7 +647,9 @@ export class ProcessManager {
|
|
|
514
647
|
}
|
|
515
648
|
const { pid } = running;
|
|
516
649
|
|
|
517
|
-
const
|
|
650
|
+
const ctx = this.resolveRunContext(runId);
|
|
651
|
+
const effectiveWorcaDir = ctx ? ctx.worcaDir : this.worcaDir;
|
|
652
|
+
const controlDir = join(effectiveWorcaDir, 'runs', runId);
|
|
518
653
|
mkdirSync(controlDir, { recursive: true });
|
|
519
654
|
writeFileSync(
|
|
520
655
|
join(controlDir, 'control.json'),
|
|
@@ -569,21 +704,6 @@ export class ProcessManager {
|
|
|
569
704
|
}
|
|
570
705
|
}
|
|
571
706
|
|
|
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
707
|
/**
|
|
588
708
|
* Delete a run directory and clean up references.
|
|
589
709
|
* Refuses if the pipeline is currently running.
|
|
@@ -600,8 +720,10 @@ export class ProcessManager {
|
|
|
600
720
|
throw err;
|
|
601
721
|
}
|
|
602
722
|
|
|
603
|
-
const
|
|
604
|
-
const
|
|
723
|
+
const ctx = this.resolveRunContext(runId);
|
|
724
|
+
const effectiveWorcaDir = ctx ? ctx.worcaDir : this.worcaDir;
|
|
725
|
+
const runsParent = resolve(effectiveWorcaDir, 'runs');
|
|
726
|
+
const runDir = ctx ? resolve(ctx.runDir) : resolve(runsParent, runId);
|
|
605
727
|
if (!runDir.startsWith(runsParent)) {
|
|
606
728
|
const err = new Error('Invalid runId');
|
|
607
729
|
err.code = 'invalid_id';
|
|
@@ -615,17 +737,6 @@ export class ProcessManager {
|
|
|
615
737
|
|
|
616
738
|
rmSync(runDir, { recursive: true, force: true });
|
|
617
739
|
|
|
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
740
|
return { deleted: true };
|
|
630
741
|
}
|
|
631
742
|
|
|
@@ -635,7 +746,8 @@ export class ProcessManager {
|
|
|
635
746
|
* @returns {{ runId: string, paused: boolean }}
|
|
636
747
|
*/
|
|
637
748
|
pausePipeline(runId) {
|
|
638
|
-
const
|
|
749
|
+
const ctx = this.resolveRunContext(runId);
|
|
750
|
+
const controlDir = ctx ? ctx.runDir : join(this.worcaDir, 'runs', runId);
|
|
639
751
|
mkdirSync(controlDir, { recursive: true });
|
|
640
752
|
writeFileSync(
|
|
641
753
|
join(controlDir, 'control.json'),
|
|
@@ -655,19 +767,34 @@ export class ProcessManager {
|
|
|
655
767
|
|
|
656
768
|
/**
|
|
657
769
|
* Restart a failed stage by resetting it and spawning with --resume.
|
|
770
|
+
*
|
|
771
|
+
* Internal API — only called from worca-ui (project-routes.js). The signature
|
|
772
|
+
* changed in W-048 from (stageKey, opts) to (runId, stageKey, opts) because
|
|
773
|
+
* runs are now per-worktree and the manager can no longer infer "the active
|
|
774
|
+
* run". Callers must pass an explicit runId.
|
|
775
|
+
*
|
|
776
|
+
* @param {string} runId - Run identifier to restart
|
|
658
777
|
* @param {string} stageKey - The stage key to restart
|
|
659
778
|
* @param {{ projectRoot?: string }} opts
|
|
660
779
|
* @returns {Promise<{ pid: number, stage: string }>}
|
|
661
780
|
*/
|
|
662
|
-
async restartStage(stageKey, opts = {}) {
|
|
663
|
-
const running = this.getRunningPid();
|
|
781
|
+
async restartStage(runId, stageKey, opts = {}) {
|
|
782
|
+
const running = this.getRunningPid(runId);
|
|
664
783
|
if (running) {
|
|
665
784
|
const err = new Error(`Pipeline already running (PID ${running.pid})`);
|
|
666
785
|
err.code = 'already_running';
|
|
667
786
|
throw err;
|
|
668
787
|
}
|
|
669
788
|
|
|
670
|
-
const
|
|
789
|
+
const ctx = this.resolveRunContext(runId);
|
|
790
|
+
const runDir = ctx ? ctx.runDir : join(this.worcaDir, 'runs', runId);
|
|
791
|
+
// For worktree runs derive projectRoot from worcaDir parent (.worca/..)
|
|
792
|
+
const cwd =
|
|
793
|
+
opts.projectRoot ||
|
|
794
|
+
(ctx && ctx.worcaDir !== this.worcaDir
|
|
795
|
+
? join(ctx.worcaDir, '..')
|
|
796
|
+
: this.projectRoot);
|
|
797
|
+
|
|
671
798
|
const scriptPath = join(cwd, '.claude/worca/scripts/run_pipeline.py');
|
|
672
799
|
if (!existsSync(scriptPath)) {
|
|
673
800
|
const err = new Error(`Pipeline script not found at ${scriptPath}`);
|
|
@@ -675,24 +802,8 @@ export class ProcessManager {
|
|
|
675
802
|
throw err;
|
|
676
803
|
}
|
|
677
804
|
|
|
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) {
|
|
805
|
+
const statusPath = join(runDir, 'status.json');
|
|
806
|
+
if (!existsSync(statusPath)) {
|
|
696
807
|
const err = new Error('No status.json found');
|
|
697
808
|
err.code = 'no_status';
|
|
698
809
|
throw err;
|
|
@@ -720,14 +831,19 @@ export class ProcessManager {
|
|
|
720
831
|
delete status.stages[stageKey].completed_at;
|
|
721
832
|
writeFileSync(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
|
|
722
833
|
|
|
723
|
-
// Spawn with --resume
|
|
834
|
+
// Spawn with --resume --status-dir so the pipeline finds the right run
|
|
724
835
|
const env = { ...process.env };
|
|
725
836
|
delete env.CLAUDECODE;
|
|
726
837
|
|
|
727
838
|
return new Promise((resolve, reject) => {
|
|
728
839
|
const child = spawn(
|
|
729
840
|
'python3',
|
|
730
|
-
[
|
|
841
|
+
[
|
|
842
|
+
'.claude/worca/scripts/run_pipeline.py',
|
|
843
|
+
'--resume',
|
|
844
|
+
'--status-dir',
|
|
845
|
+
runDir,
|
|
846
|
+
],
|
|
731
847
|
{
|
|
732
848
|
detached: true,
|
|
733
849
|
stdio: 'ignore',
|
|
@@ -784,9 +900,13 @@ export function getRunningPid(worcaDir, runId) {
|
|
|
784
900
|
return new ProcessManager({ worcaDir }).getRunningPid(runId);
|
|
785
901
|
}
|
|
786
902
|
|
|
787
|
-
/** @param {string} worcaDir @param {string} [settingsPath] */
|
|
788
|
-
export function reconcileStatus(worcaDir, settingsPath) {
|
|
789
|
-
return new ProcessManager({
|
|
903
|
+
/** @param {string} worcaDir @param {string} [settingsPath] @param {string} [prefsDir] */
|
|
904
|
+
export function reconcileStatus(worcaDir, settingsPath, prefsDir) {
|
|
905
|
+
return new ProcessManager({
|
|
906
|
+
worcaDir,
|
|
907
|
+
settingsPath,
|
|
908
|
+
prefsDir,
|
|
909
|
+
}).reconcileStatus();
|
|
790
910
|
}
|
|
791
911
|
|
|
792
912
|
/** @param {string} worcaDir @param {object} opts */
|
|
@@ -807,10 +927,10 @@ export function pausePipeline(worcaDir, runId) {
|
|
|
807
927
|
return new ProcessManager({ worcaDir }).pausePipeline(runId);
|
|
808
928
|
}
|
|
809
929
|
|
|
810
|
-
/** @param {string} worcaDir @param {string} stageKey @param {object} opts */
|
|
811
|
-
export async function restartStage(worcaDir, stageKey, opts = {}) {
|
|
930
|
+
/** @param {string} worcaDir @param {string} runId @param {string} stageKey @param {object} opts */
|
|
931
|
+
export async function restartStage(worcaDir, runId, stageKey, opts = {}) {
|
|
812
932
|
return new ProcessManager({
|
|
813
933
|
worcaDir,
|
|
814
934
|
projectRoot: opts.projectRoot,
|
|
815
|
-
}).restartStage(stageKey, opts);
|
|
935
|
+
}).restartStage(runId, stageKey, opts);
|
|
816
936
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { atomicWriteSync } from './atomic-write.js';
|
|
4
|
+
|
|
5
|
+
function isPidAlive(pid) {
|
|
6
|
+
try {
|
|
7
|
+
process.kill(pid, 0);
|
|
8
|
+
return true;
|
|
9
|
+
} catch (err) {
|
|
10
|
+
if (err.code === 'EPERM') return true;
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function clearStalePid(statusPath, status) {
|
|
16
|
+
try {
|
|
17
|
+
const patched = {
|
|
18
|
+
...status,
|
|
19
|
+
pipeline_status: 'error',
|
|
20
|
+
error: 'Stale PID: process no longer running',
|
|
21
|
+
};
|
|
22
|
+
atomicWriteSync(statusPath, `${JSON.stringify(patched, null, 2)}\n`);
|
|
23
|
+
} catch {
|
|
24
|
+
// best-effort
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Count running pipelines across all registered projects.
|
|
30
|
+
* Walks ~/.worca/projects.d/, checks each project's .worca/runs/ for
|
|
31
|
+
* status.json entries with pipeline_status=running, and verifies PID liveness.
|
|
32
|
+
* Prunes stale PIDs (dead processes still marked as running).
|
|
33
|
+
*/
|
|
34
|
+
export function countRunningPipelinesAcrossProjects(prefsDir) {
|
|
35
|
+
const projectsDir = join(prefsDir, 'projects.d');
|
|
36
|
+
if (!existsSync(projectsDir)) return 0;
|
|
37
|
+
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = readdirSync(projectsDir);
|
|
41
|
+
} catch {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let count = 0;
|
|
46
|
+
|
|
47
|
+
for (const file of entries) {
|
|
48
|
+
if (!file.endsWith('.json')) continue;
|
|
49
|
+
|
|
50
|
+
let project;
|
|
51
|
+
try {
|
|
52
|
+
project = JSON.parse(readFileSync(join(projectsDir, file), 'utf-8'));
|
|
53
|
+
} catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!project || typeof project.path !== 'string') continue;
|
|
58
|
+
|
|
59
|
+
const runsDir = join(project.path, '.worca', 'runs');
|
|
60
|
+
if (!existsSync(runsDir)) continue;
|
|
61
|
+
|
|
62
|
+
let runEntries;
|
|
63
|
+
try {
|
|
64
|
+
runEntries = readdirSync(runsDir);
|
|
65
|
+
} catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for (const runEntry of runEntries) {
|
|
70
|
+
const statusPath = join(runsDir, runEntry, 'status.json');
|
|
71
|
+
if (!existsSync(statusPath)) continue;
|
|
72
|
+
|
|
73
|
+
let status;
|
|
74
|
+
try {
|
|
75
|
+
status = JSON.parse(readFileSync(statusPath, 'utf-8'));
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (status.pipeline_status !== 'running') continue;
|
|
81
|
+
if (!status.pid) continue;
|
|
82
|
+
|
|
83
|
+
if (isPidAlive(status.pid)) {
|
|
84
|
+
count++;
|
|
85
|
+
} else {
|
|
86
|
+
clearStalePid(statusPath, status);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return count;
|
|
92
|
+
}
|