@worca/ui 0.12.0 → 0.14.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.
- package/app/main.bundle.js +1192 -940
- package/app/main.bundle.js.map +4 -4
- package/app/protocol.js +1 -7
- package/app/styles.css +67 -1
- package/package.json +2 -1
- 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 +16 -0
- package/server/launch-lock.js +25 -0
- package/server/preferences-routes.js +143 -0
- package/server/process-manager.js +208 -88
- package/server/process-registry.js +92 -0
- package/server/project-routes.js +226 -230
- package/server/run-dir-resolver.js +79 -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 +11 -54
- package/server/watcher.js +112 -43
- package/server/worktree-ops.js +72 -0
- package/server/worktrees-routes.js +272 -0
- package/server/ws-log-watcher.js +36 -27
- package/server/ws-message-router.js +76 -115
- package/server/ws-modular.js +11 -2
- package/server/ws-status-watcher.js +187 -23
- package/server/ws.js +1 -1
- package/server/multi-watcher.js +0 -237
package/app/protocol.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Protocol definitions for worca-ui WebSocket communication.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
/** @typedef {'subscribe-run'|'unsubscribe-run'|'subscribe-log'|'unsubscribe-log'|'list-runs'|'get-agent-prompt'|'get-preferences'|'set-preferences'|'stop-run'|'resume-run'|'list-beads-issues'|'start-beads-issue'|'list-beads-counts'|'list-beads-refs'|'list-beads-unlinked'|'run-snapshot'|'run-update'|'runs-list'|'log-line'|'log-bulk'|'preferences'|'run-started'|'run-stopped'|'stage-restarted'|'beads-update'} MessageType */
|
|
5
|
+
/** @typedef {'subscribe-run'|'unsubscribe-run'|'subscribe-log'|'unsubscribe-log'|'list-runs'|'get-agent-prompt'|'get-preferences'|'set-preferences'|'stop-run'|'resume-run'|'list-beads-issues'|'start-beads-issue'|'list-beads-counts'|'list-beads-refs'|'list-beads-unlinked'|'run-snapshot'|'run-update'|'runs-list'|'log-line'|'log-bulk'|'preferences'|'run-started'|'run-stopped'|'stage-restarted'|'beads-update'|'hello'|'hello-ack'} MessageType */
|
|
6
6
|
|
|
7
7
|
/** @type {MessageType[]} */
|
|
8
8
|
export const MESSAGE_TYPES = [
|
|
@@ -29,12 +29,6 @@ export const MESSAGE_TYPES = [
|
|
|
29
29
|
// Protocol handshake
|
|
30
30
|
'hello',
|
|
31
31
|
'hello-ack',
|
|
32
|
-
// Parallel pipelines
|
|
33
|
-
'list-pipelines',
|
|
34
|
-
'subscribe-pipeline',
|
|
35
|
-
'unsubscribe-pipeline',
|
|
36
|
-
'pipeline-status-changed',
|
|
37
|
-
'pipelines-list',
|
|
38
32
|
// Server → Client events
|
|
39
33
|
'run-snapshot',
|
|
40
34
|
'run-update',
|
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
|
|
|
@@ -4525,3 +4533,61 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
4525
4533
|
padding: 2rem;
|
|
4526
4534
|
}
|
|
4527
4535
|
|
|
4536
|
+
|
|
4537
|
+
/* --- Worktrees view --- */
|
|
4538
|
+
.worktrees-view {
|
|
4539
|
+
display: flex;
|
|
4540
|
+
flex-direction: column;
|
|
4541
|
+
gap: 12px;
|
|
4542
|
+
}
|
|
4543
|
+
.worktrees-summary {
|
|
4544
|
+
display: flex;
|
|
4545
|
+
flex-wrap: wrap;
|
|
4546
|
+
gap: 4px 6px;
|
|
4547
|
+
align-items: baseline;
|
|
4548
|
+
padding: 6px 0 2px;
|
|
4549
|
+
font-size: 12px;
|
|
4550
|
+
color: var(--muted);
|
|
4551
|
+
font-variant-numeric: tabular-nums;
|
|
4552
|
+
}
|
|
4553
|
+
.worktrees-summary .meta-sep {
|
|
4554
|
+
color: var(--border-subtle);
|
|
4555
|
+
margin: 0 2px;
|
|
4556
|
+
}
|
|
4557
|
+
.worktrees-disk-alert {
|
|
4558
|
+
margin-bottom: 4px;
|
|
4559
|
+
}
|
|
4560
|
+
.worktrees-toolbar {
|
|
4561
|
+
display: flex;
|
|
4562
|
+
align-items: center;
|
|
4563
|
+
gap: 12px;
|
|
4564
|
+
flex-wrap: wrap;
|
|
4565
|
+
}
|
|
4566
|
+
.worktrees-toolbar .worktrees-filter {
|
|
4567
|
+
flex: 1;
|
|
4568
|
+
min-width: 240px;
|
|
4569
|
+
}
|
|
4570
|
+
.worktree-card-path {
|
|
4571
|
+
align-items: baseline;
|
|
4572
|
+
}
|
|
4573
|
+
.worktree-path-mono {
|
|
4574
|
+
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
4575
|
+
font-size: 11px;
|
|
4576
|
+
color: var(--muted);
|
|
4577
|
+
word-break: break-all;
|
|
4578
|
+
}
|
|
4579
|
+
.worktrees-bulk-groups {
|
|
4580
|
+
margin: 8px 0 0;
|
|
4581
|
+
padding-left: 18px;
|
|
4582
|
+
font-size: 13px;
|
|
4583
|
+
color: var(--muted);
|
|
4584
|
+
}
|
|
4585
|
+
.worktrees-bulk-groups li {
|
|
4586
|
+
margin-bottom: 2px;
|
|
4587
|
+
}
|
|
4588
|
+
.dialog-actions {
|
|
4589
|
+
display: flex;
|
|
4590
|
+
gap: 8px;
|
|
4591
|
+
justify-content: flex-end;
|
|
4592
|
+
width: 100%;
|
|
4593
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Pipeline monitoring UI for worca-cc",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sinisha Djukic",
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"lit-html": "^3.3.1",
|
|
64
64
|
"lucide": "^0.577.0",
|
|
65
65
|
"marked": "^17.0.1",
|
|
66
|
+
"proper-lockfile": "^4.1.2",
|
|
66
67
|
"ws": "^8.18.3"
|
|
67
68
|
},
|
|
68
69
|
"devDependencies": {
|
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,16 @@
|
|
|
1
|
+
import { 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
|
+
const schema = JSON.parse(
|
|
7
|
+
readFileSync(
|
|
8
|
+
resolve(__dirname, '../../src/worca/schemas/keys.json'),
|
|
9
|
+
'utf-8',
|
|
10
|
+
),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
export const GLOBAL_ONLY_KEYS = schema.global_only_keys;
|
|
14
|
+
export const NORMALIZE_SKIP_KEYS = schema.normalize_skip_keys;
|
|
15
|
+
export const GLOBAL_DEFAULTS = schema.defaults.global;
|
|
16
|
+
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
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { readGlobalSettings, writeGlobalSettings } from './settings-reader.js';
|
|
4
|
+
|
|
5
|
+
const VALID_CLEANUP_POLICIES = ['never', 'on-success', 'manual-only'];
|
|
6
|
+
const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
|
|
7
|
+
const MIN_DISK_BYTES = 500_000_000;
|
|
8
|
+
const MAX_DISK_BYTES = 50_000_000_000;
|
|
9
|
+
|
|
10
|
+
export function validateGlobalSettingsPayload(body) {
|
|
11
|
+
const details = [];
|
|
12
|
+
|
|
13
|
+
if (body.worca !== undefined) {
|
|
14
|
+
if (
|
|
15
|
+
typeof body.worca !== 'object' ||
|
|
16
|
+
body.worca === null ||
|
|
17
|
+
Array.isArray(body.worca)
|
|
18
|
+
) {
|
|
19
|
+
details.push('worca must be an object');
|
|
20
|
+
return { valid: false, details };
|
|
21
|
+
}
|
|
22
|
+
const w = body.worca;
|
|
23
|
+
|
|
24
|
+
if (w.parallel !== undefined) {
|
|
25
|
+
if (
|
|
26
|
+
typeof w.parallel !== 'object' ||
|
|
27
|
+
w.parallel === null ||
|
|
28
|
+
Array.isArray(w.parallel)
|
|
29
|
+
) {
|
|
30
|
+
details.push('worca.parallel must be an object');
|
|
31
|
+
} else {
|
|
32
|
+
if (w.parallel.max_concurrent_pipelines !== undefined) {
|
|
33
|
+
const v = w.parallel.max_concurrent_pipelines;
|
|
34
|
+
if (!Number.isInteger(v) || v < 1 || v > 100) {
|
|
35
|
+
details.push(
|
|
36
|
+
'max_concurrent_pipelines must be an integer between 1 and 100',
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (w.parallel.cleanup_policy !== undefined) {
|
|
41
|
+
if (!VALID_CLEANUP_POLICIES.includes(w.parallel.cleanup_policy)) {
|
|
42
|
+
details.push(
|
|
43
|
+
`cleanup_policy must be one of: ${VALID_CLEANUP_POLICIES.join(', ')}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (w.ui !== undefined) {
|
|
51
|
+
if (typeof w.ui !== 'object' || w.ui === null || Array.isArray(w.ui)) {
|
|
52
|
+
details.push('worca.ui must be an object');
|
|
53
|
+
} else if (w.ui.worktree_disk_warning_bytes !== undefined) {
|
|
54
|
+
const v = w.ui.worktree_disk_warning_bytes;
|
|
55
|
+
if (
|
|
56
|
+
typeof v !== 'number' ||
|
|
57
|
+
!Number.isFinite(v) ||
|
|
58
|
+
v < MIN_DISK_BYTES ||
|
|
59
|
+
v > MAX_DISK_BYTES
|
|
60
|
+
) {
|
|
61
|
+
details.push(
|
|
62
|
+
`worktree_disk_warning_bytes must be a number between ${MIN_DISK_BYTES} and ${MAX_DISK_BYTES}`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (w.circuit_breaker !== undefined) {
|
|
69
|
+
if (
|
|
70
|
+
typeof w.circuit_breaker !== 'object' ||
|
|
71
|
+
w.circuit_breaker === null ||
|
|
72
|
+
Array.isArray(w.circuit_breaker)
|
|
73
|
+
) {
|
|
74
|
+
details.push('worca.circuit_breaker must be an object');
|
|
75
|
+
} else if (w.circuit_breaker.classifier_model !== undefined) {
|
|
76
|
+
if (!VALID_MODELS.includes(w.circuit_breaker.classifier_model)) {
|
|
77
|
+
details.push(
|
|
78
|
+
`classifier_model must be one of: ${VALID_MODELS.join(', ')}`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return details.length ? { valid: false, details } : { valid: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createPreferencesRouter({ prefsDir }) {
|
|
89
|
+
const router = Router();
|
|
90
|
+
const globalSettingsPath = join(prefsDir, 'settings.json');
|
|
91
|
+
|
|
92
|
+
router.get('/', (_req, res) => {
|
|
93
|
+
try {
|
|
94
|
+
const prefs = readGlobalSettings(globalSettingsPath);
|
|
95
|
+
res.json({ ok: true, preferences: prefs });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
res.status(500).json({
|
|
98
|
+
ok: false,
|
|
99
|
+
error: 'Failed to read global preferences',
|
|
100
|
+
detail: err.message,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
router.put('/', (req, res) => {
|
|
106
|
+
const body = req.body;
|
|
107
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
108
|
+
return res.status(400).json({
|
|
109
|
+
ok: false,
|
|
110
|
+
error: {
|
|
111
|
+
code: 'validation_error',
|
|
112
|
+
message: 'Request body must be a JSON object',
|
|
113
|
+
details: [],
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const validation = validateGlobalSettingsPayload(body);
|
|
119
|
+
if (!validation.valid) {
|
|
120
|
+
return res.status(400).json({
|
|
121
|
+
ok: false,
|
|
122
|
+
error: {
|
|
123
|
+
code: 'validation_error',
|
|
124
|
+
message: 'Invalid preferences payload',
|
|
125
|
+
details: validation.details,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const merged = writeGlobalSettings(globalSettingsPath, body);
|
|
132
|
+
res.json({ ok: true, preferences: merged });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
res.status(500).json({
|
|
135
|
+
ok: false,
|
|
136
|
+
error: 'Failed to write global preferences',
|
|
137
|
+
detail: err.message,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return router;
|
|
143
|
+
}
|