@worca/ui 0.13.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/app/main.bundle.js +836 -716
- package/app/main.bundle.js.map +3 -3
- package/app/styles.css +9 -1
- package/bin/worca-ui.js +170 -56
- package/package.json +3 -1
- package/scripts/build-frontend.js +15 -0
- package/server/app.js +24 -2
- package/server/atomic-write.js +18 -0
- package/server/beads-reader.js +29 -4
- package/server/global-keys.js +49 -0
- package/server/keys-schema.js +27 -0
- package/server/launch-lock.js +25 -0
- package/server/preferences-routes.js +143 -0
- package/server/process-manager.js +90 -9
- package/server/process-registry.js +92 -0
- package/server/project-routes.js +222 -142
- package/server/run-dir-resolver.js +79 -0
- package/server/schemas/keys.json +39 -0
- package/server/settings-reader.js +31 -1
- package/server/settings-validator.js +112 -1
- package/server/status-routes.js +23 -0
- package/server/watcher-set.js +8 -10
- package/server/worktree-ops.js +72 -0
- package/server/worktrees-routes.js +3 -80
- package/server/ws-log-watcher.js +33 -24
- package/server/ws-message-router.js +76 -65
package/app/styles.css
CHANGED
|
@@ -1039,9 +1039,17 @@ sl-details.log-history-panel::part(content) {
|
|
|
1039
1039
|
min-width: 140px;
|
|
1040
1040
|
}
|
|
1041
1041
|
|
|
1042
|
-
|
|
1042
|
+
/* Prefix-slot icon centering. Inline SVGs default to baseline alignment,
|
|
1043
|
+
which sits them at the top of an sl-input. Flex-center the slot wrapper
|
|
1044
|
+
so any iconSvg(...) we drop into a prefix lines up with the placeholder
|
|
1045
|
+
/ value text. Generic rule covers every sl-input; .log-controls keeps
|
|
1046
|
+
the extra left padding it always had. */
|
|
1047
|
+
sl-input [slot="prefix"] {
|
|
1043
1048
|
display: flex;
|
|
1044
1049
|
align-items: center;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
.log-controls sl-input [slot="prefix"] {
|
|
1045
1053
|
padding-left: 4px;
|
|
1046
1054
|
}
|
|
1047
1055
|
|
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/**",
|
|
@@ -63,6 +64,7 @@
|
|
|
63
64
|
"lit-html": "^3.3.1",
|
|
64
65
|
"lucide": "^0.577.0",
|
|
65
66
|
"marked": "^17.0.1",
|
|
67
|
+
"proper-lockfile": "^4.1.2",
|
|
66
68
|
"ws": "^8.18.3"
|
|
67
69
|
},
|
|
68
70
|
"devDependencies": {
|
|
@@ -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/app.js
CHANGED
|
@@ -11,6 +11,8 @@ import express from 'express';
|
|
|
11
11
|
import { dbExists, getIssue, listIssues } from './beads-reader.js';
|
|
12
12
|
import { RAW_BODY } from './integrations/index.js';
|
|
13
13
|
import { verify } from './integrations/verify.js';
|
|
14
|
+
import { LaunchLock } from './launch-lock.js';
|
|
15
|
+
import { createPreferencesRouter } from './preferences-routes.js';
|
|
14
16
|
import { ProcessManager } from './process-manager.js';
|
|
15
17
|
import { scanDirectory } from './project-registry.js';
|
|
16
18
|
import {
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
projectResolver,
|
|
20
22
|
} from './project-routes.js';
|
|
21
23
|
import { validateIntegrationsConfig } from './settings-validator.js';
|
|
24
|
+
import { createStatusRouter } from './status-routes.js';
|
|
22
25
|
import { discoverSubagents } from './subagents-discovery.js';
|
|
23
26
|
import { checkWorcaVersion } from './version-check.js';
|
|
24
27
|
import { getVersionInfo } from './versions.js';
|
|
@@ -89,6 +92,13 @@ export function createApp(options = {}) {
|
|
|
89
92
|
const webhookInbox = options.webhookInbox || createInbox();
|
|
90
93
|
app.locals.webhookInbox = webhookInbox;
|
|
91
94
|
|
|
95
|
+
// Single LaunchLock instance shared across BOTH legacy /api and
|
|
96
|
+
// /api/projects/:id mounts so the global max_concurrent_pipelines cap is
|
|
97
|
+
// enforced atomically across all entry points. Without this, two routers
|
|
98
|
+
// each held their own mutex and concurrent launches via /api/runs +
|
|
99
|
+
// /api/projects/:id/runs could both pass the cap check and start.
|
|
100
|
+
const launchLock = new LaunchLock();
|
|
101
|
+
|
|
92
102
|
// ─── Legacy single-project API ─────────────────────────────────────────
|
|
93
103
|
// Mounts the shared project-scoped routes at /api with a middleware that
|
|
94
104
|
// injects req.project from the closure options, so /api/runs, /api/settings,
|
|
@@ -112,7 +122,12 @@ export function createApp(options = {}) {
|
|
|
112
122
|
};
|
|
113
123
|
next();
|
|
114
124
|
},
|
|
115
|
-
createProjectScopedRoutes({
|
|
125
|
+
createProjectScopedRoutes({
|
|
126
|
+
prefsDir,
|
|
127
|
+
serverHost,
|
|
128
|
+
serverPort,
|
|
129
|
+
launchLock,
|
|
130
|
+
}),
|
|
116
131
|
);
|
|
117
132
|
|
|
118
133
|
// ─── Unique routes (not in project-scoped router) ──────────────────────
|
|
@@ -519,6 +534,8 @@ export function createApp(options = {}) {
|
|
|
519
534
|
|
|
520
535
|
// ─── Multi-project routes ──────────────────────────────────────────────
|
|
521
536
|
if (prefsDir) {
|
|
537
|
+
app.use('/api/preferences', createPreferencesRouter({ prefsDir }));
|
|
538
|
+
app.use('/api/status', createStatusRouter({ prefsDir }));
|
|
522
539
|
app.use(
|
|
523
540
|
'/api/projects',
|
|
524
541
|
createProjectRoutes({ prefsDir, projectRoot, serverHost, serverPort }),
|
|
@@ -526,7 +543,12 @@ export function createApp(options = {}) {
|
|
|
526
543
|
app.use(
|
|
527
544
|
'/api/projects/:projectId',
|
|
528
545
|
projectResolver({ prefsDir, projectRoot }),
|
|
529
|
-
createProjectScopedRoutes({
|
|
546
|
+
createProjectScopedRoutes({
|
|
547
|
+
prefsDir,
|
|
548
|
+
serverHost,
|
|
549
|
+
serverPort,
|
|
550
|
+
launchLock,
|
|
551
|
+
}),
|
|
530
552
|
);
|
|
531
553
|
}
|
|
532
554
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file write: write to a temp file then rename into place.
|
|
3
|
+
* Prevents partial reads when a reader opens the file mid-write.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { mkdirSync, renameSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
export function atomicWriteSync(filePath, data, options = {}) {
|
|
10
|
+
const dir = dirname(filePath);
|
|
11
|
+
mkdirSync(dir, { recursive: true });
|
|
12
|
+
const tmp = join(
|
|
13
|
+
dir,
|
|
14
|
+
`.${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`,
|
|
15
|
+
);
|
|
16
|
+
writeFileSync(tmp, data, options);
|
|
17
|
+
renameSync(tmp, filePath);
|
|
18
|
+
}
|
package/server/beads-reader.js
CHANGED
|
@@ -89,15 +89,40 @@ export async function listUnlinkedIssues(beadsDb) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Returns { runId: { total, done } } for every run:<id> label in the beads db.
|
|
94
|
+
*
|
|
95
|
+
* `total` comes from the cheap `bd label list-all` count. `done` requires
|
|
96
|
+
* looking at issue status, so we query `bd list --label-any run:<id>` per
|
|
97
|
+
* run and count statuses === "closed". N+1 queries, but N is bounded by
|
|
98
|
+
* the number of pipeline runs and this endpoint is called on app load /
|
|
99
|
+
* project switch only, not on every render.
|
|
100
|
+
*/
|
|
92
101
|
export async function countIssuesByRunLabel(beadsDb) {
|
|
93
102
|
try {
|
|
94
103
|
const rows = await runBd(['label', 'list-all'], beadsDb);
|
|
95
104
|
const counts = {};
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}
|
|
105
|
+
const runLabels = rows.filter((r) => r.label.startsWith('run:'));
|
|
106
|
+
for (const row of runLabels) {
|
|
107
|
+
counts[row.label.replace('run:', '')] = { total: row.count, done: 0 };
|
|
100
108
|
}
|
|
109
|
+
// Count closed issues per label in parallel.
|
|
110
|
+
await Promise.all(
|
|
111
|
+
runLabels.map(async (row) => {
|
|
112
|
+
const runId = row.label.replace('run:', '');
|
|
113
|
+
try {
|
|
114
|
+
const issues = await runBd(
|
|
115
|
+
['list', '--label-any', row.label, '--all', '--limit', '0'],
|
|
116
|
+
beadsDb,
|
|
117
|
+
);
|
|
118
|
+
counts[runId].done = issues.filter(
|
|
119
|
+
(i) => i.status === 'closed',
|
|
120
|
+
).length;
|
|
121
|
+
} catch {
|
|
122
|
+
/* leave done at 0 on per-run failure */
|
|
123
|
+
}
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
101
126
|
return counts;
|
|
102
127
|
} catch {
|
|
103
128
|
return {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { GLOBAL_ONLY_KEYS } from './keys-schema.js';
|
|
2
|
+
|
|
3
|
+
const INERT_MILESTONE_KEYS = ['pr_approval', 'deploy_approval'];
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Mutates `blob` in place: extracts misplaced global-only keys and strips
|
|
7
|
+
* inert milestone keys (pr_approval/deploy_approval when set to `true`).
|
|
8
|
+
*
|
|
9
|
+
* Returns { globalExtracted, removedMilestones } for the caller to merge
|
|
10
|
+
* into ~/.worca/settings.json and to surface in the response.
|
|
11
|
+
*/
|
|
12
|
+
export function extractAndStripGlobalKeys(blob) {
|
|
13
|
+
const globalExtracted = {};
|
|
14
|
+
const removedMilestones = [];
|
|
15
|
+
|
|
16
|
+
const worca = blob.worca;
|
|
17
|
+
if (!worca || typeof worca !== 'object') {
|
|
18
|
+
return { globalExtracted, removedMilestones };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const [section, key] of GLOBAL_ONLY_KEYS) {
|
|
22
|
+
const sectionObj = worca[section];
|
|
23
|
+
if (!sectionObj || typeof sectionObj !== 'object') continue;
|
|
24
|
+
if (!(key in sectionObj)) continue;
|
|
25
|
+
|
|
26
|
+
if (!globalExtracted[section]) globalExtracted[section] = {};
|
|
27
|
+
globalExtracted[section][key] = sectionObj[key];
|
|
28
|
+
delete sectionObj[key];
|
|
29
|
+
|
|
30
|
+
if (Object.keys(sectionObj).length === 0) {
|
|
31
|
+
delete worca[section];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const milestones = worca.milestones;
|
|
36
|
+
if (milestones && typeof milestones === 'object') {
|
|
37
|
+
for (const key of INERT_MILESTONE_KEYS) {
|
|
38
|
+
if (milestones[key] === true) {
|
|
39
|
+
delete milestones[key];
|
|
40
|
+
removedMilestones.push(key);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (Object.keys(milestones).length === 0) {
|
|
44
|
+
delete worca.milestones;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { globalExtracted, removedMilestones };
|
|
49
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
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'));
|
|
23
|
+
|
|
24
|
+
export const GLOBAL_ONLY_KEYS = schema.global_only_keys;
|
|
25
|
+
export const NORMALIZE_SKIP_KEYS = schema.normalize_skip_keys;
|
|
26
|
+
export const GLOBAL_DEFAULTS = schema.defaults.global;
|
|
27
|
+
export const PROJECT_DEFAULTS = schema.defaults.project;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process async mutex using a promise chain.
|
|
3
|
+
* Used to serialize pipeline launches so max_concurrent_pipelines is enforced atomically.
|
|
4
|
+
*/
|
|
5
|
+
export class LaunchLock {
|
|
6
|
+
#tail = Promise.resolve();
|
|
7
|
+
|
|
8
|
+
acquire() {
|
|
9
|
+
let release;
|
|
10
|
+
const prev = this.#tail;
|
|
11
|
+
this.#tail = new Promise((resolve) => {
|
|
12
|
+
release = resolve;
|
|
13
|
+
});
|
|
14
|
+
return prev.then(() => release);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async withLock(fn) {
|
|
18
|
+
const release = await this.acquire();
|
|
19
|
+
try {
|
|
20
|
+
return await fn();
|
|
21
|
+
} finally {
|
|
22
|
+
release();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|