@worca/ui 0.3.1-rc.2 → 0.3.1-rc.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.3.1-rc.2",
3
+ "version": "0.3.1-rc.4",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -10,6 +10,7 @@ import {
10
10
  existsSync,
11
11
  mkdirSync,
12
12
  openSync,
13
+ readdirSync,
13
14
  readFileSync,
14
15
  unlinkSync,
15
16
  writeFileSync,
@@ -67,80 +68,118 @@ export class ProcessManager {
67
68
 
68
69
  /**
69
70
  * Check if a pipeline is currently running.
71
+ * @param {string} [runId] - If provided, check per-run PID first
70
72
  * @returns {{ pid: number } | null}
71
73
  */
72
- getRunningPid() {
73
- const pidPath = join(this.worcaDir, 'pipeline.pid');
74
- if (!existsSync(pidPath)) return null;
75
- try {
76
- const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
77
- if (Number.isNaN(pid) || pid <= 0) {
74
+ getRunningPid(runId) {
75
+ // Build candidate PID paths: per-run first, then project-level fallback
76
+ const candidates = [];
77
+ if (runId) {
78
+ candidates.push(join(this.worcaDir, 'runs', runId, 'pipeline.pid'));
79
+ }
80
+ candidates.push(join(this.worcaDir, 'pipeline.pid'));
81
+
82
+ for (const pidPath of candidates) {
83
+ if (!existsSync(pidPath)) continue;
84
+ try {
85
+ const pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
86
+ if (Number.isNaN(pid) || pid <= 0) {
87
+ try {
88
+ unlinkSync(pidPath);
89
+ } catch {
90
+ /* ignore */
91
+ }
92
+ continue;
93
+ }
94
+ process.kill(pid, 0); // throws if dead
95
+ return { pid };
96
+ } catch {
97
+ // Stale PID file — clean up
78
98
  try {
79
99
  unlinkSync(pidPath);
80
100
  } catch {
81
101
  /* ignore */
82
102
  }
83
- return null;
84
- }
85
- process.kill(pid, 0); // throws if dead
86
- return { pid };
87
- } catch {
88
- // Stale PID file — clean up
89
- try {
90
- unlinkSync(pidPath);
91
- } catch {
92
- /* ignore */
93
103
  }
94
- return null;
95
104
  }
105
+ return null;
96
106
  }
97
107
 
98
108
  /**
99
109
  * Reconcile stale "running" status when the pipeline process is dead.
100
- * Checks the active run's status.json if pipeline_status is "running"
101
- * but no process is alive, transitions to "failed" with stop_reason="stale".
110
+ * Scans all runs with per-run PID files + the active_run pointer.
111
+ * If pipeline_status is "running" but no process is alive, transitions
112
+ * to "failed" with stop_reason="stale".
102
113
  * Preserves any existing stop_reason (e.g. "signal" set by Layer 1).
103
114
  *
104
- * @returns {boolean} true if status was fixed
115
+ * @returns {boolean} true if any status was fixed
105
116
  */
106
117
  reconcileStatus() {
107
- const running = this.getRunningPid();
108
- if (running) return false; // process is alive, nothing to fix
118
+ let fixed = false;
109
119
 
110
- const activeRunPath = join(this.worcaDir, 'active_run');
111
- if (!existsSync(activeRunPath)) return false;
120
+ // Collect run IDs to check: scan runs/*/pipeline.pid + active_run fallback
121
+ const runIds = new Set();
122
+ const runsDir = join(this.worcaDir, 'runs');
123
+ if (existsSync(runsDir)) {
124
+ try {
125
+ for (const entry of readdirSync(runsDir, { withFileTypes: true })) {
126
+ if (
127
+ entry.isDirectory() &&
128
+ existsSync(join(runsDir, entry.name, 'pipeline.pid'))
129
+ ) {
130
+ runIds.add(entry.name);
131
+ }
132
+ }
133
+ } catch {
134
+ /* ignore */
135
+ }
136
+ }
112
137
 
113
- let runId;
114
- try {
115
- runId = readFileSync(activeRunPath, 'utf8').trim();
116
- } catch {
117
- return false;
138
+ // Backward compat: also check active_run pointer
139
+ const activeRunPath = join(this.worcaDir, 'active_run');
140
+ if (existsSync(activeRunPath)) {
141
+ try {
142
+ const activeId = readFileSync(activeRunPath, 'utf8').trim();
143
+ if (activeId) runIds.add(activeId);
144
+ } catch {
145
+ /* ignore */
146
+ }
118
147
  }
119
- if (!runId) return false;
120
148
 
121
- const statusPath = join(this.worcaDir, 'runs', runId, 'status.json');
122
- if (!existsSync(statusPath)) return false;
149
+ for (const runId of runIds) {
150
+ // Check if this run's process is alive
151
+ const alive = this.getRunningPid(runId);
152
+ if (alive) continue;
123
153
 
124
- let status;
125
- try {
126
- status = JSON.parse(readFileSync(statusPath, 'utf8'));
127
- } catch {
128
- return false;
129
- }
154
+ const statusPath = join(this.worcaDir, 'runs', runId, 'status.json');
155
+ if (!existsSync(statusPath)) continue;
156
+
157
+ let status;
158
+ try {
159
+ status = JSON.parse(readFileSync(statusPath, 'utf8'));
160
+ } catch {
161
+ continue;
162
+ }
130
163
 
131
- if (status.pipeline_status !== 'running') return false;
164
+ if (status.pipeline_status !== 'running') continue;
132
165
 
133
- status.pipeline_status = 'failed';
134
- if (!status.stop_reason) {
135
- status.stop_reason = 'stale';
136
- }
137
- try {
138
- writeFileSync(statusPath, `${JSON.stringify(status, null, 2)}\n`, 'utf8');
139
- } catch {
140
- return false;
166
+ status.pipeline_status = 'failed';
167
+ if (!status.stop_reason) {
168
+ status.stop_reason = 'stale';
169
+ }
170
+ try {
171
+ writeFileSync(
172
+ statusPath,
173
+ `${JSON.stringify(status, null, 2)}\n`,
174
+ 'utf8',
175
+ );
176
+ fixed = true;
177
+ } catch {
178
+ /* ignore */
179
+ }
141
180
  }
142
181
 
143
- return true;
182
+ return fixed;
144
183
  }
145
184
 
146
185
  /**
@@ -271,16 +310,27 @@ export class ProcessManager {
271
310
  /**
272
311
  * Stop a running pipeline.
273
312
  * PID file is the sole source of truth — no pgrep fallback.
313
+ * @param {string} [runId] - If provided, look up PID from per-run directory first
274
314
  * @returns {{ pid: number, stopped: boolean }}
275
315
  */
276
- stopPipeline() {
316
+ stopPipeline(runId) {
277
317
  let pid = null;
278
- const pidPath = join(this.worcaDir, 'pipeline.pid');
318
+ let foundPidPath = null;
279
319
 
280
- if (existsSync(pidPath)) {
320
+ // Check per-run PID file first, then project-level fallback
321
+ const candidates = [];
322
+ if (runId) {
323
+ candidates.push(join(this.worcaDir, 'runs', runId, 'pipeline.pid'));
324
+ }
325
+ candidates.push(join(this.worcaDir, 'pipeline.pid'));
326
+
327
+ for (const pidPath of candidates) {
328
+ if (!existsSync(pidPath)) continue;
281
329
  try {
282
330
  pid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
283
331
  process.kill(pid, 0); // verify alive
332
+ foundPidPath = pidPath;
333
+ break;
284
334
  } catch {
285
335
  try {
286
336
  unlinkSync(pidPath);
@@ -298,27 +348,24 @@ export class ProcessManager {
298
348
  }
299
349
 
300
350
  // Belt-and-suspenders: write control.json so the orchestrator gets a clean signal
301
- const activeRunPath = join(this.worcaDir, 'active_run');
302
- if (existsSync(activeRunPath)) {
351
+ const effectiveRunId = runId || this._readActiveRunId();
352
+ if (effectiveRunId) {
303
353
  try {
304
- const runId = readFileSync(activeRunPath, 'utf8').trim();
305
- if (runId) {
306
- const controlDir = join(this.worcaDir, 'runs', runId);
307
- mkdirSync(controlDir, { recursive: true });
308
- writeFileSync(
309
- join(controlDir, 'control.json'),
310
- `${JSON.stringify(
311
- {
312
- action: 'stop',
313
- requested_at: new Date().toISOString(),
314
- source: 'ui',
315
- },
316
- null,
317
- 2,
318
- )}\n`,
319
- 'utf8',
320
- );
321
- }
354
+ const controlDir = join(this.worcaDir, 'runs', effectiveRunId);
355
+ mkdirSync(controlDir, { recursive: true });
356
+ writeFileSync(
357
+ join(controlDir, 'control.json'),
358
+ `${JSON.stringify(
359
+ {
360
+ action: 'stop',
361
+ requested_at: new Date().toISOString(),
362
+ source: 'ui',
363
+ },
364
+ null,
365
+ 2,
366
+ )}\n`,
367
+ 'utf8',
368
+ );
322
369
  } catch {
323
370
  /* non-fatal */
324
371
  }
@@ -328,7 +375,7 @@ export class ProcessManager {
328
375
  process.kill(pid, 'SIGTERM');
329
376
  } catch (e) {
330
377
  try {
331
- unlinkSync(pidPath);
378
+ if (foundPidPath) unlinkSync(foundPidPath);
332
379
  } catch {
333
380
  /* ignore */
334
381
  }
@@ -352,16 +399,33 @@ export class ProcessManager {
352
399
  }, 10000);
353
400
  watchdog.unref();
354
401
 
355
- // Clean up PID file
356
- try {
357
- unlinkSync(pidPath);
358
- } catch {
359
- /* ignore */
402
+ // Clean up PID files (per-run + project-level)
403
+ for (const pidPath of candidates) {
404
+ try {
405
+ unlinkSync(pidPath);
406
+ } catch {
407
+ /* ignore */
408
+ }
360
409
  }
361
410
 
362
411
  return { pid, stopped: true };
363
412
  }
364
413
 
414
+ /**
415
+ * Read the active_run file to get the current run ID.
416
+ * @returns {string|null}
417
+ */
418
+ _readActiveRunId() {
419
+ const activeRunPath = join(this.worcaDir, 'active_run');
420
+ if (!existsSync(activeRunPath)) return null;
421
+ try {
422
+ const id = readFileSync(activeRunPath, 'utf8').trim();
423
+ return id || null;
424
+ } catch {
425
+ return null;
426
+ }
427
+ }
428
+
365
429
  /**
366
430
  * Pause a running pipeline by writing a control file.
367
431
  * @param {string} runId - Pipeline run identifier
@@ -512,9 +576,9 @@ export class ProcessManager {
512
576
  // These delegate to a one-off ProcessManager instance so existing callers
513
577
  // (app.js, ws.js, tests) continue to work without changes during Phase 0.
514
578
 
515
- /** @param {string} worcaDir */
516
- export function getRunningPid(worcaDir) {
517
- return new ProcessManager({ worcaDir }).getRunningPid();
579
+ /** @param {string} worcaDir @param {string} [runId] */
580
+ export function getRunningPid(worcaDir, runId) {
581
+ return new ProcessManager({ worcaDir }).getRunningPid(runId);
518
582
  }
519
583
 
520
584
  /** @param {string} worcaDir */
@@ -530,9 +594,9 @@ export async function startPipeline(worcaDir, opts = {}) {
530
594
  }).startPipeline(opts);
531
595
  }
532
596
 
533
- /** @param {string} worcaDir */
534
- export function stopPipeline(worcaDir) {
535
- return new ProcessManager({ worcaDir }).stopPipeline();
597
+ /** @param {string} worcaDir @param {string} [runId] */
598
+ export function stopPipeline(worcaDir, runId) {
599
+ return new ProcessManager({ worcaDir }).stopPipeline(runId);
536
600
  }
537
601
 
538
602
  /** @param {string} worcaDir @param {string} runId */
@@ -597,7 +597,7 @@ export function createProjectScopedRoutes() {
597
597
  // DELETE /api/projects/:projectId/runs/:id — stop a running pipeline
598
598
  router.delete('/runs/:id', requireWorcaDir, (req, res) => {
599
599
  try {
600
- const result = req.project.pm.stopPipeline();
600
+ const result = req.project.pm.stopPipeline(req.params.id);
601
601
  const { broadcast } = req.app.locals;
602
602
  if (broadcast) broadcast('run-stopped', { pid: result.pid });
603
603
  res.json({ ok: true, stopped: true, pid: result.pid });
@@ -691,7 +691,7 @@ export function createProjectScopedRoutes() {
691
691
  /* non-fatal — SIGTERM follows */
692
692
  }
693
693
  try {
694
- const result = req.project.pm.stopPipeline();
694
+ const result = req.project.pm.stopPipeline(runId);
695
695
  const { broadcast } = req.app.locals;
696
696
  if (broadcast) broadcast('run-stopped', { runId, pid: result.pid });
697
697
  res.json({ ok: true, stopped: true, runId, pid: result.pid });
@@ -1107,7 +1107,7 @@ export function createProjectScopedRoutes() {
1107
1107
  worcaDir: join(pipeline.worktree_path, '.worca'),
1108
1108
  });
1109
1109
  try {
1110
- const result = worktreePm.stopPipeline();
1110
+ const result = worktreePm.stopPipeline(runId);
1111
1111
  res.json({ ok: true, stopped: true, runId, pid: result.pid });
1112
1112
  } catch (err) {
1113
1113
  if (err.code === 'not_running') {
@@ -452,7 +452,8 @@ export function createMessageRouter({
452
452
  return;
453
453
  }
454
454
  try {
455
- const result = pmStopPipeline(proj.worcaDir);
455
+ const { runId } = req.payload || {};
456
+ const result = pmStopPipeline(proj.worcaDir, runId);
456
457
  ws.send(JSON.stringify(makeOk(req, result)));
457
458
  let checks = 0;
458
459
  const maxChecks = 20;