@worca/ui 0.3.1-rc.3 → 0.3.1-rc.5

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
@@ -2265,6 +2265,27 @@ sl-details.live-output-panel::part(content) {
2265
2265
  font-weight: 500;
2266
2266
  }
2267
2267
 
2268
+ .new-run-info {
2269
+ background: var(--surface-raised, #1e293b);
2270
+ border: 1px solid var(--border, #334155);
2271
+ border-radius: 8px;
2272
+ padding: 16px 20px;
2273
+ margin-bottom: 20px;
2274
+ font-size: 13px;
2275
+ color: var(--muted, #94a3b8);
2276
+ }
2277
+ .new-run-info strong {
2278
+ color: var(--text, #e2e8f0);
2279
+ display: block;
2280
+ margin-bottom: 6px;
2281
+ }
2282
+ .new-run-info p { margin: 0; line-height: 1.5; }
2283
+
2284
+ .new-run-form-disabled {
2285
+ opacity: 0.5;
2286
+ pointer-events: none;
2287
+ }
2288
+
2268
2289
  .new-run-section sl-select {
2269
2290
  width: 100%;
2270
2291
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.3.1-rc.3",
3
+ "version": "0.3.1-rc.5",
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 */
@@ -473,6 +473,17 @@ export function createProjectScopedRoutes() {
473
473
 
474
474
  // POST /api/projects/:projectId/runs — start a new pipeline
475
475
  router.post('/runs', requireWorcaDir, async (req, res) => {
476
+ // Block parallel pipelines on the same project (GH #82)
477
+ const running = req.project.pm.getRunningPid();
478
+ if (running) {
479
+ return res.status(409).json({
480
+ ok: false,
481
+ error:
482
+ 'A pipeline is already running on this project. Parallel pipelines on the same project are not yet supported.',
483
+ code: 'already_running',
484
+ });
485
+ }
486
+
476
487
  const body = req.body || {};
477
488
 
478
489
  let {
@@ -597,7 +608,7 @@ export function createProjectScopedRoutes() {
597
608
  // DELETE /api/projects/:projectId/runs/:id — stop a running pipeline
598
609
  router.delete('/runs/:id', requireWorcaDir, (req, res) => {
599
610
  try {
600
- const result = req.project.pm.stopPipeline();
611
+ const result = req.project.pm.stopPipeline(req.params.id);
601
612
  const { broadcast } = req.app.locals;
602
613
  if (broadcast) broadcast('run-stopped', { pid: result.pid });
603
614
  res.json({ ok: true, stopped: true, pid: result.pid });
@@ -691,7 +702,7 @@ export function createProjectScopedRoutes() {
691
702
  /* non-fatal — SIGTERM follows */
692
703
  }
693
704
  try {
694
- const result = req.project.pm.stopPipeline();
705
+ const result = req.project.pm.stopPipeline(runId);
695
706
  const { broadcast } = req.app.locals;
696
707
  if (broadcast) broadcast('run-stopped', { runId, pid: result.pid });
697
708
  res.json({ ok: true, stopped: true, runId, pid: result.pid });
@@ -1107,7 +1118,7 @@ export function createProjectScopedRoutes() {
1107
1118
  worcaDir: join(pipeline.worktree_path, '.worca'),
1108
1119
  });
1109
1120
  try {
1110
- const result = worktreePm.stopPipeline();
1121
+ const result = worktreePm.stopPipeline(runId);
1111
1122
  res.json({ ok: true, stopped: true, runId, pid: result.pid });
1112
1123
  } catch (err) {
1113
1124
  if (err.code === 'not_running') {
package/server/watcher.js CHANGED
@@ -100,7 +100,9 @@ export function discoverRuns(worcaDir) {
100
100
  const id = createRunId(data);
101
101
  if (!seenIds.has(id)) {
102
102
  seenIds.add(id);
103
- runs.push({ id, active: false, ...data });
103
+ const active =
104
+ !isTerminal(data) && data.pipeline_status === 'running';
105
+ runs.push({ id, active, ...data });
104
106
  }
105
107
  }
106
108
  } else if (entry.isDirectory()) {
@@ -111,7 +113,9 @@ export function discoverRuns(worcaDir) {
111
113
  const id = createRunId(data);
112
114
  if (!seenIds.has(id)) {
113
115
  seenIds.add(id);
114
- runs.push({ id, active: false, ...data });
116
+ const active =
117
+ !isTerminal(data) && data.pipeline_status === 'running';
118
+ runs.push({ id, active, ...data });
115
119
  }
116
120
  }
117
121
  }
@@ -213,7 +217,8 @@ export async function discoverRunsAsync(worcaDir) {
213
217
  const id = createRunId(data);
214
218
  if (!seenIds.has(id)) {
215
219
  seenIds.add(id);
216
- runs.push({ id, active: false, ...data });
220
+ const active = !isTerminal(data) && data.pipeline_status === 'running';
221
+ runs.push({ id, active, ...data });
217
222
  }
218
223
  }
219
224
  } catch {
@@ -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;