@worca/ui 0.1.0-rc.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/index.html +23 -0
- package/app/main.bundle.js +5738 -0
- package/app/main.bundle.js.map +7 -0
- package/app/styles.css +3897 -0
- package/app/vendor/shoelace-dark.css +483 -0
- package/app/vendor/shoelace-light.css +484 -0
- package/app/vendor/xterm.css +285 -0
- package/bin/worca-ui.js +540 -0
- package/package.json +71 -0
- package/scripts/build-frontend.js +49 -0
- package/server/app.js +421 -0
- package/server/beads-reader.js +199 -0
- package/server/index.js +131 -0
- package/server/log-tailer.js +156 -0
- package/server/multi-watcher.js +237 -0
- package/server/preferences.js +17 -0
- package/server/process-manager.js +546 -0
- package/server/project-registry.js +145 -0
- package/server/project-routes.js +1265 -0
- package/server/settings-merge.js +83 -0
- package/server/settings-reader.js +23 -0
- package/server/settings-validator.js +506 -0
- package/server/watcher-set.js +286 -0
- package/server/watcher.js +357 -0
- package/server/webhook-inbox.js +59 -0
- package/server/worca-setup.js +114 -0
- package/server/ws-beads-watcher.js +62 -0
- package/server/ws-broadcaster.js +106 -0
- package/server/ws-client-manager.js +129 -0
- package/server/ws-event-watcher.js +124 -0
- package/server/ws-log-watcher.js +299 -0
- package/server/ws-message-router.js +870 -0
- package/server/ws-modular.js +309 -0
- package/server/ws-status-watcher.js +259 -0
- package/server/ws.js +5 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Shared settings loader with .local.json deep-merge support.
|
|
2
|
+
//
|
|
3
|
+
// All worca-ui server code should use readMergedSettings() for reads and
|
|
4
|
+
// write UI changes to settings.local.json via readLocalSettings/localPathFor.
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { extname } from 'node:path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Recursively merge override into base, returning a new object.
|
|
11
|
+
* Dicts are merged recursively; lists and scalars from override replace entirely.
|
|
12
|
+
* Neither input is mutated.
|
|
13
|
+
*/
|
|
14
|
+
export function deepMerge(base, override) {
|
|
15
|
+
if (!base || typeof base !== 'object' || Array.isArray(base)) return override;
|
|
16
|
+
if (!override || typeof override !== 'object' || Array.isArray(override))
|
|
17
|
+
return override;
|
|
18
|
+
|
|
19
|
+
const result = { ...base };
|
|
20
|
+
for (const key of Object.keys(override)) {
|
|
21
|
+
if (
|
|
22
|
+
key in result &&
|
|
23
|
+
typeof result[key] === 'object' &&
|
|
24
|
+
!Array.isArray(result[key]) &&
|
|
25
|
+
typeof override[key] === 'object' &&
|
|
26
|
+
!Array.isArray(override[key])
|
|
27
|
+
) {
|
|
28
|
+
result[key] = deepMerge(result[key], override[key]);
|
|
29
|
+
} else {
|
|
30
|
+
result[key] = override[key];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Derive the .local.json sibling path from a base settings path.
|
|
38
|
+
* e.g. "settings.json" → "settings.local.json"
|
|
39
|
+
*/
|
|
40
|
+
export function localPathFor(settingsPath) {
|
|
41
|
+
const ext = extname(settingsPath);
|
|
42
|
+
const base = settingsPath.slice(0, -ext.length);
|
|
43
|
+
return `${base}.local${ext}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read base settings + .local.json sibling, deep-merge, and return the result.
|
|
48
|
+
* If local file is missing or invalid, returns base as-is.
|
|
49
|
+
*/
|
|
50
|
+
export function readMergedSettings(settingsPath) {
|
|
51
|
+
let base;
|
|
52
|
+
try {
|
|
53
|
+
base = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
|
54
|
+
} catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const localPath = localPathFor(settingsPath);
|
|
59
|
+
if (!existsSync(localPath)) return base;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const local = JSON.parse(readFileSync(localPath, 'utf8'));
|
|
63
|
+
return deepMerge(base, local);
|
|
64
|
+
} catch {
|
|
65
|
+
console.warn(
|
|
66
|
+
`[settings] Warning: ${localPath} contains invalid JSON, ignoring local overrides`,
|
|
67
|
+
);
|
|
68
|
+
return base;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Read just the .local.json sibling (for write-back / reset operations).
|
|
74
|
+
* Returns {} if the file doesn't exist or has invalid JSON.
|
|
75
|
+
*/
|
|
76
|
+
export function readLocalSettings(settingsPath) {
|
|
77
|
+
const localPath = localPathFor(settingsPath);
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(readFileSync(localPath, 'utf8'));
|
|
80
|
+
} catch {
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readMergedSettings } from './settings-merge.js';
|
|
2
|
+
|
|
3
|
+
export function readSettings(path) {
|
|
4
|
+
try {
|
|
5
|
+
const raw = readMergedSettings(path);
|
|
6
|
+
const worca = raw.worca || {};
|
|
7
|
+
return {
|
|
8
|
+
agents: worca.agents || {},
|
|
9
|
+
loops: worca.loops || {},
|
|
10
|
+
milestones: worca.milestones || {},
|
|
11
|
+
stageUi: worca.ui?.stages || {},
|
|
12
|
+
learnEnabled: worca.stages?.learn?.enabled || false,
|
|
13
|
+
};
|
|
14
|
+
} catch {
|
|
15
|
+
return {
|
|
16
|
+
agents: {},
|
|
17
|
+
loops: {},
|
|
18
|
+
milestones: {},
|
|
19
|
+
stageUi: {},
|
|
20
|
+
learnEnabled: false,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// server/settings-validator.js
|
|
2
|
+
import { STAGE_ORDER } from '../app/utils/stage-order.js';
|
|
3
|
+
|
|
4
|
+
const VALID_AGENTS = [
|
|
5
|
+
'planner',
|
|
6
|
+
'plan_reviewer',
|
|
7
|
+
'coordinator',
|
|
8
|
+
'implementer',
|
|
9
|
+
'tester',
|
|
10
|
+
'guardian',
|
|
11
|
+
'learner',
|
|
12
|
+
];
|
|
13
|
+
const VALID_STAGES = STAGE_ORDER;
|
|
14
|
+
const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
|
|
15
|
+
const VALID_LOOPS = [
|
|
16
|
+
'implement_test',
|
|
17
|
+
'pr_changes',
|
|
18
|
+
'restart_planning',
|
|
19
|
+
'plan_review',
|
|
20
|
+
];
|
|
21
|
+
const VALID_MILESTONES = ['plan_approval', 'pr_approval', 'deploy_approval'];
|
|
22
|
+
const VALID_GUARDS = [
|
|
23
|
+
'block_rm_rf',
|
|
24
|
+
'block_env_write',
|
|
25
|
+
'block_force_push',
|
|
26
|
+
'restrict_git_commit',
|
|
27
|
+
];
|
|
28
|
+
const VALID_PRICING_MODELS = ['opus', 'sonnet'];
|
|
29
|
+
const VALID_PRICING_FIELDS = [
|
|
30
|
+
'input_per_mtok',
|
|
31
|
+
'output_per_mtok',
|
|
32
|
+
'cache_write_per_mtok',
|
|
33
|
+
'cache_read_per_mtok',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export function validateSettingsPayload(body) {
|
|
37
|
+
const details = [];
|
|
38
|
+
|
|
39
|
+
if (body.worca !== undefined) {
|
|
40
|
+
if (
|
|
41
|
+
typeof body.worca !== 'object' ||
|
|
42
|
+
body.worca === null ||
|
|
43
|
+
Array.isArray(body.worca)
|
|
44
|
+
) {
|
|
45
|
+
details.push('worca must be an object');
|
|
46
|
+
return { valid: false, details };
|
|
47
|
+
}
|
|
48
|
+
const w = body.worca;
|
|
49
|
+
|
|
50
|
+
// agents
|
|
51
|
+
if (w.agents !== undefined) {
|
|
52
|
+
if (
|
|
53
|
+
typeof w.agents !== 'object' ||
|
|
54
|
+
w.agents === null ||
|
|
55
|
+
Array.isArray(w.agents)
|
|
56
|
+
) {
|
|
57
|
+
details.push('worca.agents must be an object');
|
|
58
|
+
} else {
|
|
59
|
+
for (const [name, cfg] of Object.entries(w.agents)) {
|
|
60
|
+
if (!VALID_AGENTS.includes(name)) {
|
|
61
|
+
details.push(`Unknown agent name: "${name}"`);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (cfg.model !== undefined && !VALID_MODELS.includes(cfg.model)) {
|
|
65
|
+
details.push(`Invalid model "${cfg.model}" for agent "${name}"`);
|
|
66
|
+
}
|
|
67
|
+
if (cfg.max_turns !== undefined) {
|
|
68
|
+
if (
|
|
69
|
+
!Number.isInteger(cfg.max_turns) ||
|
|
70
|
+
cfg.max_turns < 1 ||
|
|
71
|
+
cfg.max_turns > 500
|
|
72
|
+
) {
|
|
73
|
+
details.push(
|
|
74
|
+
`max_turns for "${name}" must be an integer between 1 and 500`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// stages
|
|
83
|
+
if (w.stages !== undefined) {
|
|
84
|
+
if (
|
|
85
|
+
typeof w.stages !== 'object' ||
|
|
86
|
+
w.stages === null ||
|
|
87
|
+
Array.isArray(w.stages)
|
|
88
|
+
) {
|
|
89
|
+
details.push('worca.stages must be an object');
|
|
90
|
+
} else {
|
|
91
|
+
for (const [name, cfg] of Object.entries(w.stages)) {
|
|
92
|
+
if (!VALID_STAGES.includes(name)) {
|
|
93
|
+
details.push(`Unknown stage name: "${name}"`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (cfg.enabled !== undefined && typeof cfg.enabled !== 'boolean') {
|
|
97
|
+
details.push(`enabled for stage "${name}" must be a boolean`);
|
|
98
|
+
}
|
|
99
|
+
if (name === 'preflight') {
|
|
100
|
+
if (
|
|
101
|
+
cfg.script !== undefined &&
|
|
102
|
+
(typeof cfg.script !== 'string' || cfg.script.length === 0)
|
|
103
|
+
) {
|
|
104
|
+
details.push('preflight.script must be a non-empty string');
|
|
105
|
+
}
|
|
106
|
+
if (cfg.require !== undefined && !Array.isArray(cfg.require)) {
|
|
107
|
+
details.push('preflight.require must be an array');
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
if (cfg.agent !== undefined && !VALID_AGENTS.includes(cfg.agent)) {
|
|
111
|
+
details.push(`Invalid agent "${cfg.agent}" for stage "${name}"`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// loops
|
|
119
|
+
if (w.loops !== undefined) {
|
|
120
|
+
if (
|
|
121
|
+
typeof w.loops !== 'object' ||
|
|
122
|
+
w.loops === null ||
|
|
123
|
+
Array.isArray(w.loops)
|
|
124
|
+
) {
|
|
125
|
+
details.push('worca.loops must be an object');
|
|
126
|
+
} else {
|
|
127
|
+
for (const [key, val] of Object.entries(w.loops)) {
|
|
128
|
+
if (!VALID_LOOPS.includes(key)) {
|
|
129
|
+
details.push(`Unknown loop key: "${key}"`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!Number.isInteger(val) || val < 0 || val > 100) {
|
|
133
|
+
details.push(
|
|
134
|
+
`Loop "${key}" must be a non-negative integer, max 100`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// plan_path_template
|
|
142
|
+
if (w.plan_path_template !== undefined) {
|
|
143
|
+
if (
|
|
144
|
+
typeof w.plan_path_template !== 'string' ||
|
|
145
|
+
w.plan_path_template.length === 0
|
|
146
|
+
) {
|
|
147
|
+
details.push('plan_path_template must be a non-empty string');
|
|
148
|
+
} else if (w.plan_path_template.length > 500) {
|
|
149
|
+
details.push('plan_path_template must be at most 500 characters');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// defaults
|
|
154
|
+
if (w.defaults !== undefined) {
|
|
155
|
+
if (
|
|
156
|
+
typeof w.defaults !== 'object' ||
|
|
157
|
+
w.defaults === null ||
|
|
158
|
+
Array.isArray(w.defaults)
|
|
159
|
+
) {
|
|
160
|
+
details.push('defaults must be an object');
|
|
161
|
+
} else {
|
|
162
|
+
if (w.defaults.msize !== undefined) {
|
|
163
|
+
if (
|
|
164
|
+
!Number.isInteger(w.defaults.msize) ||
|
|
165
|
+
w.defaults.msize < 1 ||
|
|
166
|
+
w.defaults.msize > 10
|
|
167
|
+
) {
|
|
168
|
+
details.push('defaults.msize must be an integer between 1 and 10');
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (w.defaults.mloops !== undefined) {
|
|
172
|
+
if (
|
|
173
|
+
!Number.isInteger(w.defaults.mloops) ||
|
|
174
|
+
w.defaults.mloops < 1 ||
|
|
175
|
+
w.defaults.mloops > 10
|
|
176
|
+
) {
|
|
177
|
+
details.push('defaults.mloops must be an integer between 1 and 10');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// pricing
|
|
184
|
+
if (w.pricing !== undefined) {
|
|
185
|
+
if (
|
|
186
|
+
typeof w.pricing !== 'object' ||
|
|
187
|
+
w.pricing === null ||
|
|
188
|
+
Array.isArray(w.pricing)
|
|
189
|
+
) {
|
|
190
|
+
details.push('pricing must be an object');
|
|
191
|
+
} else {
|
|
192
|
+
const p = w.pricing;
|
|
193
|
+
if (p.models !== undefined) {
|
|
194
|
+
if (
|
|
195
|
+
typeof p.models !== 'object' ||
|
|
196
|
+
p.models === null ||
|
|
197
|
+
Array.isArray(p.models)
|
|
198
|
+
) {
|
|
199
|
+
details.push('pricing.models must be an object');
|
|
200
|
+
} else {
|
|
201
|
+
for (const [model, costs] of Object.entries(p.models)) {
|
|
202
|
+
if (!VALID_PRICING_MODELS.includes(model)) {
|
|
203
|
+
details.push(`Unknown pricing model: "${model}"`);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (
|
|
207
|
+
typeof costs !== 'object' ||
|
|
208
|
+
costs === null ||
|
|
209
|
+
Array.isArray(costs)
|
|
210
|
+
) {
|
|
211
|
+
details.push(`pricing.models.${model} must be an object`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
for (const [field, val] of Object.entries(costs)) {
|
|
215
|
+
if (!VALID_PRICING_FIELDS.includes(field)) {
|
|
216
|
+
details.push(
|
|
217
|
+
`Unknown pricing field "${field}" for model "${model}"`,
|
|
218
|
+
);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
typeof val !== 'number' ||
|
|
223
|
+
!Number.isFinite(val) ||
|
|
224
|
+
val < 0
|
|
225
|
+
) {
|
|
226
|
+
details.push(
|
|
227
|
+
`pricing.models.${model}.${field} must be a non-negative finite number`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (p.currency !== undefined && typeof p.currency !== 'string') {
|
|
235
|
+
details.push('pricing.currency must be a string');
|
|
236
|
+
}
|
|
237
|
+
if (
|
|
238
|
+
p.last_updated !== undefined &&
|
|
239
|
+
typeof p.last_updated !== 'string'
|
|
240
|
+
) {
|
|
241
|
+
details.push('pricing.last_updated must be a string');
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// milestones
|
|
247
|
+
if (w.milestones !== undefined) {
|
|
248
|
+
if (
|
|
249
|
+
typeof w.milestones !== 'object' ||
|
|
250
|
+
w.milestones === null ||
|
|
251
|
+
Array.isArray(w.milestones)
|
|
252
|
+
) {
|
|
253
|
+
details.push('worca.milestones must be an object');
|
|
254
|
+
} else {
|
|
255
|
+
for (const [key, val] of Object.entries(w.milestones)) {
|
|
256
|
+
if (!VALID_MILESTONES.includes(key)) {
|
|
257
|
+
details.push(`Unknown milestone key: "${key}"`);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (typeof val !== 'boolean') {
|
|
261
|
+
details.push(`Milestone "${key}" must be a boolean`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// governance
|
|
268
|
+
if (w.governance !== undefined) {
|
|
269
|
+
if (
|
|
270
|
+
typeof w.governance !== 'object' ||
|
|
271
|
+
w.governance === null ||
|
|
272
|
+
Array.isArray(w.governance)
|
|
273
|
+
) {
|
|
274
|
+
details.push('worca.governance must be an object');
|
|
275
|
+
} else {
|
|
276
|
+
const g = w.governance;
|
|
277
|
+
if (g.guards !== undefined) {
|
|
278
|
+
if (
|
|
279
|
+
typeof g.guards !== 'object' ||
|
|
280
|
+
g.guards === null ||
|
|
281
|
+
Array.isArray(g.guards)
|
|
282
|
+
) {
|
|
283
|
+
details.push('governance.guards must be an object');
|
|
284
|
+
} else {
|
|
285
|
+
for (const [key, val] of Object.entries(g.guards)) {
|
|
286
|
+
if (!VALID_GUARDS.includes(key)) {
|
|
287
|
+
details.push(`Unknown guard key: "${key}"`);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (typeof val !== 'boolean') {
|
|
291
|
+
details.push(`Guard "${key}" must be a boolean`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (g.test_gate_strikes !== undefined) {
|
|
297
|
+
if (
|
|
298
|
+
!Number.isInteger(g.test_gate_strikes) ||
|
|
299
|
+
g.test_gate_strikes < 1 ||
|
|
300
|
+
g.test_gate_strikes > 20
|
|
301
|
+
) {
|
|
302
|
+
details.push(
|
|
303
|
+
'test_gate_strikes must be an integer between 1 and 20',
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (g.dispatch !== undefined) {
|
|
308
|
+
if (
|
|
309
|
+
typeof g.dispatch !== 'object' ||
|
|
310
|
+
g.dispatch === null ||
|
|
311
|
+
Array.isArray(g.dispatch)
|
|
312
|
+
) {
|
|
313
|
+
details.push('governance.dispatch must be an object');
|
|
314
|
+
} else {
|
|
315
|
+
for (const [key, val] of Object.entries(g.dispatch)) {
|
|
316
|
+
if (!VALID_AGENTS.includes(key)) {
|
|
317
|
+
details.push(`Unknown dispatch agent: "${key}"`);
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (!Array.isArray(val)) {
|
|
321
|
+
details.push(`Dispatch for "${key}" must be an array`);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
for (const v of val) {
|
|
325
|
+
if (!VALID_AGENTS.includes(v)) {
|
|
326
|
+
details.push(`Unknown agent "${v}" in dispatch for "${key}"`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// events
|
|
336
|
+
if (w.events !== undefined) {
|
|
337
|
+
if (
|
|
338
|
+
typeof w.events !== 'object' ||
|
|
339
|
+
w.events === null ||
|
|
340
|
+
Array.isArray(w.events)
|
|
341
|
+
) {
|
|
342
|
+
details.push('events must be an object');
|
|
343
|
+
} else {
|
|
344
|
+
const ev = w.events;
|
|
345
|
+
if (ev.enabled !== undefined && typeof ev.enabled !== 'boolean') {
|
|
346
|
+
details.push('events.enabled must be a boolean');
|
|
347
|
+
}
|
|
348
|
+
if (
|
|
349
|
+
ev.agent_telemetry !== undefined &&
|
|
350
|
+
typeof ev.agent_telemetry !== 'boolean'
|
|
351
|
+
) {
|
|
352
|
+
details.push('events.agent_telemetry must be a boolean');
|
|
353
|
+
}
|
|
354
|
+
if (
|
|
355
|
+
ev.hook_events !== undefined &&
|
|
356
|
+
typeof ev.hook_events !== 'boolean'
|
|
357
|
+
) {
|
|
358
|
+
details.push('events.hook_events must be a boolean');
|
|
359
|
+
}
|
|
360
|
+
if (ev.rate_limit_ms !== undefined) {
|
|
361
|
+
if (!Number.isInteger(ev.rate_limit_ms) || ev.rate_limit_ms < 0) {
|
|
362
|
+
details.push('events.rate_limit_ms must be a non-negative integer');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// budget
|
|
369
|
+
if (w.budget !== undefined) {
|
|
370
|
+
if (
|
|
371
|
+
typeof w.budget !== 'object' ||
|
|
372
|
+
w.budget === null ||
|
|
373
|
+
Array.isArray(w.budget)
|
|
374
|
+
) {
|
|
375
|
+
details.push('budget must be an object');
|
|
376
|
+
} else {
|
|
377
|
+
const b = w.budget;
|
|
378
|
+
if (b.max_cost_usd !== undefined) {
|
|
379
|
+
if (
|
|
380
|
+
typeof b.max_cost_usd !== 'number' ||
|
|
381
|
+
!Number.isFinite(b.max_cost_usd) ||
|
|
382
|
+
b.max_cost_usd <= 0
|
|
383
|
+
) {
|
|
384
|
+
details.push(
|
|
385
|
+
'budget.max_cost_usd must be a positive finite number',
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (b.warning_pct !== undefined) {
|
|
390
|
+
if (
|
|
391
|
+
typeof b.warning_pct !== 'number' ||
|
|
392
|
+
!Number.isFinite(b.warning_pct) ||
|
|
393
|
+
b.warning_pct < 0 ||
|
|
394
|
+
b.warning_pct > 100
|
|
395
|
+
) {
|
|
396
|
+
details.push(
|
|
397
|
+
'budget.warning_pct must be a number between 0 and 100',
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// webhooks
|
|
405
|
+
if (w.webhooks !== undefined) {
|
|
406
|
+
if (!Array.isArray(w.webhooks)) {
|
|
407
|
+
details.push('webhooks must be an array');
|
|
408
|
+
} else {
|
|
409
|
+
for (let i = 0; i < w.webhooks.length; i++) {
|
|
410
|
+
const wh = w.webhooks[i];
|
|
411
|
+
const pfx = `webhooks[${i}]`;
|
|
412
|
+
if (typeof wh !== 'object' || wh === null || Array.isArray(wh)) {
|
|
413
|
+
details.push(`${pfx} must be an object`);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
// url — required
|
|
417
|
+
if (
|
|
418
|
+
wh.url === undefined ||
|
|
419
|
+
typeof wh.url !== 'string' ||
|
|
420
|
+
wh.url.trim().length === 0
|
|
421
|
+
) {
|
|
422
|
+
details.push(`${pfx}.url must be a non-empty string`);
|
|
423
|
+
} else {
|
|
424
|
+
try {
|
|
425
|
+
const parsed = new URL(wh.url);
|
|
426
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
427
|
+
details.push(`${pfx}.url must use http or https protocol`);
|
|
428
|
+
}
|
|
429
|
+
} catch {
|
|
430
|
+
details.push(`${pfx}.url is not a valid URL`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (wh.secret !== undefined && typeof wh.secret !== 'string') {
|
|
434
|
+
details.push(`${pfx}.secret must be a string`);
|
|
435
|
+
}
|
|
436
|
+
if (wh.events !== undefined) {
|
|
437
|
+
if (!Array.isArray(wh.events)) {
|
|
438
|
+
details.push(`${pfx}.events must be an array`);
|
|
439
|
+
} else {
|
|
440
|
+
for (let j = 0; j < wh.events.length; j++) {
|
|
441
|
+
if (
|
|
442
|
+
typeof wh.events[j] !== 'string' ||
|
|
443
|
+
wh.events[j].length === 0
|
|
444
|
+
) {
|
|
445
|
+
details.push(
|
|
446
|
+
`${pfx}.events[${j}] must be a non-empty string`,
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (wh.timeout_ms !== undefined) {
|
|
453
|
+
if (!Number.isInteger(wh.timeout_ms) || wh.timeout_ms < 1) {
|
|
454
|
+
details.push(`${pfx}.timeout_ms must be a positive integer`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (wh.max_retries !== undefined) {
|
|
458
|
+
if (
|
|
459
|
+
!Number.isInteger(wh.max_retries) ||
|
|
460
|
+
wh.max_retries < 0 ||
|
|
461
|
+
wh.max_retries > 10
|
|
462
|
+
) {
|
|
463
|
+
details.push(
|
|
464
|
+
`${pfx}.max_retries must be an integer between 0 and 10`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (wh.rate_limit_ms !== undefined) {
|
|
469
|
+
if (!Number.isInteger(wh.rate_limit_ms) || wh.rate_limit_ms < 0) {
|
|
470
|
+
details.push(
|
|
471
|
+
`${pfx}.rate_limit_ms must be a non-negative integer`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (wh.control !== undefined && typeof wh.control !== 'boolean') {
|
|
476
|
+
details.push(`${pfx}.control must be a boolean`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// permissions
|
|
484
|
+
if (body.permissions !== undefined) {
|
|
485
|
+
if (
|
|
486
|
+
typeof body.permissions !== 'object' ||
|
|
487
|
+
body.permissions === null ||
|
|
488
|
+
Array.isArray(body.permissions)
|
|
489
|
+
) {
|
|
490
|
+
details.push('permissions must be an object');
|
|
491
|
+
} else if (body.permissions.allow !== undefined) {
|
|
492
|
+
if (!Array.isArray(body.permissions.allow)) {
|
|
493
|
+
details.push('permissions.allow must be an array');
|
|
494
|
+
} else {
|
|
495
|
+
for (let i = 0; i < body.permissions.allow.length; i++) {
|
|
496
|
+
const v = body.permissions.allow[i];
|
|
497
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
498
|
+
details.push(`permissions.allow[${i}] must be a non-empty string`);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return details.length ? { valid: false, details } : { valid: true };
|
|
506
|
+
}
|