@worca/ui 0.22.0 → 0.24.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.
@@ -6,26 +6,47 @@ export const STATES = [
6
6
  'failed',
7
7
  'interrupted',
8
8
  'cancelled',
9
+ 'halted',
10
+ 'setup_failed',
11
+ 'unrecoverable',
12
+ 'planning',
13
+ 'integration_testing',
14
+ 'integration_failed',
15
+ 'blocked',
9
16
  ];
10
17
 
11
18
  const ACTION_MATRIX = {
12
- stop: { running: true },
13
- pause: { running: true },
14
- resume: { paused: true, failed: true, interrupted: true },
19
+ stop: { running: true, planning: true, integration_testing: true },
20
+ pause: { running: true, planning: true, integration_testing: true },
21
+ resume: {
22
+ paused: true,
23
+ failed: true,
24
+ interrupted: true,
25
+ halted: true,
26
+ integration_failed: true,
27
+ blocked: true,
28
+ },
15
29
  cancel: {
16
30
  pending: true,
17
31
  running: true,
18
32
  paused: true,
19
33
  failed: true,
20
34
  interrupted: true,
35
+ halted: true,
36
+ setup_failed: true,
37
+ integration_failed: true,
38
+ blocked: true,
21
39
  },
22
40
  archive: {
23
- pending: true,
24
41
  paused: true,
25
42
  completed: true,
26
43
  failed: true,
27
44
  interrupted: true,
28
45
  cancelled: true,
46
+ halted: true,
47
+ setup_failed: true,
48
+ unrecoverable: true,
49
+ integration_failed: true,
29
50
  },
30
51
  unarchive: {
31
52
  completed: true,
@@ -40,6 +61,11 @@ const ACTION_MATRIX = {
40
61
  failed: true,
41
62
  interrupted: true,
42
63
  cancelled: true,
64
+ halted: true,
65
+ setup_failed: true,
66
+ unrecoverable: true,
67
+ integration_failed: true,
68
+ blocked: true,
43
69
  },
44
70
  learn: {
45
71
  paused: true,
@@ -47,6 +73,9 @@ const ACTION_MATRIX = {
47
73
  failed: true,
48
74
  interrupted: true,
49
75
  cancelled: true,
76
+ halted: true,
77
+ integration_failed: true,
78
+ blocked: true,
50
79
  },
51
80
  };
52
81
 
@@ -0,0 +1,11 @@
1
+ // AUTO-GENERATED by scripts/build-frontend.js — do not edit.
2
+ // Source: src/worca/state/status.py
3
+
4
+ export const FLEET_STICKY = Object.freeze(new Set(["halted","paused"]));
5
+ export const FLEET_TERMINAL = Object.freeze(new Set(["completed","failed","halted"]));
6
+ export const WORKSPACE_TERMINAL = Object.freeze(new Set(["completed","failed","halted","integration_failed"]));
7
+ export const PIPELINE_ACTIVE = Object.freeze(new Set(["paused","resuming","running"]));
8
+ export const PIPELINE_TERMINAL = Object.freeze(new Set(["completed","interrupted"]));
9
+ export const PIPELINE_FAILURE = Object.freeze(new Set(["failed","setup_failed","unrecoverable"]));
10
+ export const PIPELINE_ALL_TERMINAL = Object.freeze(new Set(["cancelled","completed","failed","interrupted","setup_failed","unrecoverable"]));
11
+ export const PIPELINE_IN_FLIGHT = Object.freeze(new Set(["resuming","running"]));
package/bin/worca-ui.js CHANGED
@@ -12,9 +12,9 @@ import {
12
12
  writeFileSync,
13
13
  } from 'node:fs';
14
14
  import { connect, createServer } from 'node:net';
15
- import { homedir } from 'node:os';
16
15
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
17
16
  import { fileURLToPath } from 'node:url';
17
+ import { worcaHome } from '../server/paths.js';
18
18
  import {
19
19
  readProjects,
20
20
  removeProject,
@@ -37,7 +37,7 @@ function findProjectRoot(startDir) {
37
37
  return startDir;
38
38
  }
39
39
 
40
- const PREFS_DIR = join(homedir(), '.worca');
40
+ const PREFS_DIR = worcaHome();
41
41
  const SERVER_SCRIPT = join(__dirname, '..', 'server', 'index.js');
42
42
 
43
43
  /** Exported for testing */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.22.0",
3
+ "version": "0.24.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -38,6 +38,7 @@
38
38
  "app/protocol.js",
39
39
  "app/utils/stage-order.js",
40
40
  "app/utils/state-actions.js",
41
+ "app/utils/status-constants.js",
41
42
  "scripts/build-frontend.js"
42
43
  ],
43
44
  "engines": {
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { copyFileSync, mkdirSync } from 'node:fs';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { copyFileSync, mkdirSync, writeFileSync } from 'node:fs';
3
4
  import path from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
5
6
 
@@ -41,6 +42,52 @@ async function run() {
41
42
  console.log('copied', path.relative(repoRoot, dest));
42
43
  }
43
44
 
45
+ // Codegen: derive status-constants.js from the Python enums in
46
+ // src/worca/state/status.py. Single source of truth — when Python adds a
47
+ // status, the JS side picks it up at the next build. Shipped in the npm
48
+ // package via the `files` allowlist in package.json.
49
+ const utilsDir = path.join(appDir, 'utils');
50
+ mkdirSync(utilsDir, { recursive: true });
51
+ const constantsOut = path.join(utilsDir, 'status-constants.js');
52
+ try {
53
+ const pyScript =
54
+ 'import json; from worca.state import status as s; ' +
55
+ 'print(json.dumps({' +
56
+ '"FLEET_STICKY": sorted(s.FLEET_STICKY), ' +
57
+ '"FLEET_TERMINAL": sorted(s.FLEET_TERMINAL), ' +
58
+ '"WORKSPACE_TERMINAL": sorted(s.WORKSPACE_TERMINAL), ' +
59
+ '"PIPELINE_ACTIVE": sorted(s.PIPELINE_ACTIVE), ' +
60
+ '"PIPELINE_TERMINAL": sorted(s.PIPELINE_TERMINAL), ' +
61
+ '"PIPELINE_FAILURE": sorted(s.PIPELINE_FAILURE), ' +
62
+ '"PIPELINE_ALL_TERMINAL": sorted(s.PIPELINE_ALL_TERMINAL), ' +
63
+ '"PIPELINE_IN_FLIGHT": sorted(s.PIPELINE_IN_FLIGHT)' +
64
+ '}))';
65
+ const raw = execFileSync('python3', ['-c', pyScript], {
66
+ cwd: path.join(repoRoot, '..'),
67
+ encoding: 'utf8',
68
+ });
69
+ const constants = JSON.parse(raw);
70
+ const lines = [
71
+ '// AUTO-GENERATED by scripts/build-frontend.js — do not edit.',
72
+ '// Source: src/worca/state/status.py',
73
+ '',
74
+ ];
75
+ for (const [name, values] of Object.entries(constants)) {
76
+ lines.push(
77
+ `export const ${name} = Object.freeze(new Set(${JSON.stringify(values)}));`,
78
+ );
79
+ }
80
+ writeFileSync(constantsOut, `${lines.join('\n')}\n`);
81
+ console.log('generated', path.relative(repoRoot, constantsOut));
82
+ } catch (err) {
83
+ console.error(
84
+ 'status-constants codegen failed (is python3 + worca-cc installed?):',
85
+ err.message,
86
+ );
87
+ process.exitCode = 1;
88
+ return;
89
+ }
90
+
44
91
  try {
45
92
  const esbuild = await import('esbuild');
46
93
  await esbuild.build({
package/server/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // server/app.js
2
2
 
3
- import { execFileSync } from 'node:child_process';
3
+ import { execFileSync, spawn } from 'node:child_process';
4
4
  import { createHmac, randomUUID } from 'node:crypto';
5
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
@@ -9,9 +9,11 @@ import { fileURLToPath } from 'node:url';
9
9
  import express from 'express';
10
10
 
11
11
  import { dbExists, getIssue, listIssues } from './beads-reader.js';
12
+ import { createFleetRouter } from './fleet-routes.js';
12
13
  import { RAW_BODY } from './integrations/index.js';
13
14
  import { verify } from './integrations/verify.js';
14
15
  import { LaunchLock } from './launch-lock.js';
16
+ import { fleetRunsDir, workspaceRunsDir, workspacesDir } from './paths.js';
15
17
  import { createPreferencesRouter } from './preferences-routes.js';
16
18
  import { ProcessManager } from './process-manager.js';
17
19
  import { scanDirectory } from './project-registry.js';
@@ -26,6 +28,30 @@ import { discoverSubagents } from './subagents-discovery.js';
26
28
  import { checkWorcaVersion } from './version-check.js';
27
29
  import { getVersionInfo } from './versions.js';
28
30
  import { createInbox } from './webhook-inbox.js';
31
+ import { createWorkspaceRouter } from './workspace-routes.js';
32
+
33
+ // Invokes `worca cleanup --<flag> <id>` as a subprocess and resolves once
34
+ // the cleanup completes. Wired into the fleet/workspace router DELETE
35
+ // ?cleanup=1 path so the UI Cleanup button actually removes the worktrees
36
+ // + manifest dir (without this, the route falls back to a no-op default).
37
+ function runWorcaCleanupSubprocess(flag, id) {
38
+ return new Promise((resolve, reject) => {
39
+ const child = spawn(
40
+ 'python3',
41
+ ['-m', 'worca.cli.main', 'cleanup', flag, id],
42
+ { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } },
43
+ );
44
+ let stderr = '';
45
+ child.stderr.on('data', (chunk) => {
46
+ stderr += chunk.toString();
47
+ });
48
+ child.on('error', reject);
49
+ child.on('exit', (code) => {
50
+ if (code === 0) resolve({});
51
+ else reject(new Error(`worca cleanup exited ${code}: ${stderr.trim()}`));
52
+ });
53
+ });
54
+ }
29
55
 
30
56
  export function createApp(options = {}) {
31
57
  const app = express();
@@ -550,6 +576,138 @@ export function createApp(options = {}) {
550
576
  launchLock,
551
577
  }),
552
578
  );
579
+ app.use(
580
+ '/api/fleet-runs',
581
+ createFleetRouter({
582
+ fleetRunsDir: fleetRunsDir(),
583
+ prefsDir,
584
+ runCleanup: (id) => runWorcaCleanupSubprocess('--fleet-id', id),
585
+ // Spawn run_fleet.py in a detached subprocess so the route can return
586
+ // immediately. We pass the pre-generated fleet_id so the in-flight
587
+ // manifest path matches what the route just wrote.
588
+ dispatchFleet: async ({ fleet_id, projects, manifest, resume }) => {
589
+ // Resume path: the /resume route already flipped the manifest to
590
+ // "running"; run_fleet.py --resume reads the manifest, continues
591
+ // paused/interrupted children in place and re-dispatches
592
+ // failed/pending ones. No --projects — the manifest is the source.
593
+ if (resume) {
594
+ const child = spawn(
595
+ 'python3',
596
+ ['-m', 'worca.scripts.run_fleet', '--resume', fleet_id],
597
+ { detached: true, stdio: 'ignore', env: { ...process.env } },
598
+ );
599
+ child.unref();
600
+ return;
601
+ }
602
+ if (!projects || projects.length === 0) return;
603
+ const args = [
604
+ '-m',
605
+ 'worca.scripts.run_fleet',
606
+ '--fleet-id',
607
+ fleet_id,
608
+ '--projects',
609
+ ...projects,
610
+ ];
611
+ if (manifest.work_request?.source) {
612
+ args.push('--source', manifest.work_request.source);
613
+ } else {
614
+ args.push('--prompt', manifest.work_request?.description ?? '');
615
+ }
616
+ if (manifest.head_template) {
617
+ args.push('--head-template', manifest.head_template);
618
+ }
619
+ if (manifest.base_branch) {
620
+ args.push('--base', manifest.base_branch);
621
+ }
622
+ if (manifest.plan?.path) {
623
+ args.push('--plan', manifest.plan.path);
624
+ }
625
+ for (const p of manifest.guide?.paths || []) {
626
+ args.push('--guide', p);
627
+ }
628
+ if (manifest.max_parallel) {
629
+ args.push('--max-parallel', String(manifest.max_parallel));
630
+ }
631
+ if (manifest.fleet_failure_threshold != null) {
632
+ args.push(
633
+ '--fleet-failure-threshold',
634
+ String(manifest.fleet_failure_threshold),
635
+ );
636
+ }
637
+ const child = spawn('python3', args, {
638
+ detached: true,
639
+ stdio: 'ignore',
640
+ env: { ...process.env },
641
+ });
642
+ child.unref();
643
+ },
644
+ }),
645
+ );
646
+
647
+ // Workspace routers — both definitions (/api/workspaces) and runs
648
+ // (/api/workspace-runs). The router factory exposes them as a pair.
649
+ const workspaceRouters = createWorkspaceRouter({
650
+ workspaceRunsDir: workspaceRunsDir(),
651
+ workspacesDir: workspacesDir(),
652
+ runCleanup: (id) => runWorcaCleanupSubprocess('--workspace-id', id),
653
+ // Spawn run_workspace.py in a detached subprocess, mirroring the fleet
654
+ // dispatcher. We pass --workspace-id so the script reuses the manifest
655
+ // the route just wrote instead of generating a fresh ID (which would
656
+ // orphan the manifest the UI navigated to).
657
+ dispatchWorkspace: async ({
658
+ workspace_id,
659
+ workspace_root,
660
+ manifest,
661
+ resume,
662
+ }) => {
663
+ if (resume) {
664
+ const child = spawn(
665
+ 'python3',
666
+ [
667
+ '-m',
668
+ 'worca.scripts.run_workspace',
669
+ workspace_root,
670
+ '--resume',
671
+ workspace_id,
672
+ ],
673
+ { detached: true, stdio: 'ignore', env: { ...process.env } },
674
+ );
675
+ child.unref();
676
+ return;
677
+ }
678
+ const args = [
679
+ '-m',
680
+ 'worca.scripts.run_workspace',
681
+ workspace_root,
682
+ '--workspace-id',
683
+ workspace_id,
684
+ ];
685
+ if (manifest.work_request?.source) {
686
+ args.push('--source', manifest.work_request.source);
687
+ } else {
688
+ args.push('--prompt', manifest.work_request?.description ?? '');
689
+ }
690
+ if (manifest.branch_template) {
691
+ args.push('--branch', manifest.branch_template);
692
+ }
693
+ if (manifest.skip_integration) args.push('--skip-integration');
694
+ if (manifest.skip_planning) args.push('--skip-planning');
695
+ if (manifest.max_parallel) {
696
+ args.push('--max-parallel', String(manifest.max_parallel));
697
+ }
698
+ for (const p of manifest.guide?.paths || []) {
699
+ args.push('--guide', p);
700
+ }
701
+ const child = spawn('python3', args, {
702
+ detached: true,
703
+ stdio: 'ignore',
704
+ env: { ...process.env },
705
+ });
706
+ child.unref();
707
+ },
708
+ });
709
+ app.use('/api/workspaces', workspaceRouters.workspaces);
710
+ app.use('/api/workspace-runs', workspaceRouters.workspaceRuns);
553
711
  }
554
712
 
555
713
  // POST /api/integrations/telegram/detect — find chat IDs from recent messages.