@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/main.bundle.js +177 -170
- package/app/main.bundle.js.map +3 -3
- package/app/styles.css +21 -0
- package/package.json +1 -1
- package/server/process-manager.js +147 -83
- package/server/project-routes.js +14 -3
- package/server/watcher.js +8 -3
- package/server/ws-message-router.js +2 -1
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
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
*
|
|
101
|
-
* but no process is alive, transitions
|
|
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
|
-
|
|
108
|
-
if (running) return false; // process is alive, nothing to fix
|
|
118
|
+
let fixed = false;
|
|
109
119
|
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
164
|
+
if (status.pipeline_status !== 'running') continue;
|
|
132
165
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
318
|
+
let foundPidPath = null;
|
|
279
319
|
|
|
280
|
-
|
|
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
|
|
302
|
-
if (
|
|
351
|
+
const effectiveRunId = runId || this._readActiveRunId();
|
|
352
|
+
if (effectiveRunId) {
|
|
303
353
|
try {
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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(
|
|
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
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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 */
|
package/server/project-routes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|