@worca/ui 0.9.0-rc.4 → 0.9.0-rc.6

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
@@ -22,9 +22,9 @@
22
22
  --status-paused: #f59e0b;
23
23
  --status-completed: #22c55e;
24
24
  --status-failed: #ef4444;
25
- --status-resuming: #3b82f6;
26
25
  --status-skipped: #94a3b8;
27
26
  --status-interrupted: #f59e0b;
27
+ --status-cancelled: #94a3b8;
28
28
  /* legacy */
29
29
  --status-in-progress: #3b82f6;
30
30
  --status-error: #ef4444;
@@ -1125,8 +1125,8 @@ sl-details.log-history-panel::part(content) {
1125
1125
  color: var(--status-failed);
1126
1126
  }
1127
1127
 
1128
- .status-resuming {
1129
- color: var(--status-resuming);
1128
+ .status-cancelled {
1129
+ color: var(--status-cancelled);
1130
1130
  }
1131
1131
 
1132
1132
  /* --- 18b. Control Buttons --- */
@@ -1281,11 +1281,11 @@ sl-details.log-history-panel::part(content) {
1281
1281
 
1282
1282
  /* Status-colored left border accent on run cards */
1283
1283
  .run-card.status-running { border-left: 3px solid var(--status-running); }
1284
- .run-card.status-resuming { border-left: 3px solid var(--status-resuming); }
1285
1284
  .run-card.status-paused { border-left: 3px solid var(--status-paused); }
1286
1285
  .run-card.status-completed { border-left: 3px solid var(--status-completed); }
1287
1286
  .run-card.status-failed { border-left: 3px solid var(--status-failed); }
1288
1287
  .run-card.status-pending { border-left: 3px solid var(--status-pending); }
1288
+ .run-card.status-cancelled { border-left: 3px solid var(--status-cancelled); }
1289
1289
 
1290
1290
  /* --- 21. Dashboard --- */
1291
1291
  .dashboard {
@@ -3991,6 +3991,7 @@ sl-details.learnings-panel::part(content) {
3991
3991
  .pipeline-succeeded { border-left-color: var(--status-completed); }
3992
3992
  .pipeline-failed { border-left-color: var(--status-failed); }
3993
3993
  .pipeline-paused { border-left-color: var(--status-paused); }
3994
+ .pipeline-cancelled { border-left-color: var(--status-cancelled); }
3994
3995
  .pipeline-unknown { border-left-color: var(--muted); }
3995
3996
 
3996
3997
  .pipeline-card-header {
@@ -0,0 +1,55 @@
1
+ export const STATES = [
2
+ 'pending',
3
+ 'running',
4
+ 'paused',
5
+ 'completed',
6
+ 'failed',
7
+ 'interrupted',
8
+ 'cancelled',
9
+ ];
10
+
11
+ const ACTION_MATRIX = {
12
+ stop: { running: true },
13
+ pause: { running: true },
14
+ resume: { paused: true, failed: true, interrupted: true },
15
+ cancel: {
16
+ pending: true,
17
+ running: true,
18
+ paused: true,
19
+ failed: true,
20
+ interrupted: true,
21
+ },
22
+ archive: {
23
+ pending: true,
24
+ paused: true,
25
+ completed: true,
26
+ failed: true,
27
+ interrupted: true,
28
+ cancelled: true,
29
+ },
30
+ unarchive: {
31
+ completed: true,
32
+ failed: true,
33
+ interrupted: true,
34
+ cancelled: true,
35
+ },
36
+ delete: {
37
+ pending: true,
38
+ paused: true,
39
+ completed: true,
40
+ failed: true,
41
+ interrupted: true,
42
+ cancelled: true,
43
+ },
44
+ learn: {
45
+ paused: true,
46
+ completed: true,
47
+ failed: true,
48
+ interrupted: true,
49
+ cancelled: true,
50
+ },
51
+ };
52
+
53
+ export function actionAllowed(action, status) {
54
+ return Boolean(ACTION_MATRIX[action]?.[status]);
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.9.0-rc.4",
3
+ "version": "0.9.0-rc.6",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -35,6 +35,7 @@
35
35
  "app/vendor/",
36
36
  "app/protocol.js",
37
37
  "app/utils/stage-order.js",
38
+ "app/utils/state-actions.js",
38
39
  "scripts/build-frontend.js"
39
40
  ],
40
41
  "engines": {
package/server/app.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  } from './project-routes.js';
21
21
  import { validateIntegrationsConfig } from './settings-validator.js';
22
22
  import { discoverSubagents } from './subagents-discovery.js';
23
+ import { checkWorcaVersion } from './version-check.js';
23
24
  import { getVersionInfo } from './versions.js';
24
25
  import { createInbox } from './webhook-inbox.js';
25
26
 
@@ -105,6 +106,7 @@ export function createApp(options = {}) {
105
106
  ? new ProcessManager({
106
107
  worcaDir,
107
108
  projectRoot: projectRoot || process.cwd(),
109
+ settingsPath,
108
110
  })
109
111
  : null,
110
112
  };
@@ -502,6 +504,10 @@ export function createApp(options = {}) {
502
504
  app.get('/api/versions', async (req, res) => {
503
505
  const force = req.query.force === '1';
504
506
  const prefsPath = prefsDir ? join(prefsDir, 'preferences.json') : null;
507
+ // Re-check installed worca-cc version on force refresh
508
+ if (force) {
509
+ app.locals.worcaVersion = await checkWorcaVersion();
510
+ }
505
511
  const worcaVersion = app.locals.worcaVersion || null;
506
512
  try {
507
513
  const data = await getVersionInfo({ prefsPath, worcaVersion, force });
@@ -5,7 +5,7 @@ import { promisify } from 'node:util';
5
5
  const execFileAsync = promisify(execFile);
6
6
 
7
7
  async function runBd(args, dbPath) {
8
- const fullArgs = [...args, '--json', '--db', dbPath, '--readonly'];
8
+ const fullArgs = [...args, '--json', '--db', dbPath];
9
9
  const { stdout } = await execFileAsync('bd', fullArgs, {
10
10
  encoding: 'utf8',
11
11
  timeout: 10000,
@@ -0,0 +1,106 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ const DEFAULT_TIMEOUT_MS = 30_000;
4
+
5
+ export function resolvePythonCmd() {
6
+ if (process.env.WORCA_PYTHON) {
7
+ return [process.env.WORCA_PYTHON];
8
+ }
9
+ if (process.platform === 'win32') {
10
+ return ['py', 'python3', 'python'];
11
+ }
12
+ return ['python3', 'python'];
13
+ }
14
+
15
+ export function dispatchExternal({
16
+ runDir,
17
+ settingsPath,
18
+ eventType,
19
+ payload,
20
+ timeoutMs = DEFAULT_TIMEOUT_MS,
21
+ }) {
22
+ const candidates = resolvePythonCmd();
23
+ const args = [
24
+ '-m',
25
+ 'worca.events.dispatch_external',
26
+ '--run-dir',
27
+ runDir,
28
+ '--settings',
29
+ settingsPath,
30
+ '--event-type',
31
+ eventType,
32
+ '--payload-json',
33
+ JSON.stringify(payload),
34
+ ];
35
+
36
+ let candidateIdx = 0;
37
+
38
+ function trySpawn(resolve) {
39
+ if (candidateIdx >= candidates.length) {
40
+ resolve({ ok: false, reason: 'python_not_found' });
41
+ return;
42
+ }
43
+
44
+ const cmd = candidates[candidateIdx];
45
+ const spawnArgs = cmd === 'py' ? ['-3', ...args] : args;
46
+
47
+ const child = spawn(cmd, spawnArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
48
+
49
+ let stdoutBuf = '';
50
+ let stderrBuf = '';
51
+ let settled = false;
52
+
53
+ const timer = setTimeout(() => {
54
+ if (!settled) {
55
+ settled = true;
56
+ child.kill();
57
+ resolve({ ok: false, reason: 'timeout' });
58
+ }
59
+ }, timeoutMs);
60
+
61
+ child.stdout.on('data', (chunk) => {
62
+ stdoutBuf += chunk;
63
+ });
64
+ child.stderr.on('data', (chunk) => {
65
+ stderrBuf += chunk;
66
+ });
67
+
68
+ child.on('error', (err) => {
69
+ if (!settled && err.code === 'ENOENT') {
70
+ clearTimeout(timer);
71
+ candidateIdx++;
72
+ trySpawn(resolve);
73
+ return;
74
+ }
75
+ if (!settled) {
76
+ settled = true;
77
+ clearTimeout(timer);
78
+ resolve({ ok: false, reason: 'spawn_error', stderr: err.message });
79
+ }
80
+ });
81
+
82
+ child.on('close', (code) => {
83
+ if (settled) return;
84
+ settled = true;
85
+ clearTimeout(timer);
86
+
87
+ if (code !== 0) {
88
+ resolve({
89
+ ok: false,
90
+ reason: `exit_code_${code}`,
91
+ stderr: stderrBuf,
92
+ });
93
+ return;
94
+ }
95
+
96
+ try {
97
+ const parsed = JSON.parse(stdoutBuf);
98
+ resolve(parsed);
99
+ } catch {
100
+ resolve({ ok: false, reason: 'invalid_response', stdout: stdoutBuf });
101
+ }
102
+ });
103
+ }
104
+
105
+ return new Promise((resolve) => trySpawn(resolve));
106
+ }
@@ -13,12 +13,15 @@ import {
13
13
  openSync,
14
14
  readdirSync,
15
15
  readFileSync,
16
+ rmSync,
16
17
  unlinkSync,
17
18
  writeFileSync,
18
19
  writeSync,
19
20
  } from 'node:fs';
20
21
  import { tmpdir } from 'node:os';
21
- import { join } from 'node:path';
22
+ import { join, resolve } from 'node:path';
23
+
24
+ import { dispatchExternal } from './dispatch-external.js';
22
25
 
23
26
  /** Byte threshold — must match claude_cli.py _ARG_INLINE_LIMIT */
24
27
  const ARG_INLINE_LIMIT = 128 * 1024;
@@ -73,11 +76,12 @@ function cleanupPromptFile(filePath) {
73
76
  */
74
77
  export class ProcessManager {
75
78
  /**
76
- * @param {{ worcaDir: string, projectRoot?: string }} options
79
+ * @param {{ worcaDir: string, projectRoot?: string, settingsPath?: string }} options
77
80
  */
78
- constructor({ worcaDir, projectRoot }) {
81
+ constructor({ worcaDir, projectRoot, settingsPath }) {
79
82
  this.worcaDir = worcaDir;
80
83
  this.projectRoot = projectRoot || process.cwd();
84
+ this.settingsPath = settingsPath ?? null;
81
85
  }
82
86
 
83
87
  /**
@@ -128,8 +132,9 @@ export class ProcessManager {
128
132
  *
129
133
  * @returns {boolean} true if any status was fixed
130
134
  */
131
- reconcileStatus() {
135
+ async reconcileStatus() {
132
136
  let fixed = false;
137
+ const dispatches = [];
133
138
 
134
139
  // Collect run IDs to check: scan runs/*/pipeline.pid + active_run fallback
135
140
  const runIds = new Set();
@@ -180,8 +185,7 @@ export class ProcessManager {
180
185
  if (!status.stop_reason) {
181
186
  status.stop_reason = 'stale';
182
187
  }
183
- status.pipeline_status =
184
- status.stop_reason === 'stale' ? 'interrupted' : 'failed';
188
+ status.pipeline_status = 'failed';
185
189
  try {
186
190
  writeFileSync(
187
191
  statusPath,
@@ -193,7 +197,8 @@ export class ProcessManager {
193
197
  /* ignore */
194
198
  }
195
199
 
196
- // Append synthetic interrupted event if no terminal event exists yet
200
+ // Append synthetic failed event if no terminal event exists yet.
201
+ // Status is "failed" (process crash), so the event type must match.
197
202
  const eventsPath = join(this.worcaDir, 'runs', runId, 'events.jsonl');
198
203
  let hasTerminalEvent = false;
199
204
  if (existsSync(eventsPath)) {
@@ -214,30 +219,44 @@ export class ProcessManager {
214
219
  }
215
220
  }
216
221
  if (!hasTerminalEvent) {
217
- try {
218
- const evt = {
219
- schema_version: '1',
220
- event_id: randomUUID(),
221
- event_type: 'pipeline.run.interrupted',
222
- timestamp: new Date().toISOString(),
223
- run_id: status.run_id ?? runId,
224
- pipeline: {
225
- branch: status.branch ?? null,
226
- work_request: status.work_request ?? null,
227
- },
228
- payload: {
229
- interrupted_stage: status.current_stage ?? 'unknown',
230
- elapsed_ms: elapsedMsSince(status.started_at),
231
- source: 'reconcile',
232
- },
233
- };
234
- appendFileSync(eventsPath, `${JSON.stringify(evt)}\n`, 'utf8');
235
- } catch {
236
- /* ignore */
222
+ const payload = {
223
+ failed_stage: status.current_stage ?? 'unknown',
224
+ elapsed_ms: elapsedMsSince(status.started_at),
225
+ source: 'stale',
226
+ };
227
+
228
+ if (this.settingsPath) {
229
+ dispatches.push(
230
+ dispatchExternal({
231
+ runDir: join(this.worcaDir, 'runs', runId),
232
+ settingsPath: this.settingsPath,
233
+ eventType: 'pipeline.run.failed',
234
+ payload,
235
+ }).catch(() => {}),
236
+ );
237
+ } else {
238
+ try {
239
+ const evt = {
240
+ schema_version: '1',
241
+ event_id: randomUUID(),
242
+ event_type: 'pipeline.run.failed',
243
+ timestamp: new Date().toISOString(),
244
+ run_id: status.run_id ?? runId,
245
+ pipeline: {
246
+ branch: status.branch ?? null,
247
+ work_request: status.work_request ?? null,
248
+ },
249
+ payload: { ...payload, source: 'reconcile' },
250
+ };
251
+ appendFileSync(eventsPath, `${JSON.stringify(evt)}\n`, 'utf8');
252
+ } catch {
253
+ /* ignore */
254
+ }
237
255
  }
238
256
  }
239
257
  }
240
258
 
259
+ await Promise.all(dispatches);
241
260
  return fixed;
242
261
  }
243
262
 
@@ -443,17 +462,18 @@ export class ProcessManager {
443
462
  throw err;
444
463
  }
445
464
 
446
- // Watchdog: SIGKILL after 10s if still alive, then reconcile status
465
+ // Watchdog: SIGKILL after 10s if still alive, then reconcile status.
466
+ // Fire-and-forget: reconcileStatus is async but we intentionally don't
467
+ // await it — this is a background cleanup path after the response is sent.
447
468
  const worcaDir = this.worcaDir;
469
+ const { settingsPath } = this;
448
470
  const watchdog = setTimeout(() => {
449
471
  try {
450
472
  process.kill(pid, 0); // check alive
451
473
  process.kill(pid, 'SIGKILL');
452
- // Give the OS a moment to reap the process, then fix stale status
453
- setTimeout(() => reconcileStatus(worcaDir), 500);
474
+ setTimeout(() => reconcileStatus(worcaDir, settingsPath), 500);
454
475
  } catch {
455
- // Already dead — reconcile in case signal handler didn't save
456
- reconcileStatus(worcaDir);
476
+ reconcileStatus(worcaDir, settingsPath);
457
477
  }
458
478
  }, 10000);
459
479
  watchdog.unref();
@@ -470,6 +490,80 @@ export class ProcessManager {
470
490
  return { pid, stopped: true };
471
491
  }
472
492
 
493
+ /**
494
+ * Synchronous-style stop: control.json + signal + poll for exit.
495
+ * @param {string} runId
496
+ * @param {{ timeoutMs?: number }} [opts]
497
+ * @returns {Promise<{ pid: number, exitCode: null, forced?: boolean }>}
498
+ */
499
+ async stopPipelineSync(runId, { timeoutMs } = {}) {
500
+ if (timeoutMs === undefined) {
501
+ timeoutMs = process.platform === 'win32' ? 30000 : 5000;
502
+ }
503
+
504
+ const running = this.getRunningPid(runId);
505
+ if (!running) {
506
+ const e = new Error('not running');
507
+ e.code = 'not_running';
508
+ throw e;
509
+ }
510
+ const { pid } = running;
511
+
512
+ const controlDir = join(this.worcaDir, 'runs', runId);
513
+ mkdirSync(controlDir, { recursive: true });
514
+ writeFileSync(
515
+ join(controlDir, 'control.json'),
516
+ `${JSON.stringify({ action: 'stop', requested_at: new Date().toISOString(), source: 'ui' }, null, 2)}\n`,
517
+ 'utf8',
518
+ );
519
+
520
+ if (process.platform !== 'win32') {
521
+ try {
522
+ process.kill(pid, 'SIGTERM');
523
+ } catch {
524
+ /* already dead */
525
+ }
526
+ } else {
527
+ this._killAgentSubprocess(runId);
528
+ }
529
+
530
+ const pollMs = timeoutMs > 10000 ? 500 : 100;
531
+ const deadline = Date.now() + timeoutMs;
532
+ while (Date.now() < deadline) {
533
+ try {
534
+ process.kill(pid, 0);
535
+ } catch {
536
+ return { pid, exitCode: null };
537
+ }
538
+ await new Promise((r) => setTimeout(r, pollMs));
539
+ }
540
+
541
+ try {
542
+ process.kill(pid, 'SIGKILL');
543
+ } catch {
544
+ /* already dead */
545
+ }
546
+ return { pid, exitCode: null, forced: true };
547
+ }
548
+
549
+ /**
550
+ * Kill the agent subprocess (claude CLI) via agent.pid.
551
+ * Used on Windows where SIGTERM doesn't propagate to child processes.
552
+ * @param {string} runId
553
+ */
554
+ _killAgentSubprocess(runId) {
555
+ const pidPath = join(this.worcaDir, 'runs', runId, 'agent.pid');
556
+ if (!existsSync(pidPath)) return;
557
+ try {
558
+ const agentPid = parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
559
+ if (!Number.isNaN(agentPid) && agentPid > 0) {
560
+ process.kill(agentPid, 'SIGTERM');
561
+ }
562
+ } catch {
563
+ /* agent already dead or pid file invalid */
564
+ }
565
+ }
566
+
473
567
  /**
474
568
  * Read the active_run file to get the current run ID.
475
569
  * @returns {string|null}
@@ -485,6 +579,51 @@ export class ProcessManager {
485
579
  }
486
580
  }
487
581
 
582
+ /**
583
+ * Delete a run directory and clean up references.
584
+ * Refuses if the pipeline is currently running.
585
+ * @param {string} runId
586
+ * @returns {{ deleted: boolean }}
587
+ */
588
+ deleteRun(runId) {
589
+ const running = this.getRunningPid(runId);
590
+ if (running) {
591
+ const err = new Error(
592
+ 'Cannot delete a running pipeline — stop or cancel it first',
593
+ );
594
+ err.code = 'still_running';
595
+ throw err;
596
+ }
597
+
598
+ const runsParent = resolve(this.worcaDir, 'runs');
599
+ const runDir = resolve(runsParent, runId);
600
+ if (!runDir.startsWith(runsParent)) {
601
+ const err = new Error('Invalid runId');
602
+ err.code = 'invalid_id';
603
+ throw err;
604
+ }
605
+ if (!existsSync(runDir)) {
606
+ const err = new Error(`Run "${runId}" not found`);
607
+ err.code = 'not_found';
608
+ throw err;
609
+ }
610
+
611
+ rmSync(runDir, { recursive: true, force: true });
612
+
613
+ // Clear active_run pointer if it references this run
614
+ const activeRunPath = join(this.worcaDir, 'active_run');
615
+ if (existsSync(activeRunPath)) {
616
+ try {
617
+ const activeId = readFileSync(activeRunPath, 'utf8').trim();
618
+ if (activeId === runId) unlinkSync(activeRunPath);
619
+ } catch {
620
+ /* ignore */
621
+ }
622
+ }
623
+
624
+ return { deleted: true };
625
+ }
626
+
488
627
  /**
489
628
  * Pause a running pipeline by writing a control file.
490
629
  * @param {string} runId - Pipeline run identifier
@@ -640,9 +779,9 @@ export function getRunningPid(worcaDir, runId) {
640
779
  return new ProcessManager({ worcaDir }).getRunningPid(runId);
641
780
  }
642
781
 
643
- /** @param {string} worcaDir */
644
- export function reconcileStatus(worcaDir) {
645
- return new ProcessManager({ worcaDir }).reconcileStatus();
782
+ /** @param {string} worcaDir @param {string} [settingsPath] */
783
+ export function reconcileStatus(worcaDir, settingsPath) {
784
+ return new ProcessManager({ worcaDir, settingsPath }).reconcileStatus();
646
785
  }
647
786
 
648
787
  /** @param {string} worcaDir @param {object} opts */