@worca/ui 0.19.0 → 0.21.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 +1215 -970
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +180 -23
- package/app/utils/stage-order.js +2 -0
- package/package.json +2 -1
- package/server/bd-daemon.js +43 -0
- package/server/beads-reader.js +37 -23
- package/server/model-env-routes.js +189 -0
- package/server/model-validation.js +13 -0
- package/server/preferences-routes.js +4 -3
- package/server/project-routes.js +5 -0
- package/server/reserved-env-keys.json +19 -0
- package/server/settings-validator.js +12 -6
- package/server/watcher-set.js +2 -0
- package/server/worktree-ops.js +49 -22
- package/server/worktrees-routes.js +237 -28
- package/server/ws-beads-watcher.js +6 -2
package/app/styles.css
CHANGED
|
@@ -3436,29 +3436,27 @@ sl-details.learnings-panel::part(content) {
|
|
|
3436
3436
|
align-items: center;
|
|
3437
3437
|
}
|
|
3438
3438
|
|
|
3439
|
-
.pr-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
.pr-details-table {
|
|
3444
|
-
border-collapse: collapse;
|
|
3439
|
+
.pr-info-strip {
|
|
3440
|
+
display: flex;
|
|
3441
|
+
flex-wrap: wrap;
|
|
3442
|
+
gap: 4px 20px;
|
|
3445
3443
|
font-size: 13px;
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
vertical-align: middle;
|
|
3444
|
+
color: var(--muted);
|
|
3445
|
+
margin-top: 8px;
|
|
3446
|
+
padding-top: 8px;
|
|
3447
|
+
border-top: 1px solid var(--border-subtle);
|
|
3448
|
+
align-items: center;
|
|
3452
3449
|
}
|
|
3453
3450
|
|
|
3454
|
-
.pr-
|
|
3451
|
+
.pr-info-item {
|
|
3452
|
+
display: inline-flex;
|
|
3453
|
+
align-items: center;
|
|
3454
|
+
gap: 4px;
|
|
3455
3455
|
white-space: nowrap;
|
|
3456
|
-
width: 1%;
|
|
3457
|
-
padding-right: 10px;
|
|
3458
3456
|
}
|
|
3459
3457
|
|
|
3460
|
-
.pr-
|
|
3461
|
-
display: flex;
|
|
3458
|
+
.pr-info-strip .run-pr-link {
|
|
3459
|
+
display: inline-flex;
|
|
3462
3460
|
align-items: center;
|
|
3463
3461
|
gap: 4px;
|
|
3464
3462
|
}
|
|
@@ -3472,13 +3470,8 @@ sl-details.learnings-panel::part(content) {
|
|
|
3472
3470
|
border: 1px solid var(--border-subtle);
|
|
3473
3471
|
}
|
|
3474
3472
|
|
|
3475
|
-
.pr-branch-flow {
|
|
3476
|
-
font-family: monospace;
|
|
3477
|
-
font-size: 12px;
|
|
3478
|
-
}
|
|
3479
|
-
|
|
3480
3473
|
.pr-title-badge {
|
|
3481
|
-
|
|
3474
|
+
/* spacing handled by parent .pipeline-stage-header gap */
|
|
3482
3475
|
}
|
|
3483
3476
|
|
|
3484
3477
|
.classification-strip {
|
|
@@ -4657,3 +4650,167 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
4657
4650
|
justify-content: flex-end;
|
|
4658
4651
|
width: 100%;
|
|
4659
4652
|
}
|
|
4653
|
+
|
|
4654
|
+
/* ─── Models tab — model cards + env rows ──────────────────────────── */
|
|
4655
|
+
.models-cards {
|
|
4656
|
+
grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
|
|
4657
|
+
}
|
|
4658
|
+
|
|
4659
|
+
.model-card {
|
|
4660
|
+
display: flex;
|
|
4661
|
+
flex-direction: column;
|
|
4662
|
+
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
|
4663
|
+
}
|
|
4664
|
+
|
|
4665
|
+
.model-card.is-dirty {
|
|
4666
|
+
border-color: var(--status-running, #3b82f6);
|
|
4667
|
+
box-shadow: 0 0 0 1px var(--status-running, #3b82f6);
|
|
4668
|
+
}
|
|
4669
|
+
|
|
4670
|
+
.model-delete-btn {
|
|
4671
|
+
/* Aligned left in the footer action row via the flex layout (the status
|
|
4672
|
+
span between it and Discard/Save has flex:1 and grows). */
|
|
4673
|
+
}
|
|
4674
|
+
|
|
4675
|
+
.model-id-input::part(input) {
|
|
4676
|
+
font-family: var(--sl-font-mono);
|
|
4677
|
+
font-size: 12px;
|
|
4678
|
+
}
|
|
4679
|
+
|
|
4680
|
+
.settings-label-row {
|
|
4681
|
+
display: flex;
|
|
4682
|
+
align-items: baseline;
|
|
4683
|
+
justify-content: space-between;
|
|
4684
|
+
gap: 8px;
|
|
4685
|
+
}
|
|
4686
|
+
|
|
4687
|
+
.settings-muted-small {
|
|
4688
|
+
font-size: 11px;
|
|
4689
|
+
color: var(--muted);
|
|
4690
|
+
font-weight: 500;
|
|
4691
|
+
text-transform: uppercase;
|
|
4692
|
+
letter-spacing: 0.03em;
|
|
4693
|
+
}
|
|
4694
|
+
|
|
4695
|
+
.model-env-table {
|
|
4696
|
+
display: flex;
|
|
4697
|
+
flex-direction: column;
|
|
4698
|
+
gap: 6px;
|
|
4699
|
+
}
|
|
4700
|
+
|
|
4701
|
+
.model-env-row {
|
|
4702
|
+
display: grid;
|
|
4703
|
+
grid-template-columns: minmax(0, 240px) minmax(0, 1fr) 18px 30px;
|
|
4704
|
+
gap: 6px;
|
|
4705
|
+
align-items: center;
|
|
4706
|
+
}
|
|
4707
|
+
|
|
4708
|
+
.model-env-key::part(input) {
|
|
4709
|
+
font-family: var(--sl-font-mono);
|
|
4710
|
+
font-size: 12px;
|
|
4711
|
+
}
|
|
4712
|
+
|
|
4713
|
+
.model-env-key.is-invalid::part(base) {
|
|
4714
|
+
border-color: var(--status-failed, #ef4444);
|
|
4715
|
+
}
|
|
4716
|
+
|
|
4717
|
+
.model-env-value::part(input) {
|
|
4718
|
+
font-size: 12px;
|
|
4719
|
+
}
|
|
4720
|
+
|
|
4721
|
+
.model-env-warn {
|
|
4722
|
+
display: inline-flex;
|
|
4723
|
+
align-items: center;
|
|
4724
|
+
justify-content: center;
|
|
4725
|
+
color: var(--status-failed, #ef4444);
|
|
4726
|
+
font-size: 14px;
|
|
4727
|
+
line-height: 1;
|
|
4728
|
+
cursor: help;
|
|
4729
|
+
}
|
|
4730
|
+
|
|
4731
|
+
.model-env-warn-spacer {
|
|
4732
|
+
display: inline-block;
|
|
4733
|
+
width: 18px;
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
.model-env-remove {
|
|
4737
|
+
color: var(--muted);
|
|
4738
|
+
--sl-spacing-medium: 0;
|
|
4739
|
+
}
|
|
4740
|
+
|
|
4741
|
+
.model-env-add-btn {
|
|
4742
|
+
align-self: flex-start;
|
|
4743
|
+
margin-top: 4px;
|
|
4744
|
+
}
|
|
4745
|
+
|
|
4746
|
+
.model-card-actions {
|
|
4747
|
+
display: flex;
|
|
4748
|
+
align-items: center;
|
|
4749
|
+
gap: 8px;
|
|
4750
|
+
margin-top: 14px;
|
|
4751
|
+
padding-top: 12px;
|
|
4752
|
+
border-top: 1px solid var(--border-subtle);
|
|
4753
|
+
}
|
|
4754
|
+
|
|
4755
|
+
.model-card-status {
|
|
4756
|
+
flex: 1;
|
|
4757
|
+
font-size: 11px;
|
|
4758
|
+
color: var(--muted);
|
|
4759
|
+
font-style: italic;
|
|
4760
|
+
text-align: center;
|
|
4761
|
+
}
|
|
4762
|
+
|
|
4763
|
+
.settings-tab-description {
|
|
4764
|
+
font-size: 12px;
|
|
4765
|
+
color: var(--muted);
|
|
4766
|
+
margin: 0 0 16px 0;
|
|
4767
|
+
line-height: 1.5;
|
|
4768
|
+
}
|
|
4769
|
+
|
|
4770
|
+
.settings-tab-description code {
|
|
4771
|
+
background: var(--bg-tertiary);
|
|
4772
|
+
padding: 1px 5px;
|
|
4773
|
+
border-radius: 3px;
|
|
4774
|
+
font-family: var(--sl-font-mono);
|
|
4775
|
+
font-size: 11px;
|
|
4776
|
+
color: var(--fg);
|
|
4777
|
+
}
|
|
4778
|
+
|
|
4779
|
+
.models-add-row {
|
|
4780
|
+
margin-top: 24px;
|
|
4781
|
+
padding-top: 16px;
|
|
4782
|
+
border-top: 1px solid var(--border-subtle);
|
|
4783
|
+
}
|
|
4784
|
+
|
|
4785
|
+
.models-add-controls {
|
|
4786
|
+
display: flex;
|
|
4787
|
+
gap: 8px;
|
|
4788
|
+
align-items: flex-end;
|
|
4789
|
+
}
|
|
4790
|
+
|
|
4791
|
+
.models-add-controls sl-input {
|
|
4792
|
+
flex: 1;
|
|
4793
|
+
}
|
|
4794
|
+
|
|
4795
|
+
/* ─── Confirmation dialog — "cannot be undone" warning row ────────── */
|
|
4796
|
+
.confirm-warning {
|
|
4797
|
+
display: flex;
|
|
4798
|
+
align-items: flex-start;
|
|
4799
|
+
gap: 8px;
|
|
4800
|
+
margin: 0.75rem 0 0;
|
|
4801
|
+
padding: 8px 10px;
|
|
4802
|
+
background: rgba(245, 158, 11, 0.08);
|
|
4803
|
+
border-left: 3px solid var(--status-paused, #f59e0b);
|
|
4804
|
+
border-radius: 4px;
|
|
4805
|
+
color: var(--fg);
|
|
4806
|
+
font-size: 13px;
|
|
4807
|
+
font-weight: 500;
|
|
4808
|
+
line-height: 1.4;
|
|
4809
|
+
}
|
|
4810
|
+
|
|
4811
|
+
.confirm-warning > span:first-child {
|
|
4812
|
+
color: var(--status-paused, #f59e0b);
|
|
4813
|
+
font-size: 15px;
|
|
4814
|
+
line-height: 1.2;
|
|
4815
|
+
flex: 0 0 auto;
|
|
4816
|
+
}
|
package/app/utils/stage-order.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@worca/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Pipeline monitoring UI for worca-cc",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Sinisha Djukic",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"bin/worca-ui.js",
|
|
26
26
|
"server/**/*.js",
|
|
27
27
|
"server/schemas/keys.json",
|
|
28
|
+
"server/reserved-env-keys.json",
|
|
28
29
|
"!server/**/*.test.js",
|
|
29
30
|
"!server/test/**",
|
|
30
31
|
"!server/**/test/**",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Ensure the bd daemon is running for the project at worcaDir.
|
|
10
|
+
* Best-effort — all errors swallowed.
|
|
11
|
+
*
|
|
12
|
+
* Probes `bd daemon status` first. The `daemon.stopped` sentinel only blocks
|
|
13
|
+
* auto-start; if the daemon is already running (e.g. started manually outside
|
|
14
|
+
* worca), we report it as up regardless of the sentinel.
|
|
15
|
+
*/
|
|
16
|
+
export async function ensureBdDaemon(worcaDir) {
|
|
17
|
+
const beadsDir = resolve(join(worcaDir, '..', '.beads'));
|
|
18
|
+
if (!existsSync(beadsDir)) return false;
|
|
19
|
+
|
|
20
|
+
const workspaceDir = dirname(beadsDir);
|
|
21
|
+
const opts = {
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
timeout: 5000,
|
|
24
|
+
env: { ...process.env, BEADS_DIR: beadsDir },
|
|
25
|
+
cwd: workspaceDir,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
await execFileAsync('bd', ['daemon', 'status'], opts);
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
// not running — sentinel may block auto-start below
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (existsSync(join(beadsDir, 'daemon.stopped'))) return false;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await execFileAsync('bd', ['daemon', 'start'], opts);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/server/beads-reader.js
CHANGED
|
@@ -49,7 +49,10 @@ export function dbExists(beadsDb) {
|
|
|
49
49
|
export async function listIssues(beadsDb) {
|
|
50
50
|
try {
|
|
51
51
|
const issues = await runBd(['list', '--limit', '0'], beadsDb);
|
|
52
|
-
|
|
52
|
+
// Must await here — without it, an enrichWithDeps rejection (e.g. bd show
|
|
53
|
+
// SIGTERM under daemon contention) escapes the try/catch and propagates
|
|
54
|
+
// to the WS handler as an unhandled rejection, crashing Node.
|
|
55
|
+
return await enrichWithDeps(issues, beadsDb);
|
|
53
56
|
} catch {
|
|
54
57
|
return [];
|
|
55
58
|
}
|
|
@@ -61,7 +64,7 @@ export async function listIssuesByLabel(beadsDb, label) {
|
|
|
61
64
|
['list', '--label-any', label, '--all', '--limit', '0'],
|
|
62
65
|
beadsDb,
|
|
63
66
|
);
|
|
64
|
-
return enrichWithDeps(issues, beadsDb);
|
|
67
|
+
return await enrichWithDeps(issues, beadsDb);
|
|
65
68
|
} catch {
|
|
66
69
|
return [];
|
|
67
70
|
}
|
|
@@ -92,37 +95,48 @@ export async function listUnlinkedIssues(beadsDb) {
|
|
|
92
95
|
/**
|
|
93
96
|
* Returns { runId: { total, done } } for every run:<id> label in the beads db.
|
|
94
97
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
98
|
+
* Single-pass: bd label list-all (totals) + bd list --all (ids) +
|
|
99
|
+
* bd show <all-ids> (labels + statuses), then group by run labels in JS.
|
|
100
|
+
* Always 3 bd calls regardless of run-label count.
|
|
101
|
+
*
|
|
102
|
+
* Called by the beads watcher on every db change (counts are included in the
|
|
103
|
+
* broadcast payload) and by the list-beads-counts endpoint for initial load
|
|
104
|
+
* and project switch.
|
|
100
105
|
*/
|
|
101
106
|
export async function countIssuesByRunLabel(beadsDb) {
|
|
102
107
|
try {
|
|
103
108
|
const rows = await runBd(['label', 'list-all'], beadsDb);
|
|
104
109
|
const counts = {};
|
|
105
110
|
const runLabels = rows.filter((r) => r.label.startsWith('run:'));
|
|
111
|
+
if (runLabels.length === 0) return counts;
|
|
106
112
|
for (const row of runLabels) {
|
|
107
113
|
counts[row.label.replace('run:', '')] = { total: row.count, done: 0 };
|
|
108
114
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
115
|
+
try {
|
|
116
|
+
const issues = await runBd(['list', '--all', '--limit', '0'], beadsDb);
|
|
117
|
+
if (issues.length === 0) return counts;
|
|
118
|
+
const detailed = await runBd(
|
|
119
|
+
['show', ...issues.map((i) => i.id)],
|
|
120
|
+
beadsDb,
|
|
121
|
+
);
|
|
122
|
+
for (const issue of detailed) {
|
|
123
|
+
if (issue.status !== 'closed') continue;
|
|
124
|
+
for (const label of issue.labels || []) {
|
|
125
|
+
if (label.startsWith('run:')) {
|
|
126
|
+
const runId = label.replace('run:', '');
|
|
127
|
+
if (counts[runId]) counts[runId].done++;
|
|
128
|
+
}
|
|
123
129
|
}
|
|
124
|
-
}
|
|
125
|
-
)
|
|
130
|
+
}
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// Leave done=0 for all runs on list/show failure. Logged so a stale
|
|
133
|
+
// "0/N" badge can be traced back to bd subprocess timeout (typically
|
|
134
|
+
// daemon contention) rather than mistaken for "no closed issues."
|
|
135
|
+
// The next watcher tick recomputes and corrects.
|
|
136
|
+
console.warn(
|
|
137
|
+
`[countIssuesByRunLabel] bd list/show failed; counts.done left at 0: ${err?.message || err}`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
126
140
|
return counts;
|
|
127
141
|
} catch {
|
|
128
142
|
return {};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { Router } from 'express';
|
|
4
|
+
import { atomicWriteSync } from './atomic-write.js';
|
|
5
|
+
import { localPathFor } from './settings-merge.js';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const denylist = require('./reserved-env-keys.json');
|
|
9
|
+
const RESERVED_KEYS = new Set(denylist.keys);
|
|
10
|
+
const RESERVED_PREFIXES = denylist.prefixes;
|
|
11
|
+
|
|
12
|
+
function isReservedKey(key) {
|
|
13
|
+
if (RESERVED_KEYS.has(key)) return true;
|
|
14
|
+
return RESERVED_PREFIXES.some((p) => key.startsWith(p));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readJsonOr(path, fallback) {
|
|
18
|
+
if (!existsSync(path)) return fallback;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
21
|
+
} catch {
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createModelEnvRouter({ settingsPath: staticPath } = {}) {
|
|
27
|
+
const router = Router({ mergeParams: true });
|
|
28
|
+
|
|
29
|
+
function resolveSettingsPath(req) {
|
|
30
|
+
return req.project?.settingsPath || staticPath;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
router.put('/', (req, res) => {
|
|
34
|
+
const { model, id, env } = req.body || {};
|
|
35
|
+
|
|
36
|
+
if (!model || typeof model !== 'string') {
|
|
37
|
+
return res
|
|
38
|
+
.status(400)
|
|
39
|
+
.json({ ok: false, error: 'model name is required' });
|
|
40
|
+
}
|
|
41
|
+
if (id != null && typeof id !== 'string') {
|
|
42
|
+
return res.status(400).json({ ok: false, error: 'id must be a string' });
|
|
43
|
+
}
|
|
44
|
+
if (env != null && (typeof env !== 'object' || Array.isArray(env))) {
|
|
45
|
+
return res
|
|
46
|
+
.status(400)
|
|
47
|
+
.json({ ok: false, error: 'env must be an object' });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const envIn = env || {};
|
|
51
|
+
for (const [key, value] of Object.entries(envIn)) {
|
|
52
|
+
if (typeof key !== 'string' || key === '') {
|
|
53
|
+
return res
|
|
54
|
+
.status(400)
|
|
55
|
+
.json({ ok: false, error: 'env keys must be non-empty strings' });
|
|
56
|
+
}
|
|
57
|
+
if (isReservedKey(key)) {
|
|
58
|
+
return res.status(400).json({
|
|
59
|
+
ok: false,
|
|
60
|
+
key,
|
|
61
|
+
error: `Key "${key}" is reserved and cannot be used as a model env var`,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (typeof value !== 'string') {
|
|
65
|
+
return res.status(400).json({
|
|
66
|
+
ok: false,
|
|
67
|
+
key,
|
|
68
|
+
error: `value for "${key}" must be a string`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const settingsPath = resolveSettingsPath(req);
|
|
74
|
+
if (!settingsPath) {
|
|
75
|
+
return res
|
|
76
|
+
.status(501)
|
|
77
|
+
.json({ ok: false, error: 'settingsPath not configured' });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Storage split (deliberate after the W-051 simplification):
|
|
81
|
+
// settings.json — public model entry: string id or { id }, NEVER env
|
|
82
|
+
// settings.local.json — { env } only, NEVER id
|
|
83
|
+
//
|
|
84
|
+
// Writing env to local while leaving env behind in settings.json would
|
|
85
|
+
// let deep-merge resurrect deleted keys (a key removed in the UI but
|
|
86
|
+
// still present in settings.json would reappear on next load). So PUT
|
|
87
|
+
// actively strips env from the settings.json entry whenever it writes
|
|
88
|
+
// env to local, and conversely never lets id leak into local.
|
|
89
|
+
const localPath = localPathFor(settingsPath);
|
|
90
|
+
const local = readJsonOr(localPath, {});
|
|
91
|
+
if (!local.worca) local.worca = {};
|
|
92
|
+
if (!local.worca.models) local.worca.models = {};
|
|
93
|
+
|
|
94
|
+
if (Object.keys(envIn).length === 0) {
|
|
95
|
+
delete local.worca.models[model];
|
|
96
|
+
} else {
|
|
97
|
+
local.worca.models[model] = { env: { ...envIn } };
|
|
98
|
+
}
|
|
99
|
+
atomicWriteSync(localPath, `${JSON.stringify(local, null, 2)}\n`);
|
|
100
|
+
|
|
101
|
+
// settings.json: keep/update id, drop env entirely. If the model
|
|
102
|
+
// doesn't exist there and no id was supplied, skip the file. If id is
|
|
103
|
+
// explicitly an empty string, treat it as "no id" and drop the entry.
|
|
104
|
+
const base = readJsonOr(settingsPath, {});
|
|
105
|
+
if (!base.worca) base.worca = {};
|
|
106
|
+
if (!base.worca.models) base.worca.models = {};
|
|
107
|
+
|
|
108
|
+
const baseEntry = base.worca.models[model];
|
|
109
|
+
let resolvedId = id;
|
|
110
|
+
if (resolvedId == null) {
|
|
111
|
+
if (typeof baseEntry === 'string') resolvedId = baseEntry;
|
|
112
|
+
else if (baseEntry && typeof baseEntry === 'object')
|
|
113
|
+
resolvedId = baseEntry.id;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let baseChanged = false;
|
|
117
|
+
if (resolvedId) {
|
|
118
|
+
// Prefer string form when there's no other metadata — keeps JSON minimal.
|
|
119
|
+
const nextBaseEntry = resolvedId;
|
|
120
|
+
if (JSON.stringify(baseEntry) !== JSON.stringify(nextBaseEntry)) {
|
|
121
|
+
base.worca.models[model] = nextBaseEntry;
|
|
122
|
+
baseChanged = true;
|
|
123
|
+
}
|
|
124
|
+
} else if (baseEntry !== undefined) {
|
|
125
|
+
delete base.worca.models[model];
|
|
126
|
+
baseChanged = true;
|
|
127
|
+
}
|
|
128
|
+
if (baseChanged) {
|
|
129
|
+
atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
res.json({ ok: true, model, id: resolvedId || null, env: { ...envIn } });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
router.delete('/', (req, res) => {
|
|
136
|
+
const model =
|
|
137
|
+
req.body?.model ||
|
|
138
|
+
(typeof req.query?.model === 'string' ? req.query.model : null);
|
|
139
|
+
|
|
140
|
+
if (!model) {
|
|
141
|
+
return res
|
|
142
|
+
.status(400)
|
|
143
|
+
.json({ ok: false, error: 'model name is required' });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const settingsPath = resolveSettingsPath(req);
|
|
147
|
+
if (!settingsPath) {
|
|
148
|
+
return res
|
|
149
|
+
.status(501)
|
|
150
|
+
.json({ ok: false, error: 'settingsPath not configured' });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Remove from BOTH files so deep-merge can't resurrect the entry. The
|
|
154
|
+
// settings POST endpoint deep-merges and cannot remove a key, so we
|
|
155
|
+
// operate on disk directly. This is intentional — "Delete model" in
|
|
156
|
+
// the UI means the model goes away, full stop.
|
|
157
|
+
let removedFromBase = false;
|
|
158
|
+
let removedFromLocal = false;
|
|
159
|
+
|
|
160
|
+
if (existsSync(settingsPath)) {
|
|
161
|
+
const base = readJsonOr(settingsPath, {});
|
|
162
|
+
if (base?.worca?.models && model in base.worca.models) {
|
|
163
|
+
delete base.worca.models[model];
|
|
164
|
+
atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
|
|
165
|
+
removedFromBase = true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const localPath = localPathFor(settingsPath);
|
|
170
|
+
if (existsSync(localPath)) {
|
|
171
|
+
const local = readJsonOr(localPath, {});
|
|
172
|
+
if (local?.worca?.models && model in local.worca.models) {
|
|
173
|
+
delete local.worca.models[model];
|
|
174
|
+
atomicWriteSync(localPath, `${JSON.stringify(local, null, 2)}\n`);
|
|
175
|
+
removedFromLocal = true;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
res.json({
|
|
180
|
+
ok: true,
|
|
181
|
+
model,
|
|
182
|
+
removed: removedFromBase || removedFromLocal,
|
|
183
|
+
fromBase: removedFromBase,
|
|
184
|
+
fromLocal: removedFromLocal,
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return router;
|
|
189
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const DEFAULT_MODELS = ['opus', 'sonnet', 'haiku'];
|
|
2
|
+
|
|
3
|
+
export function deriveValidModels(worcaObj) {
|
|
4
|
+
const configuredModels =
|
|
5
|
+
worcaObj?.models &&
|
|
6
|
+
typeof worcaObj.models === 'object' &&
|
|
7
|
+
!Array.isArray(worcaObj.models)
|
|
8
|
+
? Object.keys(worcaObj.models)
|
|
9
|
+
: [];
|
|
10
|
+
return [...new Set([...DEFAULT_MODELS, ...configuredModels])];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export { DEFAULT_MODELS };
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { Router } from 'express';
|
|
3
|
+
import { deriveValidModels } from './model-validation.js';
|
|
3
4
|
import { readGlobalSettings, writeGlobalSettings } from './settings-reader.js';
|
|
4
5
|
|
|
5
6
|
const VALID_CLEANUP_POLICIES = ['never', 'on-success', 'manual-only'];
|
|
6
|
-
const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
|
|
7
7
|
const MIN_DISK_BYTES = 500_000_000;
|
|
8
8
|
const MAX_DISK_BYTES = 50_000_000_000;
|
|
9
9
|
|
|
@@ -73,9 +73,10 @@ export function validateGlobalSettingsPayload(body) {
|
|
|
73
73
|
) {
|
|
74
74
|
details.push('worca.circuit_breaker must be an object');
|
|
75
75
|
} else if (w.circuit_breaker.classifier_model !== undefined) {
|
|
76
|
-
|
|
76
|
+
const validModels = deriveValidModels(w);
|
|
77
|
+
if (!validModels.includes(w.circuit_breaker.classifier_model)) {
|
|
77
78
|
details.push(
|
|
78
|
-
`classifier_model must be one of: ${
|
|
79
|
+
`classifier_model must be one of: ${validModels.join(', ')}`,
|
|
79
80
|
);
|
|
80
81
|
}
|
|
81
82
|
}
|
package/server/project-routes.js
CHANGED
|
@@ -27,6 +27,7 @@ import { dispatchExternal } from './dispatch-external.js';
|
|
|
27
27
|
import { ensureWebhookForUi } from './ensure-webhook.js';
|
|
28
28
|
import { extractAndStripGlobalKeys } from './global-keys.js';
|
|
29
29
|
import { LaunchLock } from './launch-lock.js';
|
|
30
|
+
import { createModelEnvRouter } from './model-env-routes.js';
|
|
30
31
|
import { readPreferences } from './preferences.js';
|
|
31
32
|
import { ProcessManager } from './process-manager.js';
|
|
32
33
|
import { countRunningPipelinesAcrossProjects } from './process-registry.js';
|
|
@@ -430,6 +431,9 @@ export function createProjectScopedRoutes({
|
|
|
430
431
|
res.json({ ok: true, files });
|
|
431
432
|
});
|
|
432
433
|
|
|
434
|
+
// --- Model env endpoints (writes wholesale to settings.local.json) ---
|
|
435
|
+
router.use('/settings/model-env', createModelEnvRouter());
|
|
436
|
+
|
|
433
437
|
// --- Project-scoped settings endpoints ---
|
|
434
438
|
|
|
435
439
|
// GET /api/projects/:projectId/settings
|
|
@@ -578,6 +582,7 @@ export function createProjectScopedRoutes({
|
|
|
578
582
|
// DELETE /api/projects/:projectId/settings/:section
|
|
579
583
|
const SECTION_KEYS = {
|
|
580
584
|
agents: { worca: ['agents'] },
|
|
585
|
+
models: { worca: ['models'] },
|
|
581
586
|
pipeline: { worca: ['stages', 'loops', 'plan_path_template', 'defaults'] },
|
|
582
587
|
governance: { worca: ['governance'], top: ['permissions'] },
|
|
583
588
|
pricing: { worca: ['pricing'] },
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"keys": [
|
|
3
|
+
"PATH",
|
|
4
|
+
"CLAUDECODE",
|
|
5
|
+
"WORCA_AGENT",
|
|
6
|
+
"WORCA_PROJECT_ROOT",
|
|
7
|
+
"WORCA_RUN_ID",
|
|
8
|
+
"WORCA_RUN_DIR",
|
|
9
|
+
"WORCA_PLAN_FILE",
|
|
10
|
+
"WORCA_EVENTS_PATH",
|
|
11
|
+
"WORCA_TARGET_BRANCH",
|
|
12
|
+
"WORCA_COVERAGE",
|
|
13
|
+
"WORCA_SKIP_BEADS",
|
|
14
|
+
"WORCA_CLAUDE_BIN"
|
|
15
|
+
],
|
|
16
|
+
"prefixes": [
|
|
17
|
+
"WORCA_"
|
|
18
|
+
]
|
|
19
|
+
}
|