@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/styles.css CHANGED
@@ -1039,9 +1039,17 @@ sl-details.log-history-panel::part(content) {
1039
1039
  min-width: 140px;
1040
1040
  }
1041
1041
 
1042
- .log-controls sl-input [slot="prefix"] {
1042
+ /* Prefix-slot icon centering. Inline SVGs default to baseline alignment,
1043
+ which sits them at the top of an sl-input. Flex-center the slot wrapper
1044
+ so any iconSvg(...) we drop into a prefix lines up with the placeholder
1045
+ / value text. Generic rule covers every sl-input; .log-controls keeps
1046
+ the extra left padding it always had. */
1047
+ sl-input [slot="prefix"] {
1043
1048
  display: flex;
1044
1049
  align-items: center;
1050
+ }
1051
+
1052
+ .log-controls sl-input [slot="prefix"] {
1045
1053
  padding-left: 4px;
1046
1054
  }
1047
1055
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -63,6 +63,7 @@
63
63
  "lit-html": "^3.3.1",
64
64
  "lucide": "^0.577.0",
65
65
  "marked": "^17.0.1",
66
+ "proper-lockfile": "^4.1.2",
66
67
  "ws": "^8.18.3"
67
68
  },
68
69
  "devDependencies": {
package/server/app.js CHANGED
@@ -11,6 +11,8 @@ import express from 'express';
11
11
  import { dbExists, getIssue, listIssues } from './beads-reader.js';
12
12
  import { RAW_BODY } from './integrations/index.js';
13
13
  import { verify } from './integrations/verify.js';
14
+ import { LaunchLock } from './launch-lock.js';
15
+ import { createPreferencesRouter } from './preferences-routes.js';
14
16
  import { ProcessManager } from './process-manager.js';
15
17
  import { scanDirectory } from './project-registry.js';
16
18
  import {
@@ -19,6 +21,7 @@ import {
19
21
  projectResolver,
20
22
  } from './project-routes.js';
21
23
  import { validateIntegrationsConfig } from './settings-validator.js';
24
+ import { createStatusRouter } from './status-routes.js';
22
25
  import { discoverSubagents } from './subagents-discovery.js';
23
26
  import { checkWorcaVersion } from './version-check.js';
24
27
  import { getVersionInfo } from './versions.js';
@@ -89,6 +92,13 @@ export function createApp(options = {}) {
89
92
  const webhookInbox = options.webhookInbox || createInbox();
90
93
  app.locals.webhookInbox = webhookInbox;
91
94
 
95
+ // Single LaunchLock instance shared across BOTH legacy /api and
96
+ // /api/projects/:id mounts so the global max_concurrent_pipelines cap is
97
+ // enforced atomically across all entry points. Without this, two routers
98
+ // each held their own mutex and concurrent launches via /api/runs +
99
+ // /api/projects/:id/runs could both pass the cap check and start.
100
+ const launchLock = new LaunchLock();
101
+
92
102
  // ─── Legacy single-project API ─────────────────────────────────────────
93
103
  // Mounts the shared project-scoped routes at /api with a middleware that
94
104
  // injects req.project from the closure options, so /api/runs, /api/settings,
@@ -112,7 +122,12 @@ export function createApp(options = {}) {
112
122
  };
113
123
  next();
114
124
  },
115
- createProjectScopedRoutes({ serverHost, serverPort }),
125
+ createProjectScopedRoutes({
126
+ prefsDir,
127
+ serverHost,
128
+ serverPort,
129
+ launchLock,
130
+ }),
116
131
  );
117
132
 
118
133
  // ─── Unique routes (not in project-scoped router) ──────────────────────
@@ -519,6 +534,8 @@ export function createApp(options = {}) {
519
534
 
520
535
  // ─── Multi-project routes ──────────────────────────────────────────────
521
536
  if (prefsDir) {
537
+ app.use('/api/preferences', createPreferencesRouter({ prefsDir }));
538
+ app.use('/api/status', createStatusRouter({ prefsDir }));
522
539
  app.use(
523
540
  '/api/projects',
524
541
  createProjectRoutes({ prefsDir, projectRoot, serverHost, serverPort }),
@@ -526,7 +543,12 @@ export function createApp(options = {}) {
526
543
  app.use(
527
544
  '/api/projects/:projectId',
528
545
  projectResolver({ prefsDir, projectRoot }),
529
- createProjectScopedRoutes({ prefsDir, serverHost, serverPort }),
546
+ createProjectScopedRoutes({
547
+ prefsDir,
548
+ serverHost,
549
+ serverPort,
550
+ launchLock,
551
+ }),
530
552
  );
531
553
  }
532
554
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Atomic file write: write to a temp file then rename into place.
3
+ * Prevents partial reads when a reader opens the file mid-write.
4
+ */
5
+
6
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+
9
+ export function atomicWriteSync(filePath, data, options = {}) {
10
+ const dir = dirname(filePath);
11
+ mkdirSync(dir, { recursive: true });
12
+ const tmp = join(
13
+ dir,
14
+ `.${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`,
15
+ );
16
+ writeFileSync(tmp, data, options);
17
+ renameSync(tmp, filePath);
18
+ }
@@ -89,15 +89,40 @@ export async function listUnlinkedIssues(beadsDb) {
89
89
  }
90
90
  }
91
91
 
92
+ /**
93
+ * Returns { runId: { total, done } } for every run:<id> label in the beads db.
94
+ *
95
+ * `total` comes from the cheap `bd label list-all` count. `done` requires
96
+ * looking at issue status, so we query `bd list --label-any run:<id>` per
97
+ * run and count statuses === "closed". N+1 queries, but N is bounded by
98
+ * the number of pipeline runs and this endpoint is called on app load /
99
+ * project switch only, not on every render.
100
+ */
92
101
  export async function countIssuesByRunLabel(beadsDb) {
93
102
  try {
94
103
  const rows = await runBd(['label', 'list-all'], beadsDb);
95
104
  const counts = {};
96
- for (const row of rows) {
97
- if (row.label.startsWith('run:')) {
98
- counts[row.label.replace('run:', '')] = row.count;
99
- }
105
+ const runLabels = rows.filter((r) => r.label.startsWith('run:'));
106
+ for (const row of runLabels) {
107
+ counts[row.label.replace('run:', '')] = { total: row.count, done: 0 };
100
108
  }
109
+ // Count closed issues per label in parallel.
110
+ await Promise.all(
111
+ runLabels.map(async (row) => {
112
+ const runId = row.label.replace('run:', '');
113
+ try {
114
+ const issues = await runBd(
115
+ ['list', '--label-any', row.label, '--all', '--limit', '0'],
116
+ beadsDb,
117
+ );
118
+ counts[runId].done = issues.filter(
119
+ (i) => i.status === 'closed',
120
+ ).length;
121
+ } catch {
122
+ /* leave done at 0 on per-run failure */
123
+ }
124
+ }),
125
+ );
101
126
  return counts;
102
127
  } catch {
103
128
  return {};
@@ -0,0 +1,49 @@
1
+ import { GLOBAL_ONLY_KEYS } from './keys-schema.js';
2
+
3
+ const INERT_MILESTONE_KEYS = ['pr_approval', 'deploy_approval'];
4
+
5
+ /**
6
+ * Mutates `blob` in place: extracts misplaced global-only keys and strips
7
+ * inert milestone keys (pr_approval/deploy_approval when set to `true`).
8
+ *
9
+ * Returns { globalExtracted, removedMilestones } for the caller to merge
10
+ * into ~/.worca/settings.json and to surface in the response.
11
+ */
12
+ export function extractAndStripGlobalKeys(blob) {
13
+ const globalExtracted = {};
14
+ const removedMilestones = [];
15
+
16
+ const worca = blob.worca;
17
+ if (!worca || typeof worca !== 'object') {
18
+ return { globalExtracted, removedMilestones };
19
+ }
20
+
21
+ for (const [section, key] of GLOBAL_ONLY_KEYS) {
22
+ const sectionObj = worca[section];
23
+ if (!sectionObj || typeof sectionObj !== 'object') continue;
24
+ if (!(key in sectionObj)) continue;
25
+
26
+ if (!globalExtracted[section]) globalExtracted[section] = {};
27
+ globalExtracted[section][key] = sectionObj[key];
28
+ delete sectionObj[key];
29
+
30
+ if (Object.keys(sectionObj).length === 0) {
31
+ delete worca[section];
32
+ }
33
+ }
34
+
35
+ const milestones = worca.milestones;
36
+ if (milestones && typeof milestones === 'object') {
37
+ for (const key of INERT_MILESTONE_KEYS) {
38
+ if (milestones[key] === true) {
39
+ delete milestones[key];
40
+ removedMilestones.push(key);
41
+ }
42
+ }
43
+ if (Object.keys(milestones).length === 0) {
44
+ delete worca.milestones;
45
+ }
46
+ }
47
+
48
+ return { globalExtracted, removedMilestones };
49
+ }
@@ -0,0 +1,16 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const schema = JSON.parse(
7
+ readFileSync(
8
+ resolve(__dirname, '../../src/worca/schemas/keys.json'),
9
+ 'utf-8',
10
+ ),
11
+ );
12
+
13
+ export const GLOBAL_ONLY_KEYS = schema.global_only_keys;
14
+ export const NORMALIZE_SKIP_KEYS = schema.normalize_skip_keys;
15
+ export const GLOBAL_DEFAULTS = schema.defaults.global;
16
+ export const PROJECT_DEFAULTS = schema.defaults.project;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * In-process async mutex using a promise chain.
3
+ * Used to serialize pipeline launches so max_concurrent_pipelines is enforced atomically.
4
+ */
5
+ export class LaunchLock {
6
+ #tail = Promise.resolve();
7
+
8
+ acquire() {
9
+ let release;
10
+ const prev = this.#tail;
11
+ this.#tail = new Promise((resolve) => {
12
+ release = resolve;
13
+ });
14
+ return prev.then(() => release);
15
+ }
16
+
17
+ async withLock(fn) {
18
+ const release = await this.acquire();
19
+ try {
20
+ return await fn();
21
+ } finally {
22
+ release();
23
+ }
24
+ }
25
+ }
@@ -0,0 +1,143 @@
1
+ import { join } from 'node:path';
2
+ import { Router } from 'express';
3
+ import { readGlobalSettings, writeGlobalSettings } from './settings-reader.js';
4
+
5
+ const VALID_CLEANUP_POLICIES = ['never', 'on-success', 'manual-only'];
6
+ const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
7
+ const MIN_DISK_BYTES = 500_000_000;
8
+ const MAX_DISK_BYTES = 50_000_000_000;
9
+
10
+ export function validateGlobalSettingsPayload(body) {
11
+ const details = [];
12
+
13
+ if (body.worca !== undefined) {
14
+ if (
15
+ typeof body.worca !== 'object' ||
16
+ body.worca === null ||
17
+ Array.isArray(body.worca)
18
+ ) {
19
+ details.push('worca must be an object');
20
+ return { valid: false, details };
21
+ }
22
+ const w = body.worca;
23
+
24
+ if (w.parallel !== undefined) {
25
+ if (
26
+ typeof w.parallel !== 'object' ||
27
+ w.parallel === null ||
28
+ Array.isArray(w.parallel)
29
+ ) {
30
+ details.push('worca.parallel must be an object');
31
+ } else {
32
+ if (w.parallel.max_concurrent_pipelines !== undefined) {
33
+ const v = w.parallel.max_concurrent_pipelines;
34
+ if (!Number.isInteger(v) || v < 1 || v > 100) {
35
+ details.push(
36
+ 'max_concurrent_pipelines must be an integer between 1 and 100',
37
+ );
38
+ }
39
+ }
40
+ if (w.parallel.cleanup_policy !== undefined) {
41
+ if (!VALID_CLEANUP_POLICIES.includes(w.parallel.cleanup_policy)) {
42
+ details.push(
43
+ `cleanup_policy must be one of: ${VALID_CLEANUP_POLICIES.join(', ')}`,
44
+ );
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ if (w.ui !== undefined) {
51
+ if (typeof w.ui !== 'object' || w.ui === null || Array.isArray(w.ui)) {
52
+ details.push('worca.ui must be an object');
53
+ } else if (w.ui.worktree_disk_warning_bytes !== undefined) {
54
+ const v = w.ui.worktree_disk_warning_bytes;
55
+ if (
56
+ typeof v !== 'number' ||
57
+ !Number.isFinite(v) ||
58
+ v < MIN_DISK_BYTES ||
59
+ v > MAX_DISK_BYTES
60
+ ) {
61
+ details.push(
62
+ `worktree_disk_warning_bytes must be a number between ${MIN_DISK_BYTES} and ${MAX_DISK_BYTES}`,
63
+ );
64
+ }
65
+ }
66
+ }
67
+
68
+ if (w.circuit_breaker !== undefined) {
69
+ if (
70
+ typeof w.circuit_breaker !== 'object' ||
71
+ w.circuit_breaker === null ||
72
+ Array.isArray(w.circuit_breaker)
73
+ ) {
74
+ details.push('worca.circuit_breaker must be an object');
75
+ } else if (w.circuit_breaker.classifier_model !== undefined) {
76
+ if (!VALID_MODELS.includes(w.circuit_breaker.classifier_model)) {
77
+ details.push(
78
+ `classifier_model must be one of: ${VALID_MODELS.join(', ')}`,
79
+ );
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ return details.length ? { valid: false, details } : { valid: true };
86
+ }
87
+
88
+ export function createPreferencesRouter({ prefsDir }) {
89
+ const router = Router();
90
+ const globalSettingsPath = join(prefsDir, 'settings.json');
91
+
92
+ router.get('/', (_req, res) => {
93
+ try {
94
+ const prefs = readGlobalSettings(globalSettingsPath);
95
+ res.json({ ok: true, preferences: prefs });
96
+ } catch (err) {
97
+ res.status(500).json({
98
+ ok: false,
99
+ error: 'Failed to read global preferences',
100
+ detail: err.message,
101
+ });
102
+ }
103
+ });
104
+
105
+ router.put('/', (req, res) => {
106
+ const body = req.body;
107
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
108
+ return res.status(400).json({
109
+ ok: false,
110
+ error: {
111
+ code: 'validation_error',
112
+ message: 'Request body must be a JSON object',
113
+ details: [],
114
+ },
115
+ });
116
+ }
117
+
118
+ const validation = validateGlobalSettingsPayload(body);
119
+ if (!validation.valid) {
120
+ return res.status(400).json({
121
+ ok: false,
122
+ error: {
123
+ code: 'validation_error',
124
+ message: 'Invalid preferences payload',
125
+ details: validation.details,
126
+ },
127
+ });
128
+ }
129
+
130
+ try {
131
+ const merged = writeGlobalSettings(globalSettingsPath, body);
132
+ res.json({ ok: true, preferences: merged });
133
+ } catch (err) {
134
+ res.status(500).json({
135
+ ok: false,
136
+ error: 'Failed to write global preferences',
137
+ detail: err.message,
138
+ });
139
+ }
140
+ });
141
+
142
+ return router;
143
+ }
@@ -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,11 @@ 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;
85
88
  }
86
89
 
87
90
  /**
@@ -127,10 +130,17 @@ export class ProcessManager {
127
130
  * @returns {{ pid: number } | null}
128
131
  */
129
132
  getRunningPid(runId) {
130
- // Build candidate PID paths: per-run first, then project-level fallback
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/.
131
136
  const candidates = [];
132
137
  if (runId) {
133
- candidates.push(join(this.worcaDir, 'runs', runId, 'pipeline.pid'));
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
+ }
134
144
  }
135
145
  candidates.push(join(this.worcaDir, 'pipeline.pid'));
136
146
 
@@ -285,12 +295,76 @@ export class ProcessManager {
285
295
  }
286
296
  }
287
297
  }
298
+
299
+ this.maybeAutoCleanup(runId);
288
300
  }
289
301
 
290
302
  await Promise.all(dispatches);
291
303
  return fixed;
292
304
  }
293
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
+
294
368
  /**
295
369
  * Start a new pipeline run.
296
370
  * @param {{ inputType?: string, inputValue?: string, msize?: number, mloops?: number, planFile?: string, resume?: boolean, projectRoot?: string }} opts
@@ -527,14 +601,17 @@ export class ProcessManager {
527
601
  // Fire-and-forget: reconcileStatus is async but we intentionally don't
528
602
  // await it — this is a background cleanup path after the response is sent.
529
603
  const worcaDir = this.worcaDir;
530
- const { settingsPath } = this;
604
+ const { settingsPath, prefsDir } = this;
531
605
  const watchdog = setTimeout(() => {
532
606
  try {
533
607
  process.kill(pid, 0); // check alive
534
608
  process.kill(pid, 'SIGKILL');
535
- setTimeout(() => reconcileStatus(worcaDir, settingsPath), 500);
609
+ setTimeout(
610
+ () => reconcileStatus(worcaDir, settingsPath, prefsDir),
611
+ 500,
612
+ );
536
613
  } catch {
537
- reconcileStatus(worcaDir, settingsPath);
614
+ reconcileStatus(worcaDir, settingsPath, prefsDir);
538
615
  }
539
616
  }, 10000);
540
617
  watchdog.unref();
@@ -823,9 +900,13 @@ export function getRunningPid(worcaDir, runId) {
823
900
  return new ProcessManager({ worcaDir }).getRunningPid(runId);
824
901
  }
825
902
 
826
- /** @param {string} worcaDir @param {string} [settingsPath] */
827
- export function reconcileStatus(worcaDir, settingsPath) {
828
- return new ProcessManager({ worcaDir, settingsPath }).reconcileStatus();
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();
829
910
  }
830
911
 
831
912
  /** @param {string} worcaDir @param {object} opts */
@@ -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
+ }