@worca/ui 0.20.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 +1203 -953
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +164 -0
- package/package.json +2 -1
- 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/worktree-ops.js +49 -22
- package/server/worktrees-routes.js +237 -28
package/app/styles.css
CHANGED
|
@@ -4650,3 +4650,167 @@ sl-tooltip.bead-tooltip::part(body) {
|
|
|
4650
4650
|
justify-content: flex-end;
|
|
4651
4651
|
width: 100%;
|
|
4652
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/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,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
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// server/settings-validator.js
|
|
2
2
|
import { STAGE_ORDER } from '../app/utils/stage-order.js';
|
|
3
3
|
import { GLOBAL_ONLY_KEYS } from './keys-schema.js';
|
|
4
|
+
import { DEFAULT_MODELS, deriveValidModels } from './model-validation.js';
|
|
4
5
|
|
|
5
6
|
const VALID_AGENTS = [
|
|
6
7
|
'planner',
|
|
@@ -13,7 +14,7 @@ const VALID_AGENTS = [
|
|
|
13
14
|
'learner',
|
|
14
15
|
];
|
|
15
16
|
const VALID_STAGES = STAGE_ORDER;
|
|
16
|
-
export const VALID_MODELS =
|
|
17
|
+
export const VALID_MODELS = DEFAULT_MODELS;
|
|
17
18
|
const VALID_LOOPS = [
|
|
18
19
|
'implement_test',
|
|
19
20
|
'pr_changes',
|
|
@@ -27,7 +28,7 @@ const VALID_GUARDS = [
|
|
|
27
28
|
'block_force_push',
|
|
28
29
|
'restrict_git_commit',
|
|
29
30
|
];
|
|
30
|
-
const
|
|
31
|
+
const DEFAULT_PRICING_MODELS = ['opus', 'sonnet'];
|
|
31
32
|
const VALID_PRICING_FIELDS = [
|
|
32
33
|
'input_per_mtok',
|
|
33
34
|
'output_per_mtok',
|
|
@@ -48,6 +49,7 @@ export function validateSettingsPayload(body) {
|
|
|
48
49
|
return { valid: false, details };
|
|
49
50
|
}
|
|
50
51
|
const w = body.worca;
|
|
52
|
+
const validModels = deriveValidModels(w);
|
|
51
53
|
|
|
52
54
|
// agents
|
|
53
55
|
if (w.agents !== undefined) {
|
|
@@ -63,7 +65,7 @@ export function validateSettingsPayload(body) {
|
|
|
63
65
|
details.push(`Unknown agent name: "${name}"`);
|
|
64
66
|
continue;
|
|
65
67
|
}
|
|
66
|
-
if (cfg.model !== undefined && !
|
|
68
|
+
if (cfg.model !== undefined && !validModels.includes(cfg.model)) {
|
|
67
69
|
details.push(`Invalid model "${cfg.model}" for agent "${name}"`);
|
|
68
70
|
}
|
|
69
71
|
if (cfg.max_turns !== undefined) {
|
|
@@ -192,6 +194,9 @@ export function validateSettingsPayload(body) {
|
|
|
192
194
|
details.push('pricing must be an object');
|
|
193
195
|
} else {
|
|
194
196
|
const p = w.pricing;
|
|
197
|
+
const validPricingModels = [
|
|
198
|
+
...new Set([...DEFAULT_PRICING_MODELS, ...validModels]),
|
|
199
|
+
];
|
|
195
200
|
if (p.models !== undefined) {
|
|
196
201
|
if (
|
|
197
202
|
typeof p.models !== 'object' ||
|
|
@@ -201,7 +206,7 @@ export function validateSettingsPayload(body) {
|
|
|
201
206
|
details.push('pricing.models must be an object');
|
|
202
207
|
} else {
|
|
203
208
|
for (const [model, costs] of Object.entries(p.models)) {
|
|
204
|
-
if (!
|
|
209
|
+
if (!validPricingModels.includes(model)) {
|
|
205
210
|
details.push(`Unknown pricing model: "${model}"`);
|
|
206
211
|
continue;
|
|
207
212
|
}
|
|
@@ -864,12 +869,13 @@ export function validateGlobalSettings(prefs) {
|
|
|
864
869
|
}
|
|
865
870
|
}
|
|
866
871
|
|
|
872
|
+
const globalValidModels = deriveValidModels(w);
|
|
867
873
|
if (
|
|
868
874
|
w.circuit_breaker?.classifier_model !== undefined &&
|
|
869
|
-
!
|
|
875
|
+
!globalValidModels.includes(w.circuit_breaker.classifier_model)
|
|
870
876
|
) {
|
|
871
877
|
details.push(
|
|
872
|
-
`circuit_breaker.classifier_model must be one of: ${
|
|
878
|
+
`circuit_breaker.classifier_model must be one of: ${globalValidModels.join(', ')}`,
|
|
873
879
|
);
|
|
874
880
|
}
|
|
875
881
|
|
package/server/worktree-ops.js
CHANGED
|
@@ -2,33 +2,37 @@
|
|
|
2
2
|
* Shared worktree operations — single owner of `git worktree remove` shell-out.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
lstatSync,
|
|
9
|
-
readFileSync,
|
|
10
|
-
rmSync,
|
|
11
|
-
unlinkSync,
|
|
12
|
-
} from 'node:fs';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { existsSync, lstatSync } from 'node:fs';
|
|
7
|
+
import { readFile, rm, unlink } from 'node:fs/promises';
|
|
13
8
|
import { join } from 'node:path';
|
|
9
|
+
import { promisify } from 'node:util';
|
|
10
|
+
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
14
12
|
|
|
15
13
|
/**
|
|
16
14
|
* Remove a worktree and its registry entry.
|
|
17
15
|
* Mirrors WorktreeSource.remove from src/worca/cli/cleanup.py:
|
|
18
16
|
* 1. Attempt `git worktree remove --force <path>` from the project root
|
|
19
|
-
* 2. On failure (e.g. non-worktree temp dir in tests), fall back to
|
|
17
|
+
* 2. On failure (e.g. non-worktree temp dir in tests), fall back to rm (async)
|
|
20
18
|
* 3. Run `git worktree prune` so git's metadata (`.git/worktrees/<id>/`)
|
|
21
19
|
* drops the entry even when the directory was removed manually
|
|
20
|
+
* (skipped when skipPrune is true — caller is responsible for pruning later)
|
|
22
21
|
* 4. Delete the registry file
|
|
23
22
|
*/
|
|
24
|
-
export function removeWorktree(
|
|
23
|
+
export async function removeWorktree(
|
|
24
|
+
worcaDir,
|
|
25
|
+
runId,
|
|
26
|
+
{ skipPrune = false } = {},
|
|
27
|
+
) {
|
|
25
28
|
const regFile = join(worcaDir, 'multi', 'pipelines.d', `${runId}.json`);
|
|
26
29
|
const projectRoot = join(worcaDir, '..');
|
|
27
30
|
let worktreePath = null;
|
|
28
31
|
|
|
29
32
|
if (existsSync(regFile)) {
|
|
30
33
|
try {
|
|
31
|
-
const
|
|
34
|
+
const content = await readFile(regFile, 'utf8');
|
|
35
|
+
const reg = JSON.parse(content);
|
|
32
36
|
worktreePath = reg.worktree_path || null;
|
|
33
37
|
} catch {
|
|
34
38
|
/* ignore */
|
|
@@ -37,11 +41,15 @@ export function removeWorktree(worcaDir, runId) {
|
|
|
37
41
|
|
|
38
42
|
if (worktreePath && existsSync(worktreePath)) {
|
|
39
43
|
try {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
await execFileAsync(
|
|
45
|
+
'git',
|
|
46
|
+
['worktree', 'remove', '--force', worktreePath],
|
|
47
|
+
{
|
|
48
|
+
cwd: projectRoot,
|
|
49
|
+
stdio: 'pipe',
|
|
50
|
+
timeout: 30_000,
|
|
51
|
+
},
|
|
52
|
+
);
|
|
45
53
|
} catch {
|
|
46
54
|
let isRealDir = false;
|
|
47
55
|
try {
|
|
@@ -51,13 +59,36 @@ export function removeWorktree(worcaDir, runId) {
|
|
|
51
59
|
/* ignore */
|
|
52
60
|
}
|
|
53
61
|
if (isRealDir) {
|
|
54
|
-
|
|
62
|
+
await rm(worktreePath, { recursive: true, force: true });
|
|
55
63
|
}
|
|
56
64
|
}
|
|
57
65
|
}
|
|
58
66
|
|
|
67
|
+
if (!skipPrune) {
|
|
68
|
+
try {
|
|
69
|
+
await execFileAsync('git', ['worktree', 'prune'], {
|
|
70
|
+
cwd: projectRoot,
|
|
71
|
+
stdio: 'pipe',
|
|
72
|
+
timeout: 30_000,
|
|
73
|
+
});
|
|
74
|
+
} catch {
|
|
75
|
+
/* non-fatal */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (existsSync(regFile)) {
|
|
80
|
+
await unlink(regFile);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Run `git worktree prune` once for the project at worcaDir.
|
|
86
|
+
* Use after a batch of removeWorktree({ skipPrune: true }) calls.
|
|
87
|
+
*/
|
|
88
|
+
export async function pruneWorktrees(worcaDir) {
|
|
89
|
+
const projectRoot = join(worcaDir, '..');
|
|
59
90
|
try {
|
|
60
|
-
|
|
91
|
+
await execFileAsync('git', ['worktree', 'prune'], {
|
|
61
92
|
cwd: projectRoot,
|
|
62
93
|
stdio: 'pipe',
|
|
63
94
|
timeout: 30_000,
|
|
@@ -65,8 +96,4 @@ export function removeWorktree(worcaDir, runId) {
|
|
|
65
96
|
} catch {
|
|
66
97
|
/* non-fatal */
|
|
67
98
|
}
|
|
68
|
-
|
|
69
|
-
if (existsSync(regFile)) {
|
|
70
|
-
unlinkSync(regFile);
|
|
71
|
-
}
|
|
72
99
|
}
|