@worca/ui 0.14.0 → 0.15.2

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/bin/worca-ui.js CHANGED
@@ -1,14 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from 'node:child_process';
3
3
  import {
4
+ closeSync,
4
5
  existsSync,
5
6
  mkdirSync,
7
+ openSync,
6
8
  readdirSync,
7
9
  readFileSync,
10
+ realpathSync,
8
11
  unlinkSync,
9
12
  writeFileSync,
10
13
  } from 'node:fs';
11
- import { createServer } from 'node:net';
14
+ import { connect, createServer } from 'node:net';
12
15
  import { homedir } from 'node:os';
13
16
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
14
17
  import { fileURLToPath } from 'node:url';
@@ -110,6 +113,61 @@ export function parseArgs(argv) {
110
113
  return args;
111
114
  }
112
115
 
116
+ /** Resolve log file path based on mode (mirrors PID file location). */
117
+ function resolveLogPath(isGlobal) {
118
+ if (isGlobal) {
119
+ return join(PREFS_DIR, 'worca-ui-global.log');
120
+ }
121
+ const projectRoot = findProjectRoot(process.cwd());
122
+ return join(projectRoot, '.worca', 'worca-ui.log');
123
+ }
124
+
125
+ /** Try to open a TCP connection. Resolves true if a peer accepts within timeoutMs. */
126
+ function canConnect(port, host, timeoutMs = 250) {
127
+ return new Promise((resolveProm) => {
128
+ let done = false;
129
+ const finish = (ok) => {
130
+ if (done) return;
131
+ done = true;
132
+ try {
133
+ sock.destroy();
134
+ } catch {
135
+ /* ignore */
136
+ }
137
+ resolveProm(ok);
138
+ };
139
+ const sock = connect({ port, host });
140
+ sock.once('connect', () => finish(true));
141
+ sock.once('error', () => finish(false));
142
+ setTimeout(() => finish(false), timeoutMs);
143
+ });
144
+ }
145
+
146
+ /**
147
+ * Wait for the spawned server to either start listening or die.
148
+ * Returns 'ready' (port open), 'died' (process exited), or 'timeout'.
149
+ */
150
+ async function waitForServerStart({ pid, port, host, timeoutMs = 5000 }) {
151
+ const deadline = Date.now() + timeoutMs;
152
+ while (Date.now() < deadline) {
153
+ if (!isRunning(pid)) return 'died';
154
+ if (await canConnect(port, host, 250)) return 'ready';
155
+ await new Promise((r) => setTimeout(r, 150));
156
+ }
157
+ return isRunning(pid) ? 'timeout' : 'died';
158
+ }
159
+
160
+ /** Read the last N lines of a log file. Returns '' if unreadable. */
161
+ function tailLogFile(logPath, maxLines = 60) {
162
+ try {
163
+ const content = readFileSync(logPath, 'utf8');
164
+ const lines = content.split('\n');
165
+ return lines.slice(-maxLines).join('\n').trimEnd();
166
+ } catch {
167
+ return '';
168
+ }
169
+ }
170
+
113
171
  /** Resolve PID file path and dir based on mode. */
114
172
  function resolvePidPaths(isGlobal) {
115
173
  if (isGlobal) {
@@ -234,13 +292,55 @@ async function start({ port, host, open, global: isGlobal }) {
234
292
  spawnArgs.push('--global');
235
293
  }
236
294
 
295
+ // Capture child stdout+stderr to a log file so startup crashes are visible.
296
+ // Without this, errors thrown during module load (missing files, bad imports,
297
+ // config errors, port binding races) silently disappear and the CLI cheerfully
298
+ // reports "started (PID …)" — see CLAUDE.md "missing-module crash" note.
299
+ const logPath = resolveLogPath(isGlobal);
300
+ let logFd = null;
301
+ try {
302
+ mkdirSync(dirname(logPath), { recursive: true });
303
+ logFd = openSync(logPath, 'w'); // truncate previous run's output
304
+ } catch (e) {
305
+ console.warn(
306
+ `Warning: could not open log file ${logPath}: ${e.message}\nStartup errors will not be captured.`,
307
+ );
308
+ }
309
+
237
310
  const child = spawn(process.execPath, spawnArgs, {
238
311
  detached: true,
239
- stdio: 'ignore',
312
+ stdio: logFd != null ? ['ignore', logFd, logFd] : 'ignore',
240
313
  cwd: process.cwd(),
241
314
  });
315
+ if (logFd != null) closeSync(logFd); // child holds its own dup
242
316
  child.unref();
243
317
 
318
+ const url = `http://${host}:${availablePort}`;
319
+ const state = await waitForServerStart({
320
+ pid: child.pid,
321
+ port: availablePort,
322
+ host,
323
+ timeoutMs: 5000,
324
+ });
325
+
326
+ if (state === 'died') {
327
+ const tail = tailLogFile(logPath);
328
+ console.error(
329
+ `\n worca-ui failed to start. The server process exited during startup.\n`,
330
+ );
331
+ if (tail) {
332
+ console.error(' Last log output:\n');
333
+ const indented = tail
334
+ .split('\n')
335
+ .map((l) => ` ${l}`)
336
+ .join('\n');
337
+ console.error(indented);
338
+ console.error('');
339
+ }
340
+ console.error(` Full log: ${logPath}`);
341
+ process.exit(1);
342
+ }
343
+
244
344
  const info = {
245
345
  pid: child.pid,
246
346
  port: availablePort,
@@ -248,12 +348,17 @@ async function start({ port, host, open, global: isGlobal }) {
248
348
  started_at: new Date().toISOString(),
249
349
  mode: isGlobal ? 'global' : 'per-project',
250
350
  projectPath: isGlobal ? null : findProjectRoot(process.cwd()),
351
+ logPath,
251
352
  };
252
353
  writePidFile(pidFile, pidDir, info);
253
- const url = `http://${host}:${availablePort}`;
254
354
  console.log(
255
355
  `worca-ui ${isGlobal ? '(global) ' : ''}started (PID ${child.pid}) at ${url}`,
256
356
  );
357
+ if (state === 'timeout') {
358
+ console.warn(
359
+ ` Note: server did not accept connections within 5s but is still running.\n Tail the log if it does not come up: ${logPath}`,
360
+ );
361
+ }
257
362
 
258
363
  // Hint: if global mode, empty projects.d/, and cwd has .worca/
259
364
  if (isGlobal) {
@@ -525,57 +630,66 @@ Options:
525
630
  -h, --help Show this help`);
526
631
  }
527
632
 
528
- const args = parseArgs(process.argv);
529
- switch (args.command) {
530
- case 'start':
531
- start(args);
532
- break;
533
- case 'stop':
534
- stop(args);
535
- break;
536
- case 'restart':
537
- restart(args);
538
- break;
539
- case 'status':
540
- status(args);
541
- break;
542
- case 'projects':
543
- switch (args.subAction) {
544
- case 'list':
545
- projectsList();
546
- break;
547
- case 'add':
548
- projectsAdd(args.projectPath, args.projectName);
549
- break;
550
- case 'remove':
551
- projectsRemove(args.projectPath);
552
- break;
553
- default:
554
- console.log('Usage: worca-ui projects [list|add|remove]');
555
- }
556
- break;
557
- case 'migrate':
558
- if (args.scanDir) {
559
- migrateScan(args.scanDir, args.dryRun);
560
- } else if (args.migrateAdd) {
561
- migrateAdd(args.migrateAdd);
562
- } else if (args.migrateStatus) {
563
- migrateStatus();
564
- } else {
565
- console.log(
566
- 'Usage:\n' +
567
- ' worca-ui migrate --scan <dir> [--dry-run]\n' +
568
- ' worca-ui migrate --add /path/to/project\n' +
569
- ' worca-ui migrate --status',
570
- );
571
- }
572
- break;
573
- case 'version':
574
- console.log(pkg.version);
575
- break;
576
- case 'help':
577
- printHelp();
578
- break;
579
- default:
580
- printHelp();
633
+ function main() {
634
+ const args = parseArgs(process.argv);
635
+ switch (args.command) {
636
+ case 'start':
637
+ start(args);
638
+ break;
639
+ case 'stop':
640
+ stop(args);
641
+ break;
642
+ case 'restart':
643
+ restart(args);
644
+ break;
645
+ case 'status':
646
+ status(args);
647
+ break;
648
+ case 'projects':
649
+ switch (args.subAction) {
650
+ case 'list':
651
+ projectsList();
652
+ break;
653
+ case 'add':
654
+ projectsAdd(args.projectPath, args.projectName);
655
+ break;
656
+ case 'remove':
657
+ projectsRemove(args.projectPath);
658
+ break;
659
+ default:
660
+ console.log('Usage: worca-ui projects [list|add|remove]');
661
+ }
662
+ break;
663
+ case 'migrate':
664
+ if (args.scanDir) {
665
+ migrateScan(args.scanDir, args.dryRun);
666
+ } else if (args.migrateAdd) {
667
+ migrateAdd(args.migrateAdd);
668
+ } else if (args.migrateStatus) {
669
+ migrateStatus();
670
+ } else {
671
+ console.log(
672
+ 'Usage:\n' +
673
+ ' worca-ui migrate --scan <dir> [--dry-run]\n' +
674
+ ' worca-ui migrate --add /path/to/project\n' +
675
+ ' worca-ui migrate --status',
676
+ );
677
+ }
678
+ break;
679
+ case 'version':
680
+ console.log(pkg.version);
681
+ break;
682
+ case 'help':
683
+ printHelp();
684
+ break;
685
+ default:
686
+ printHelp();
687
+ }
688
+ }
689
+
690
+ // Only run the CLI when this file is the entry point — not when imported
691
+ // by tests (which load the module to access exported helpers like parseArgs).
692
+ const entry = process.argv[1] ? realpathSync(process.argv[1]) : null;
693
+ if (entry === fileURLToPath(import.meta.url)) {
694
+ main();
581
695
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.14.0",
3
+ "version": "0.15.2",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -24,6 +24,7 @@
24
24
  "files": [
25
25
  "bin/worca-ui.js",
26
26
  "server/**/*.js",
27
+ "server/schemas/keys.json",
27
28
  "!server/**/*.test.js",
28
29
  "!server/test/**",
29
30
  "!server/**/test/**",
@@ -10,9 +10,11 @@ async function run() {
10
10
  const entry = path.join(appDir, 'main.js');
11
11
  const outfile = path.join(appDir, 'main.bundle.js');
12
12
  const vendorDir = path.join(appDir, 'vendor');
13
+ const serverSchemasDir = path.join(repoRoot, 'server', 'schemas');
13
14
 
14
15
  mkdirSync(appDir, { recursive: true });
15
16
  mkdirSync(vendorDir, { recursive: true });
17
+ mkdirSync(serverSchemasDir, { recursive: true });
16
18
 
17
19
  // Copy vendor CSS assets
18
20
  const vendorAssets = [
@@ -26,6 +28,19 @@ async function run() {
26
28
  console.log('copied', dest);
27
29
  }
28
30
 
31
+ // Copy shared schema(s) from the Python source tree so the published npm
32
+ // package is self-contained (it does not ship src/worca/).
33
+ const sharedSchemas = [
34
+ [
35
+ path.join(repoRoot, '..', 'src', 'worca', 'schemas', 'keys.json'),
36
+ path.join(serverSchemasDir, 'keys.json'),
37
+ ],
38
+ ];
39
+ for (const [src, dest] of sharedSchemas) {
40
+ copyFileSync(src, dest);
41
+ console.log('copied', path.relative(repoRoot, dest));
42
+ }
43
+
29
44
  try {
30
45
  const esbuild = await import('esbuild');
31
46
  await esbuild.build({
@@ -1,14 +1,25 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
5
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
- const schema = JSON.parse(
7
- readFileSync(
8
- resolve(__dirname, '../../src/worca/schemas/keys.json'),
9
- 'utf-8',
10
- ),
11
- );
6
+
7
+ // Look for keys.json in two places:
8
+ // 1. server/schemas/keys.json — the in-package copy populated by
9
+ // scripts/build-frontend.js. This is what ships in the npm tarball.
10
+ // 2. ../../src/worca/schemas/keys.json — the canonical Python source,
11
+ // reachable from a fresh monorepo checkout before `npm run build` has run.
12
+ const candidates = [
13
+ resolve(__dirname, './schemas/keys.json'),
14
+ resolve(__dirname, '../../src/worca/schemas/keys.json'),
15
+ ];
16
+ const schemaPath = candidates.find((p) => existsSync(p));
17
+ if (!schemaPath) {
18
+ throw new Error(
19
+ `worca-ui: keys.json not found. Run "npm run build" inside worca-ui/ before starting the server. Looked in:\n ${candidates.join('\n ')}`,
20
+ );
21
+ }
22
+ const schema = JSON.parse(readFileSync(schemaPath, 'utf-8'));
12
23
 
13
24
  export const GLOBAL_ONLY_KEYS = schema.global_only_keys;
14
25
  export const NORMALIZE_SKIP_KEYS = schema.normalize_skip_keys;
@@ -371,7 +371,21 @@ export class ProcessManager {
371
371
  * @returns {Promise<{ pid: number }>}
372
372
  */
373
373
  async startPipeline(opts = {}) {
374
- const cwd = opts.projectRoot || this.projectRoot;
374
+ // Resume must spawn inside the run's own working tree. Worktree-hosted
375
+ // runs live under <worktree>/.worca/runs/<id>; if we spawn from the parent
376
+ // project root, git operations and relative settings paths target the
377
+ // wrong tree and the resumed pipeline corrupts state on the parent
378
+ // branch. Worktree wins over opts.projectRoot for resume — callers
379
+ // routinely pass proj.projectRoot without knowing whether the run is
380
+ // worktree-hosted. Mirrors the cwd derivation in restartStage.
381
+ let resumeCtx = null;
382
+ if (opts.resume && opts.runId) {
383
+ resumeCtx = this.resolveRunContext(opts.runId);
384
+ }
385
+ const cwd =
386
+ resumeCtx && resumeCtx.worcaDir !== this.worcaDir
387
+ ? join(resumeCtx.worcaDir, '..')
388
+ : opts.projectRoot || this.projectRoot;
375
389
  const pipelineScriptRel = '.claude/worca/scripts/run_pipeline.py';
376
390
  const worktreeScriptRel = '.claude/worca/scripts/run_worktree.py';
377
391
 
@@ -408,9 +422,8 @@ export class ProcessManager {
408
422
  if (opts.resume) {
409
423
  args.push('--resume');
410
424
  if (opts.runId) {
411
- const ctx = this.resolveRunContext(opts.runId);
412
- const statusDir = ctx
413
- ? ctx.runDir
425
+ const statusDir = resumeCtx
426
+ ? resumeCtx.runDir
414
427
  : join(this.worcaDir, 'runs', opts.runId);
415
428
  args.push('--status-dir', statusDir);
416
429
  }
@@ -71,7 +71,7 @@ function validateRunId(runId) {
71
71
  // Re-exported from run-dir-resolver so callers (including older tests) can
72
72
  // continue importing from project-routes. The implementation now overlays
73
73
  // worktree runs registered in <worcaDir>/multi/pipelines.d/.
74
- import { findRunStatusPath } from './run-dir-resolver.js';
74
+ import { findRunStatusPath, readPipelineOverlay } from './run-dir-resolver.js';
75
75
  export { findRunStatusPath };
76
76
 
77
77
  /** Validate a branch name — alphanumeric, dots, hyphens, underscores, slashes */
@@ -686,8 +686,14 @@ export function createProjectScopedRoutes({
686
686
  }
687
687
  try {
688
688
  let status = JSON.parse(readFileSync(statusPath, 'utf8'));
689
- // Reconcile stale "running" status when no process is alive
690
- if (status.pipeline_status === 'running' && pm && !pm.getRunningPid()) {
689
+ // Reconcile stale "running" status when no process is alive.
690
+ // Pass runId so worktree-hosted pipelines (PID lives under
691
+ // <worktree>/.worca/runs/<id>/pipeline.pid) are detected correctly.
692
+ if (
693
+ status.pipeline_status === 'running' &&
694
+ pm &&
695
+ !pm.getRunningPid(runId)
696
+ ) {
691
697
  try {
692
698
  pm.reconcileStatus();
693
699
  status = JSON.parse(readFileSync(statusPath, 'utf8'));
@@ -1337,7 +1343,7 @@ export function createProjectScopedRoutes({
1337
1343
  .json({ ok: false, error: `Run "${runId}" not found` });
1338
1344
  }
1339
1345
 
1340
- const running = req.project.pm.getRunningPid();
1346
+ const running = req.project.pm.getRunningPid(runId);
1341
1347
  if (running) {
1342
1348
  return res.status(409).json({
1343
1349
  ok: false,
@@ -1366,7 +1372,11 @@ export function createProjectScopedRoutes({
1366
1372
  }
1367
1373
  }
1368
1374
 
1369
- const cwd = projectRoot || process.cwd();
1375
+ // Worktree-hosted runs live outside the parent project. Spawn the learner
1376
+ // inside the worktree so its default --status-dir=.worca and any git
1377
+ // operations land on the right tree, mirroring run_pipeline.py resume.
1378
+ const overlay = readPipelineOverlay(worcaDir, runId);
1379
+ const cwd = overlay?.worktree_path || projectRoot || process.cwd();
1370
1380
  const env = { ...process.env };
1371
1381
  delete env.CLAUDECODE;
1372
1382
 
@@ -0,0 +1,39 @@
1
+ {
2
+ "global_only_keys": [
3
+ ["parallel", "cleanup_policy"],
4
+ ["parallel", "max_concurrent_pipelines"],
5
+ ["ui", "worktree_disk_warning_bytes"],
6
+ ["circuit_breaker", "classifier_model"]
7
+ ],
8
+ "normalize_skip_keys": [
9
+ ["milestones", "pr_approval"]
10
+ ],
11
+ "defaults": {
12
+ "global": {
13
+ "parallel": {
14
+ "cleanup_policy": "never",
15
+ "max_concurrent_pipelines": 10
16
+ },
17
+ "ui": {
18
+ "worktree_disk_warning_bytes": 2000000000
19
+ },
20
+ "circuit_breaker": {
21
+ "classifier_model": "haiku"
22
+ }
23
+ },
24
+ "project": {
25
+ "parallel": {
26
+ "worktree_base_dir": ".worktrees",
27
+ "default_base_branch": "main"
28
+ },
29
+ "circuit_breaker": {
30
+ "enabled": true,
31
+ "max_consecutive_failures": 3
32
+ },
33
+ "milestones": {
34
+ "plan_approval": true,
35
+ "pr_approval": false
36
+ }
37
+ }
38
+ }
39
+ }