@worca/ui 0.14.0 → 0.15.1
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 +170 -56
- package/package.json +2 -1
- package/scripts/build-frontend.js +15 -0
- package/server/keys-schema.js +18 -7
- package/server/schemas/keys.json +39 -0
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
start
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
stop
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
restart
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
status
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
migrateStatus
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
'
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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.
|
|
3
|
+
"version": "0.15.1",
|
|
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({
|
package/server/keys-schema.js
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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;
|
|
@@ -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
|
+
}
|