@sigrun/sigrun 0.0.0-stage → 0.1.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/CHANGELOG.md +204 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +90 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,1053 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sigrun settings store (Task 8a, pi parity spec: the settings file).
|
|
3
|
+
*
|
|
4
|
+
* SPLIT (build-and-webui Task 1): the settings-file TYPES
|
|
5
|
+
* (`SettingsFile`, `ServerSettings`, `DefaultProviderSettings`,
|
|
6
|
+
* `ExtensionSpec`) live in the `@sigrun/shared` package
|
|
7
|
+
* (`shared/src/settings.ts` — the canonical declarations, re-exported
|
|
8
|
+
* below); this module owns the store/validation RUNTIME.
|
|
9
|
+
*
|
|
10
|
+
* A JSON file at `~/.config/sigrun/settings.json` (the config-dir
|
|
11
|
+
* convention; `SIGRUN_CONFIG_DIR` overrides the directory for hermetic
|
|
12
|
+
* tests, matching the repo's env-override style). The store carries the
|
|
13
|
+
* `HarnessSettings` pass-throughs (`steeringMode`, `followUpMode`,
|
|
14
|
+
* `retry`, `stream`) plus sigrun's own resolved fields:
|
|
15
|
+
*
|
|
16
|
+
* - `defaultTools` — a tool-name filter applied at conversation-create
|
|
17
|
+
* time (the server reads it from the store, resolves the names against
|
|
18
|
+
* the registry, and passes them as `AgentChange.modelTools` objects —
|
|
19
|
+
* the MODEL-OFFERED filter, pi-durable 1.3.0; NOT the enabled filter,
|
|
20
|
+
* so filtered-out tools stay callable by nested calls).
|
|
21
|
+
* - `perModelThinking` — per-model thinking-level overrides (`"provider/
|
|
22
|
+
* modelId": level`), applied by the server at conversation create and
|
|
23
|
+
* configure.
|
|
24
|
+
* - `server` — the static boot configuration (port, workspace/storage/
|
|
25
|
+
* extensions paths, compaction policy, backup target). The file is the
|
|
26
|
+
* deployment default UNDER env: `loadConfig(env, file)` applies a
|
|
27
|
+
* `server.*` field only where the corresponding `SIGRUN_*` variable is
|
|
28
|
+
* absent. Changes to `server.*` require a restart (they resolve at
|
|
29
|
+
* boot, unlike the live steeringMode/retry/etc. pass-throughs).
|
|
30
|
+
*
|
|
31
|
+
* The remaining boot knobs are file-configurable too (the 2026-10-13
|
|
32
|
+
* config-surface rulings): top-level `extensions` (the extension package
|
|
33
|
+
* list), top-level `defaultModel` (the boot default model — env or file,
|
|
34
|
+
* one is REQUIRED, no code default), `server.webuiBundle`, and
|
|
35
|
+
* `defaultProvider.baseUrl` (the env-driven OpenAI-compatible provider's
|
|
36
|
+
* endpoint). Env still wins per setting. Credentials live in the sibling
|
|
37
|
+
* `auth.json` (see src/harness/auth.ts), never in this file. The file's
|
|
38
|
+
* own location stays env-only (`SIGRUN_CONFIG_DIR`).
|
|
39
|
+
*
|
|
40
|
+
* Contract (pi-durable's own): "HarnessSettings is read at every
|
|
41
|
+
* resolution and never copied." So the store is a GETTER, not a snapshot:
|
|
42
|
+
* `harnessSettings()` re-reads the file on every call, and `openHarness`
|
|
43
|
+
* passes a live object whose property getters fetch fresh values — a
|
|
44
|
+
* PATCH becomes effective on the next submission/admission without a
|
|
45
|
+
* restart.
|
|
46
|
+
*
|
|
47
|
+
* Absent file = all defaults (empty store). Malformed file = fail-loud
|
|
48
|
+
* at boot (like `loadConfig`), with a distinct error for a well-formed
|
|
49
|
+
* JSON object carrying wrong-shaped or unknown fields.
|
|
50
|
+
*/
|
|
51
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
52
|
+
import { dirname, join } from "node:path";
|
|
53
|
+
import { resolveStoreRoots } from "./stores.js";
|
|
54
|
+
import { isAbsoluteHttpUrl } from "../config.js";
|
|
55
|
+
import { isIanaTimeZone, validateJobSpec } from "../jobs/spec.js";
|
|
56
|
+
/** The queue modes pi-durable accepts (its `QueueMode`). */
|
|
57
|
+
const QUEUE_MODES = ["all", "one-at-a-time"];
|
|
58
|
+
/**
|
|
59
|
+
* The `notifications.runSettled` vocabulary (W5 Task 6, spec §9.6) — a
|
|
60
|
+
* list for the same reason `QUEUE_MODES` is one: `includes` on a `const`
|
|
61
|
+
* tuple narrows to the literal union, which an unknown file value can
|
|
62
|
+
* never satisfy.
|
|
63
|
+
*/
|
|
64
|
+
const RUN_SETTLED_MODES = [
|
|
65
|
+
"unwatched",
|
|
66
|
+
"cron",
|
|
67
|
+
"always",
|
|
68
|
+
"never",
|
|
69
|
+
];
|
|
70
|
+
/** The thinking levels the configure surface accepts (pi-ai's union). */
|
|
71
|
+
const THINKING_LEVELS = [
|
|
72
|
+
"off",
|
|
73
|
+
"minimal",
|
|
74
|
+
"low",
|
|
75
|
+
"medium",
|
|
76
|
+
"high",
|
|
77
|
+
"xhigh",
|
|
78
|
+
"max",
|
|
79
|
+
];
|
|
80
|
+
/** Every settable key (PATCH validation + GET projection). */
|
|
81
|
+
const SETTING_KEYS = [
|
|
82
|
+
"steeringMode",
|
|
83
|
+
"followUpMode",
|
|
84
|
+
"retry",
|
|
85
|
+
"stream",
|
|
86
|
+
"defaultTools",
|
|
87
|
+
"perModelThinking",
|
|
88
|
+
"extensions",
|
|
89
|
+
"jobs",
|
|
90
|
+
"jobHistory",
|
|
91
|
+
"defaultModel",
|
|
92
|
+
"namingModel",
|
|
93
|
+
"defaultProvider",
|
|
94
|
+
"skills",
|
|
95
|
+
"notifications",
|
|
96
|
+
"builtinExtensions",
|
|
97
|
+
"autoEnableCodemode",
|
|
98
|
+
"server",
|
|
99
|
+
];
|
|
100
|
+
/**
|
|
101
|
+
* Warn once per process when the deprecated `server.webuiBundle` alias
|
|
102
|
+
* appears in a validated file (build-and-webui Task 4, one release):
|
|
103
|
+
* `read()` validates on EVERY access (the live-getter contract), so an
|
|
104
|
+
* ungated warn would fire per resolution. Module-level, like
|
|
105
|
+
* `warnDeprecatedModelEnv` in src/config.ts.
|
|
106
|
+
*/
|
|
107
|
+
let deprecatedWebuiBundleWarned = false;
|
|
108
|
+
function warnDeprecatedWebuiBundle() {
|
|
109
|
+
if (deprecatedWebuiBundleWarned)
|
|
110
|
+
return;
|
|
111
|
+
deprecatedWebuiBundleWarned = true;
|
|
112
|
+
console.warn("settings: server.webuiBundle is deprecated, use server.webui.bundle (reads as the same selector for one release)");
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Warn once per process when the file carries the retired
|
|
116
|
+
* `server.extensionsDir` key (§6.7): accepted for one release so existing
|
|
117
|
+
* files keep validating, warned because nothing reads it.
|
|
118
|
+
*/
|
|
119
|
+
let deprecatedExtensionsDirWarned = false;
|
|
120
|
+
function warnDeprecatedExtensionsDir() {
|
|
121
|
+
if (deprecatedExtensionsDirWarned)
|
|
122
|
+
return;
|
|
123
|
+
deprecatedExtensionsDirWarned = true;
|
|
124
|
+
console.warn("settings: server.extensionsDir is deprecated and ignored (nothing reads the extensions dir)");
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Default settings directory: the ONE config-root chain
|
|
128
|
+
* (`resolveStoreRoots`, spec Decision 3) — `SIGRUN_CONFIG_DIR` →
|
|
129
|
+
* `$XDG_CONFIG_HOME`/sigrun → `~/.config/sigrun`.
|
|
130
|
+
*/
|
|
131
|
+
export function defaultSettingsDir() {
|
|
132
|
+
return resolveStoreRoots(process.env).configDir;
|
|
133
|
+
}
|
|
134
|
+
/** Default file path: `<dir>/settings.json`. */
|
|
135
|
+
export function settingsPath(dir) {
|
|
136
|
+
return join(dir, "settings.json");
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* A field-level validation error. The server maps these to 400
|
|
140
|
+
* `invalid_field` + the offending key (matching the repo's other PATCH
|
|
141
|
+
* surfaces); `openHarness`'s boot-time read lets them throw (fail loud).
|
|
142
|
+
*/
|
|
143
|
+
export class SettingsValidationError extends Error {
|
|
144
|
+
field;
|
|
145
|
+
constructor(field, reason) {
|
|
146
|
+
super(`settings: ${field} ${reason}`);
|
|
147
|
+
this.field = field;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Validate one `SettingsFile`-shaped object; every field checked in place. */
|
|
151
|
+
function validate(value) {
|
|
152
|
+
for (const key of Object.keys(value)) {
|
|
153
|
+
if (!SETTING_KEYS.includes(key)) {
|
|
154
|
+
throw new SettingsValidationError(key, "is not a settable field");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const queueMode = (field) => {
|
|
158
|
+
const v = value[field];
|
|
159
|
+
if (v !== undefined &&
|
|
160
|
+
(typeof v !== "string" || !QUEUE_MODES.includes(v))) {
|
|
161
|
+
throw new SettingsValidationError(field, 'must be "all" or "one-at-a-time"');
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
queueMode("steeringMode");
|
|
165
|
+
queueMode("followUpMode");
|
|
166
|
+
if (value.retry !== undefined) {
|
|
167
|
+
const retry = value.retry;
|
|
168
|
+
if (typeof retry !== "object" || retry === null || Array.isArray(retry)) {
|
|
169
|
+
throw new SettingsValidationError("retry", "must be an object");
|
|
170
|
+
}
|
|
171
|
+
const retryRecord = retry;
|
|
172
|
+
for (const key of Object.keys(retryRecord)) {
|
|
173
|
+
if (!["enabled", "maxRetries", "baseDelayMs", "maxAgentDelayMs"].includes(key)) {
|
|
174
|
+
throw new SettingsValidationError(`retry.${key}`, "is not a retry field");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (retryRecord.maxRetries !== undefined && !isNonNegativeInt(retryRecord.maxRetries)) {
|
|
178
|
+
throw new SettingsValidationError("retry.maxRetries", "must be a non-negative integer");
|
|
179
|
+
}
|
|
180
|
+
if (retryRecord.baseDelayMs !== undefined && !isNonNegativeInt(retryRecord.baseDelayMs)) {
|
|
181
|
+
throw new SettingsValidationError("retry.baseDelayMs", "must be a non-negative integer");
|
|
182
|
+
}
|
|
183
|
+
if (retryRecord.maxAgentDelayMs !== undefined && !isNonNegativeInt(retryRecord.maxAgentDelayMs)) {
|
|
184
|
+
throw new SettingsValidationError("retry.maxAgentDelayMs", "must be a non-negative integer");
|
|
185
|
+
}
|
|
186
|
+
if (retryRecord.enabled !== undefined && typeof retryRecord.enabled !== "boolean") {
|
|
187
|
+
throw new SettingsValidationError("retry.enabled", "must be a boolean");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (value.stream !== undefined) {
|
|
191
|
+
const stream = value.stream;
|
|
192
|
+
if (typeof stream !== "object" || stream === null || Array.isArray(stream)) {
|
|
193
|
+
throw new SettingsValidationError("stream", "must be an object");
|
|
194
|
+
}
|
|
195
|
+
const streamRecord = stream;
|
|
196
|
+
for (const key of Object.keys(streamRecord)) {
|
|
197
|
+
if (!["timeoutMs", "maxRetries", "maxRetryDelayMs"].includes(key)) {
|
|
198
|
+
throw new SettingsValidationError(`stream.${key}`, "is not a stream field");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const key of ["timeoutMs", "maxRetries", "maxRetryDelayMs"]) {
|
|
202
|
+
if (streamRecord[key] !== undefined && !isNonNegativeInt(streamRecord[key])) {
|
|
203
|
+
throw new SettingsValidationError(`stream.${key}`, "must be a non-negative integer");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (value.defaultTools !== undefined) {
|
|
208
|
+
const tools = value.defaultTools;
|
|
209
|
+
if (!Array.isArray(tools) ||
|
|
210
|
+
tools.some((name) => typeof name !== "string" || name === "")) {
|
|
211
|
+
throw new SettingsValidationError("defaultTools", "must be an array of tool names");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (value.perModelThinking !== undefined) {
|
|
215
|
+
const perModel = value.perModelThinking;
|
|
216
|
+
if (typeof perModel !== "object" || perModel === null || Array.isArray(perModel)) {
|
|
217
|
+
throw new SettingsValidationError("perModelThinking", "must be an object");
|
|
218
|
+
}
|
|
219
|
+
for (const [model, level] of Object.entries(perModel)) {
|
|
220
|
+
if (typeof level !== "string" ||
|
|
221
|
+
!THINKING_LEVELS.includes(level)) {
|
|
222
|
+
throw new SettingsValidationError(`perModelThinking.${model}`, `must be one of ${THINKING_LEVELS.join(", ")}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (value.extensions !== undefined) {
|
|
227
|
+
const extensions = value.extensions;
|
|
228
|
+
if (!Array.isArray(extensions)) {
|
|
229
|
+
throw new SettingsValidationError("extensions", "must be an array of extension specifiers");
|
|
230
|
+
}
|
|
231
|
+
for (const entry of extensions) {
|
|
232
|
+
if (typeof entry === "string") {
|
|
233
|
+
if (entry === "") {
|
|
234
|
+
throw new SettingsValidationError("extensions", "must not contain an empty specifier");
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (typeof entry !== "object" || entry === null) {
|
|
239
|
+
throw new SettingsValidationError("extensions", "each entry must be a string or an object");
|
|
240
|
+
}
|
|
241
|
+
const spec = entry;
|
|
242
|
+
if (typeof spec.source !== "string" || spec.source === "") {
|
|
243
|
+
throw new SettingsValidationError("extensions", "object entries need a non-empty source");
|
|
244
|
+
}
|
|
245
|
+
for (const key of ["entry", "ref"]) {
|
|
246
|
+
const v = spec[key];
|
|
247
|
+
if (v !== undefined && (typeof v !== "string" || v === "")) {
|
|
248
|
+
throw new SettingsValidationError("extensions", `object entry ${key} must be a non-empty string when present`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (spec.enabled !== undefined &&
|
|
252
|
+
typeof spec.enabled !== "boolean") {
|
|
253
|
+
throw new SettingsValidationError("extensions", "object entry enabled must be a boolean");
|
|
254
|
+
}
|
|
255
|
+
const known = new Set(["source", "entry", "ref", "enabled"]);
|
|
256
|
+
for (const key of Object.keys(spec)) {
|
|
257
|
+
if (!known.has(key)) {
|
|
258
|
+
throw new SettingsValidationError("extensions", `unknown object-entry key: ${key}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// The file-declared job list (jobs §8.3): each entry is a full
|
|
264
|
+
// `validateJobSpec` run (the §8.2 grammar), plus the source-level
|
|
265
|
+
// rules — duplicate names are a validation error, and the whole list
|
|
266
|
+
// is restart-required like `extensions` (boot reconciliation owns it).
|
|
267
|
+
if (value.jobs !== undefined) {
|
|
268
|
+
const jobs = value.jobs;
|
|
269
|
+
if (!Array.isArray(jobs)) {
|
|
270
|
+
throw new SettingsValidationError("jobs", "must be an array of job specs");
|
|
271
|
+
}
|
|
272
|
+
const seen = new Set();
|
|
273
|
+
for (const entry of jobs) {
|
|
274
|
+
const result = validateJobSpec(entry);
|
|
275
|
+
if (!result.ok) {
|
|
276
|
+
throw new SettingsValidationError(`jobs`, `${result.field} ${result.message}`);
|
|
277
|
+
}
|
|
278
|
+
if (seen.has(result.spec.name)) {
|
|
279
|
+
throw new SettingsValidationError("jobs", `duplicate job name "${result.spec.name}"`);
|
|
280
|
+
}
|
|
281
|
+
seen.add(result.spec.name);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// The live job-history retention knobs (§8.1, ruling 14): both
|
|
285
|
+
// positive integers when present.
|
|
286
|
+
if (value.jobHistory !== undefined) {
|
|
287
|
+
const jobHistory = asRecord(value.jobHistory);
|
|
288
|
+
if (jobHistory === undefined) {
|
|
289
|
+
throw new SettingsValidationError("jobHistory", "must be an object");
|
|
290
|
+
}
|
|
291
|
+
checkKeys("jobHistory.", jobHistory, ["runs", "revisions"]);
|
|
292
|
+
for (const key of ["runs", "revisions"]) {
|
|
293
|
+
const v = jobHistory[key];
|
|
294
|
+
if (v !== undefined && !isPositiveInt(v)) {
|
|
295
|
+
throw new SettingsValidationError(`jobHistory.${key}`, "must be a positive integer");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (value.defaultModel !== undefined) {
|
|
300
|
+
const spec = value.defaultModel;
|
|
301
|
+
if (typeof spec !== "string" || spec.trim() === "") {
|
|
302
|
+
throw new SettingsValidationError("defaultModel", "must be a non-empty provider/model-id string");
|
|
303
|
+
}
|
|
304
|
+
// Same fail-loud spec rules as SIGRUN_DEFAULT_MODEL
|
|
305
|
+
// (config.parseModelSpec), with the file key named in the error.
|
|
306
|
+
const trimmed = spec.trim();
|
|
307
|
+
const slash = trimmed.indexOf("/");
|
|
308
|
+
if (slash !== -1 &&
|
|
309
|
+
(trimmed.slice(0, slash) === "" || trimmed.slice(slash + 1) === "")) {
|
|
310
|
+
throw new SettingsValidationError("defaultModel", "must have a non-empty provider and model id");
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (value.namingModel !== undefined && value.namingModel !== false) {
|
|
314
|
+
// Exactly defaultModel's spec rules, plus the `false` disable flag
|
|
315
|
+
// (checked before the string checks, so only the spec shapes fall
|
|
316
|
+
// through to them).
|
|
317
|
+
const spec = value.namingModel;
|
|
318
|
+
if (typeof spec !== "string" || spec.trim() === "") {
|
|
319
|
+
throw new SettingsValidationError("namingModel", "must be a non-empty provider/model-id string or false");
|
|
320
|
+
}
|
|
321
|
+
const trimmed = spec.trim();
|
|
322
|
+
const slash = trimmed.indexOf("/");
|
|
323
|
+
if (slash !== -1 &&
|
|
324
|
+
(trimmed.slice(0, slash) === "" || trimmed.slice(slash + 1) === "")) {
|
|
325
|
+
throw new SettingsValidationError("namingModel", "must have a non-empty provider and model id");
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
if (value.defaultProvider !== undefined) {
|
|
329
|
+
const provider = asRecord(value.defaultProvider);
|
|
330
|
+
if (provider === undefined) {
|
|
331
|
+
throw new SettingsValidationError("defaultProvider", "must be an object");
|
|
332
|
+
}
|
|
333
|
+
checkKeys("defaultProvider.", provider, ["baseUrl"]);
|
|
334
|
+
if (provider.baseUrl !== undefined && !isNonEmptyString(provider.baseUrl)) {
|
|
335
|
+
throw new SettingsValidationError("defaultProvider.baseUrl", "must be a non-empty string");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// The skills surface (W5 Task 4, spec §9.4): a live, flat section —
|
|
339
|
+
// `sectionMaxBytes` a positive integer.
|
|
340
|
+
if (value.skills !== undefined) {
|
|
341
|
+
const skills = asRecord(value.skills);
|
|
342
|
+
if (skills === undefined) {
|
|
343
|
+
throw new SettingsValidationError("skills", "must be an object");
|
|
344
|
+
}
|
|
345
|
+
checkKeys("skills.", skills, ["sectionMaxBytes"]);
|
|
346
|
+
if (skills.sectionMaxBytes !== undefined &&
|
|
347
|
+
!isPositiveInt(skills.sectionMaxBytes)) {
|
|
348
|
+
throw new SettingsValidationError("skills.sectionMaxBytes", "must be a positive integer");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// The notifications surface (W5 Task 6, spec §9.6): a live section —
|
|
352
|
+
// `runSettled` one of the four modes.
|
|
353
|
+
if (value.notifications !== undefined) {
|
|
354
|
+
const notifications = asRecord(value.notifications);
|
|
355
|
+
if (notifications === undefined) {
|
|
356
|
+
throw new SettingsValidationError("notifications", "must be an object");
|
|
357
|
+
}
|
|
358
|
+
checkKeys("notifications.", notifications, ["runSettled"]);
|
|
359
|
+
if (notifications.runSettled !== undefined &&
|
|
360
|
+
(typeof notifications.runSettled !== "string" ||
|
|
361
|
+
!RUN_SETTLED_MODES.includes(notifications.runSettled))) {
|
|
362
|
+
throw new SettingsValidationError("notifications.runSettled", `must be one of ${RUN_SETTLED_MODES.join(", ")}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (value.builtinExtensions !== undefined) {
|
|
366
|
+
const builtins = asRecord(value.builtinExtensions);
|
|
367
|
+
if (builtins === undefined) {
|
|
368
|
+
throw new SettingsValidationError("builtinExtensions", "must be an object");
|
|
369
|
+
}
|
|
370
|
+
checkKeys("builtinExtensions.", builtins, ["codemode", "mcp", "toolSearch"]);
|
|
371
|
+
for (const key of ["codemode", "mcp", "toolSearch"]) {
|
|
372
|
+
if (builtins[key] !== undefined && typeof builtins[key] !== "boolean") {
|
|
373
|
+
throw new SettingsValidationError(`builtinExtensions.${key}`, "must be a boolean");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (value.autoEnableCodemode !== undefined &&
|
|
378
|
+
typeof value.autoEnableCodemode !== "boolean") {
|
|
379
|
+
throw new SettingsValidationError("autoEnableCodemode", "must be a boolean");
|
|
380
|
+
}
|
|
381
|
+
if (value.server !== undefined) {
|
|
382
|
+
validateServer(value.server);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Validate the `server` section in place (the rules mirror `src/config.ts`'s
|
|
387
|
+
* env parsing exactly: port a positive integer, paths non-empty strings,
|
|
388
|
+
* compaction token counts integers with the same minimums —
|
|
389
|
+
* `backgroundTokens` keeps its designed "0 = background off" minimum —
|
|
390
|
+
* backup exactly one of the two target shapes, like the env XOR rule).
|
|
391
|
+
*/
|
|
392
|
+
function validateServer(server) {
|
|
393
|
+
const record = asRecord(server);
|
|
394
|
+
if (record === undefined) {
|
|
395
|
+
throw new SettingsValidationError("server", "must be an object");
|
|
396
|
+
}
|
|
397
|
+
checkKeys("server.", record, [
|
|
398
|
+
"port",
|
|
399
|
+
"workspacePath",
|
|
400
|
+
"storagePath",
|
|
401
|
+
"extensionsDir",
|
|
402
|
+
"maxUploadBytes",
|
|
403
|
+
"maxFileReadBytes",
|
|
404
|
+
"dataDir",
|
|
405
|
+
"cacheDir",
|
|
406
|
+
"stateDir",
|
|
407
|
+
"publicUrl",
|
|
408
|
+
"timezone",
|
|
409
|
+
"webui",
|
|
410
|
+
"webuiBundle",
|
|
411
|
+
"compaction",
|
|
412
|
+
"memorySeam",
|
|
413
|
+
"providers",
|
|
414
|
+
"auth",
|
|
415
|
+
"metrics",
|
|
416
|
+
"backup",
|
|
417
|
+
]);
|
|
418
|
+
// The jobs default timezone (§8.2): an IANA name — `Intl` accepts
|
|
419
|
+
// exactly the names croner's evaluation path resolves, and throws on
|
|
420
|
+
// everything else, so the gate is one construction. Narrowed to a
|
|
421
|
+
// string first: `asRecord`'s unknown value type flows `null` here
|
|
422
|
+
// otherwise.
|
|
423
|
+
const timezone = record.timezone === undefined || record.timezone === null
|
|
424
|
+
? undefined
|
|
425
|
+
: record.timezone;
|
|
426
|
+
if (timezone !== undefined && !isIanaTimeZone(timezone)) {
|
|
427
|
+
throw new SettingsValidationError("server.timezone", `must be an IANA name (got ${JSON.stringify(record.timezone)})`);
|
|
428
|
+
}
|
|
429
|
+
if (record.port !== undefined && !isPositiveInt(record.port)) {
|
|
430
|
+
throw new SettingsValidationError("server.port", "must be a positive integer");
|
|
431
|
+
}
|
|
432
|
+
// The upload knob (spec §6.2): same rule as the env parse — a positive
|
|
433
|
+
// integer; `Math.ceil(max*4/3)+4096` derives the base64/import limits.
|
|
434
|
+
if (record.maxUploadBytes !== undefined &&
|
|
435
|
+
!isPositiveInt(record.maxUploadBytes)) {
|
|
436
|
+
throw new SettingsValidationError("server.maxUploadBytes", "must be a positive integer");
|
|
437
|
+
}
|
|
438
|
+
// The raw file-read cap (spec §7.8): same rule as the upload knob — a
|
|
439
|
+
// positive integer.
|
|
440
|
+
if (record.maxFileReadBytes !== undefined &&
|
|
441
|
+
!isPositiveInt(record.maxFileReadBytes)) {
|
|
442
|
+
throw new SettingsValidationError("server.maxFileReadBytes", "must be a positive integer");
|
|
443
|
+
}
|
|
444
|
+
for (const key of ["workspacePath", "storagePath", "extensionsDir", "dataDir", "cacheDir", "stateDir"]) {
|
|
445
|
+
if (record[key] !== undefined && !isNonEmptyString(record[key])) {
|
|
446
|
+
throw new SettingsValidationError(`server.${key}`, "must be a non-empty string");
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// The absolute base URL (W5 Task 2): the `SIGRUN_PUBLIC_URL` grammar —
|
|
450
|
+
// an absolute http(s) URL with a hostname. A trailing slash is NOT a
|
|
451
|
+
// validation error (config resolution strips it); a non-URL is.
|
|
452
|
+
if (record.publicUrl !== undefined &&
|
|
453
|
+
(typeof record.publicUrl !== "string" ||
|
|
454
|
+
!isAbsoluteHttpUrl(record.publicUrl))) {
|
|
455
|
+
throw new SettingsValidationError("server.publicUrl", "must be an absolute http(s) URL");
|
|
456
|
+
}
|
|
457
|
+
// The retired `extensionsDir` key (§6.7) stays ACCEPTED for one
|
|
458
|
+
// release (existing files keep validating) but warns — never throws —
|
|
459
|
+
// exactly like the legacy `webuiBundle` alias below; nothing reads it.
|
|
460
|
+
if (record.extensionsDir !== undefined) {
|
|
461
|
+
warnDeprecatedExtensionsDir();
|
|
462
|
+
}
|
|
463
|
+
// The webui channel surface (build-and-webui Task 4). The LEGACY
|
|
464
|
+
// `webuiBundle` key stays ACCEPTED for one release (a read-side alias
|
|
465
|
+
// for `webui.bundle` in config resolution) but warns — never throws —
|
|
466
|
+
// so existing deployments' files keep booting while Task 7's docs
|
|
467
|
+
// migrate them.
|
|
468
|
+
if (record.webuiBundle !== undefined) {
|
|
469
|
+
if (!isNonEmptyString(record.webuiBundle)) {
|
|
470
|
+
throw new SettingsValidationError("server.webuiBundle", "must be a non-empty string");
|
|
471
|
+
}
|
|
472
|
+
warnDeprecatedWebuiBundle();
|
|
473
|
+
}
|
|
474
|
+
if (record.webui !== undefined) {
|
|
475
|
+
const webui = asRecord(record.webui);
|
|
476
|
+
if (webui === undefined) {
|
|
477
|
+
throw new SettingsValidationError("server.webui", "must be an object");
|
|
478
|
+
}
|
|
479
|
+
checkKeys("server.webui.", webui, ["enabled", "bundle"]);
|
|
480
|
+
if (webui.enabled !== undefined && typeof webui.enabled !== "boolean") {
|
|
481
|
+
throw new SettingsValidationError("server.webui.enabled", "must be a boolean");
|
|
482
|
+
}
|
|
483
|
+
// Non-empty only — name/path discrimination is Task 5's
|
|
484
|
+
// serve-time concern, not a file-validation rule.
|
|
485
|
+
if (webui.bundle !== undefined && !isNonEmptyString(webui.bundle)) {
|
|
486
|
+
throw new SettingsValidationError("server.webui.bundle", "must be a non-empty string");
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (record.compaction !== undefined) {
|
|
490
|
+
const compaction = asRecord(record.compaction);
|
|
491
|
+
if (compaction === undefined) {
|
|
492
|
+
throw new SettingsValidationError("server.compaction", "must be an object");
|
|
493
|
+
}
|
|
494
|
+
checkKeys("server.compaction.", compaction, [
|
|
495
|
+
"enabled",
|
|
496
|
+
"reserveTokens",
|
|
497
|
+
"keepRecentTokens",
|
|
498
|
+
"backgroundTokens",
|
|
499
|
+
]);
|
|
500
|
+
if (compaction.enabled !== undefined && typeof compaction.enabled !== "boolean") {
|
|
501
|
+
throw new SettingsValidationError("server.compaction.enabled", "must be a boolean");
|
|
502
|
+
}
|
|
503
|
+
for (const key of ["reserveTokens", "keepRecentTokens", "backgroundTokens"]) {
|
|
504
|
+
const v = compaction[key];
|
|
505
|
+
if (v === undefined)
|
|
506
|
+
continue;
|
|
507
|
+
// backgroundTokens' minimum is 0 — the designed "background off"
|
|
508
|
+
// value, same as `SIGRUN_COMPACTION_BACKGROUND_TOKENS`.
|
|
509
|
+
const minimum = key === "backgroundTokens" ? 0 : 1;
|
|
510
|
+
if (typeof v !== "number" ||
|
|
511
|
+
!Number.isInteger(v) ||
|
|
512
|
+
v < minimum) {
|
|
513
|
+
throw new SettingsValidationError(`server.compaction.${key}`, minimum === 0 ? "must be a non-negative integer" : "must be a positive integer");
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (record.memorySeam !== undefined) {
|
|
518
|
+
const memorySeam = asRecord(record.memorySeam);
|
|
519
|
+
if (memorySeam === undefined) {
|
|
520
|
+
throw new SettingsValidationError("server.memorySeam", "must be an object");
|
|
521
|
+
}
|
|
522
|
+
checkKeys("server.memorySeam.", memorySeam, [
|
|
523
|
+
"hookDeadlineMs",
|
|
524
|
+
"idleThresholdMs",
|
|
525
|
+
"workerDeadlineMs",
|
|
526
|
+
"queueMax",
|
|
527
|
+
]);
|
|
528
|
+
for (const key of [
|
|
529
|
+
"hookDeadlineMs",
|
|
530
|
+
"idleThresholdMs",
|
|
531
|
+
"workerDeadlineMs",
|
|
532
|
+
"queueMax",
|
|
533
|
+
]) {
|
|
534
|
+
const v = memorySeam[key];
|
|
535
|
+
if (v !== undefined && !isPositiveInt(v)) {
|
|
536
|
+
throw new SettingsValidationError(`server.memorySeam.${key}`, "must be a positive integer");
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (record.providers !== undefined) {
|
|
541
|
+
const providers = asRecord(record.providers);
|
|
542
|
+
if (providers === undefined) {
|
|
543
|
+
throw new SettingsValidationError("server.providers", "must be an object");
|
|
544
|
+
}
|
|
545
|
+
checkKeys("server.providers.", providers, ["refreshIntervalMinutes"]);
|
|
546
|
+
if (providers.refreshIntervalMinutes !== undefined &&
|
|
547
|
+
!isNonNegativeInt(providers.refreshIntervalMinutes)) {
|
|
548
|
+
throw new SettingsValidationError("server.providers.refreshIntervalMinutes", "must be a non-negative integer");
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (record.auth !== undefined) {
|
|
552
|
+
const auth = asRecord(record.auth);
|
|
553
|
+
if (auth === undefined) {
|
|
554
|
+
throw new SettingsValidationError("server.auth", "must be an object");
|
|
555
|
+
}
|
|
556
|
+
checkKeys("server.auth.", auth, [
|
|
557
|
+
"methods",
|
|
558
|
+
"proxy",
|
|
559
|
+
"adminGroups",
|
|
560
|
+
"allowedHosts",
|
|
561
|
+
"allowedOrigins",
|
|
562
|
+
]);
|
|
563
|
+
if (auth.methods !== undefined) {
|
|
564
|
+
if (!Array.isArray(auth.methods) ||
|
|
565
|
+
auth.methods.some((m) => m !== "token" && m !== "proxy")) {
|
|
566
|
+
throw new SettingsValidationError("server.auth.methods", 'must be an array of "token" and/or "proxy"');
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (auth.proxy !== undefined) {
|
|
570
|
+
const proxy = asRecord(auth.proxy);
|
|
571
|
+
if (proxy === undefined) {
|
|
572
|
+
throw new SettingsValidationError("server.auth.proxy", "must be an object");
|
|
573
|
+
}
|
|
574
|
+
// `secret` is deliberately NOT in the list: the shared secret is a
|
|
575
|
+
// credential, env-only (SIGRUN_PROXY_SECRET) — key material never
|
|
576
|
+
// lives in the settings file (auth.json's monopoly).
|
|
577
|
+
checkKeys("server.auth.proxy.", proxy, [
|
|
578
|
+
"trustedProxies",
|
|
579
|
+
"secretHeader",
|
|
580
|
+
"userHeader",
|
|
581
|
+
"groupsHeader",
|
|
582
|
+
"nameHeader",
|
|
583
|
+
"emailHeader",
|
|
584
|
+
"autoProvision",
|
|
585
|
+
]);
|
|
586
|
+
if (proxy.trustedProxies !== undefined && !isNonEmptyStringArray(proxy.trustedProxies)) {
|
|
587
|
+
throw new SettingsValidationError("server.auth.proxy.trustedProxies", "must be an array of non-empty CIDR/IP strings");
|
|
588
|
+
}
|
|
589
|
+
for (const key of ["secretHeader", "userHeader", "groupsHeader", "nameHeader", "emailHeader"]) {
|
|
590
|
+
if (proxy[key] !== undefined && !isNonEmptyString(proxy[key])) {
|
|
591
|
+
throw new SettingsValidationError(`server.auth.proxy.${key}`, "must be a non-empty string");
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (proxy.autoProvision !== undefined && typeof proxy.autoProvision !== "boolean") {
|
|
595
|
+
throw new SettingsValidationError("server.auth.proxy.autoProvision", "must be a boolean");
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
for (const key of ["adminGroups", "allowedHosts", "allowedOrigins"]) {
|
|
599
|
+
if (auth[key] !== undefined && !isNonEmptyStringArray(auth[key])) {
|
|
600
|
+
throw new SettingsValidationError(`server.auth.${key}`, "must be an array of non-empty strings");
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (record.metrics !== undefined) {
|
|
605
|
+
const metrics = asRecord(record.metrics);
|
|
606
|
+
if (metrics === undefined) {
|
|
607
|
+
throw new SettingsValidationError("server.metrics", "must be an object");
|
|
608
|
+
}
|
|
609
|
+
checkKeys("server.metrics.", metrics, ["public"]);
|
|
610
|
+
if (metrics.public !== undefined && typeof metrics.public !== "boolean") {
|
|
611
|
+
throw new SettingsValidationError("server.metrics.public", "must be a boolean");
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (record.backup !== undefined) {
|
|
615
|
+
const backup = asRecord(record.backup);
|
|
616
|
+
if (backup === undefined) {
|
|
617
|
+
throw new SettingsValidationError("server.backup", "must be an object");
|
|
618
|
+
}
|
|
619
|
+
checkKeys("server.backup.", backup, ["path", "s3", "staleAfterHours"]);
|
|
620
|
+
if (backup.path !== undefined && backup.s3 !== undefined) {
|
|
621
|
+
throw new SettingsValidationError("server.backup", "must carry exactly one target (path or s3, not both)");
|
|
622
|
+
}
|
|
623
|
+
// §6.8: the honesty knob — a positive NUMBER (not integer-only:
|
|
624
|
+
// a sub-hour window like 0.5 is a legal tuning), defaulting to 36
|
|
625
|
+
// at resolution; not a target, so it may ride either shape or sit
|
|
626
|
+
// alone (a no-target backup section holding only the knob is inert).
|
|
627
|
+
if (backup.staleAfterHours !== undefined &&
|
|
628
|
+
!isPositiveNumber(backup.staleAfterHours)) {
|
|
629
|
+
throw new SettingsValidationError("server.backup.staleAfterHours", "must be a positive number");
|
|
630
|
+
}
|
|
631
|
+
if (backup.path !== undefined && !isNonEmptyString(backup.path)) {
|
|
632
|
+
throw new SettingsValidationError("server.backup.path", "must be a non-empty string");
|
|
633
|
+
}
|
|
634
|
+
if (backup.s3 !== undefined) {
|
|
635
|
+
const s3 = asRecord(backup.s3);
|
|
636
|
+
if (s3 === undefined) {
|
|
637
|
+
throw new SettingsValidationError("server.backup.s3", "must be an object");
|
|
638
|
+
}
|
|
639
|
+
checkKeys("server.backup.s3.", s3, ["bucket", "prefix", "endpoint"]);
|
|
640
|
+
if (!isNonEmptyString(s3.bucket)) {
|
|
641
|
+
throw new SettingsValidationError("server.backup.s3.bucket", "is required (and must be a non-empty string) when server.backup.s3 is set");
|
|
642
|
+
}
|
|
643
|
+
for (const key of ["prefix", "endpoint"]) {
|
|
644
|
+
if (s3[key] !== undefined && !isNonEmptyString(s3[key])) {
|
|
645
|
+
throw new SettingsValidationError(`server.backup.s3.${key}`, "must be a non-empty string");
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function isNonNegativeInt(value) {
|
|
652
|
+
return (typeof value === "number" &&
|
|
653
|
+
Number.isInteger(value) &&
|
|
654
|
+
value >= 0);
|
|
655
|
+
}
|
|
656
|
+
/** A positive integer (the `server.port` rule, mirroring `SIGRUN_PORT`). */
|
|
657
|
+
function isPositiveInt(value) {
|
|
658
|
+
return (typeof value === "number" &&
|
|
659
|
+
Number.isInteger(value) &&
|
|
660
|
+
value > 0);
|
|
661
|
+
}
|
|
662
|
+
/** A positive finite number (§6.8's `server.backup.staleAfterHours`). */
|
|
663
|
+
function isPositiveNumber(value) {
|
|
664
|
+
return (typeof value === "number" &&
|
|
665
|
+
Number.isFinite(value) &&
|
|
666
|
+
value > 0);
|
|
667
|
+
}
|
|
668
|
+
/** A non-empty string (the file's path/string fields). */
|
|
669
|
+
function isNonEmptyString(value) {
|
|
670
|
+
return typeof value === "string" && value !== "";
|
|
671
|
+
}
|
|
672
|
+
function isNonEmptyStringArray(value) {
|
|
673
|
+
return (Array.isArray(value) &&
|
|
674
|
+
value.every((entry) => isNonEmptyString(entry)));
|
|
675
|
+
}
|
|
676
|
+
/** An enumerable record (not null, not an array). */
|
|
677
|
+
function asRecord(value) {
|
|
678
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
679
|
+
? value
|
|
680
|
+
: undefined;
|
|
681
|
+
}
|
|
682
|
+
/** Every key not in `allowed` → a `SettingsValidationError` naming it. */
|
|
683
|
+
function checkKeys(prefix, record, allowed, reason = "is not a settable field") {
|
|
684
|
+
for (const key of Object.keys(record)) {
|
|
685
|
+
if (!allowed.includes(key)) {
|
|
686
|
+
throw new SettingsValidationError(`${prefix}${key}`, reason);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* A file-backed settings store. All reads go through `read()` — fresh
|
|
692
|
+
* file contents every call, so a PATCH from another process or a hand
|
|
693
|
+
* edit lands on the next resolution.
|
|
694
|
+
*/
|
|
695
|
+
export class SettingsStore {
|
|
696
|
+
path;
|
|
697
|
+
constructor(options = {}) {
|
|
698
|
+
this.path = settingsPath(options.dir ?? defaultSettingsDir());
|
|
699
|
+
}
|
|
700
|
+
/** The current settings; absent file = `{}` (all defaults). */
|
|
701
|
+
read() {
|
|
702
|
+
if (!existsSync(this.path))
|
|
703
|
+
return {};
|
|
704
|
+
const raw = readFileSync(this.path, "utf8");
|
|
705
|
+
return this.parse(raw);
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* First-boot initialization (the settings bootstrap, ruling 5 /
|
|
709
|
+
* spec §6.6): when the file is ABSENT, write ONLY what the
|
|
710
|
+
* environment set — the env-sourced `server` fields (the
|
|
711
|
+
* `envServerSection` projection) plus the top-level `extensions`
|
|
712
|
+
* list and `defaultModel` when env supplied them. Defaults are
|
|
713
|
+
* NEVER persisted, so later releases' default changes reach existing
|
|
714
|
+
* installs; a `server` section with zero entries is omitted entirely
|
|
715
|
+
* (a nothing-env-set boot writes at most the top-level keys). When
|
|
716
|
+
* the file already exists (any content), bootstrap is a no-op: the
|
|
717
|
+
* file is never overwritten, and env vars continue to win over it on
|
|
718
|
+
* the next boot (env-over-file precedence). Idempotent: bootstrapping
|
|
719
|
+
* twice leaves the file byte-identical.
|
|
720
|
+
*/
|
|
721
|
+
bootstrap(resolvedServer, resolved) {
|
|
722
|
+
if (existsSync(this.path))
|
|
723
|
+
return;
|
|
724
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
725
|
+
const file = {
|
|
726
|
+
...(resolved?.extensions !== undefined
|
|
727
|
+
? { extensions: resolved.extensions }
|
|
728
|
+
: {}),
|
|
729
|
+
...(resolved?.defaultModel !== undefined
|
|
730
|
+
? { defaultModel: resolved.defaultModel }
|
|
731
|
+
: {}),
|
|
732
|
+
...(Object.keys(resolvedServer).length === 0
|
|
733
|
+
? {}
|
|
734
|
+
: { server: resolvedServer }),
|
|
735
|
+
};
|
|
736
|
+
writeFileSync(this.path, `${JSON.stringify(file, null, "\t")}\n`);
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Parse file contents. A malformed file throws fail-loud — at boot
|
|
740
|
+
* `openHarness` surfaces this exactly like a bad `SIGRUN_*` value
|
|
741
|
+
* (the same fail-loud posture as `loadConfig`).
|
|
742
|
+
*/
|
|
743
|
+
parse(raw) {
|
|
744
|
+
let parsed;
|
|
745
|
+
try {
|
|
746
|
+
parsed = JSON.parse(raw);
|
|
747
|
+
}
|
|
748
|
+
catch (cause) {
|
|
749
|
+
throw new Error(`settings: ${this.path} is not valid JSON (${cause instanceof Error ? cause.message : String(cause)})`);
|
|
750
|
+
}
|
|
751
|
+
if (typeof parsed !== "object" ||
|
|
752
|
+
parsed === null ||
|
|
753
|
+
Array.isArray(parsed)) {
|
|
754
|
+
throw new Error(`settings: ${this.path} must contain a JSON object`);
|
|
755
|
+
}
|
|
756
|
+
const value = parsed;
|
|
757
|
+
validate(value);
|
|
758
|
+
return value;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Merge `patch` over the current settings and persist. An explicit
|
|
762
|
+
* `null` value clears the key; a sub-object field merges per-key
|
|
763
|
+
* (`null` inside a sub-object clears that sub-field). Returns the
|
|
764
|
+
* new effective settings.
|
|
765
|
+
*
|
|
766
|
+
* @throws SettingsValidationError naming the offending field.
|
|
767
|
+
*/
|
|
768
|
+
update(patch) {
|
|
769
|
+
const merged = {
|
|
770
|
+
...this.read(),
|
|
771
|
+
};
|
|
772
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
773
|
+
if (!SETTING_KEYS.includes(key)) {
|
|
774
|
+
throw new SettingsValidationError(key, "is not a settable field");
|
|
775
|
+
}
|
|
776
|
+
if (value === null) {
|
|
777
|
+
delete merged[key];
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if ((key === "retry" || key === "stream") &&
|
|
781
|
+
typeof value === "object" &&
|
|
782
|
+
value !== null) {
|
|
783
|
+
// Sub-merge: PATCH {retry: {maxRetries: 0}} preserves other
|
|
784
|
+
// retry fields; a null sub-field clears just that one.
|
|
785
|
+
const sub = {
|
|
786
|
+
...(merged[key] ?? {}),
|
|
787
|
+
};
|
|
788
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
789
|
+
if (subValue === null)
|
|
790
|
+
delete sub[subKey];
|
|
791
|
+
else
|
|
792
|
+
sub[subKey] = subValue;
|
|
793
|
+
}
|
|
794
|
+
if (Object.keys(sub).length === 0)
|
|
795
|
+
delete merged[key];
|
|
796
|
+
else
|
|
797
|
+
merged[key] = sub;
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
if (key === "server" &&
|
|
801
|
+
typeof value === "object" &&
|
|
802
|
+
value !== null) {
|
|
803
|
+
// Server sub-merge: PATCH {server: {port: 4080}} preserves the
|
|
804
|
+
// rest of the section (including the bootstrap-written fields);
|
|
805
|
+
// `compaction`/`memorySeam`/`providers`/`webui`/`auth`/`metrics`
|
|
806
|
+
// sub-merge one level deeper (`auth.proxy` two levels); `backup`
|
|
807
|
+
// replaces wholesale (it is a discriminated union — a partial
|
|
808
|
+
// union merge could mix a path target with an s3 one).
|
|
809
|
+
const incoming = asRecord(value);
|
|
810
|
+
if (incoming === undefined) {
|
|
811
|
+
throw new SettingsValidationError("server", "must be an object");
|
|
812
|
+
}
|
|
813
|
+
const sub = {
|
|
814
|
+
...(merged.server ?? {}),
|
|
815
|
+
};
|
|
816
|
+
for (const [subKey, subValue] of Object.entries(incoming)) {
|
|
817
|
+
if (subValue === null)
|
|
818
|
+
delete sub[subKey];
|
|
819
|
+
else if (subKey === "compaction" &&
|
|
820
|
+
typeof subValue === "object") {
|
|
821
|
+
const mergedCompaction = {
|
|
822
|
+
...(sub.compaction ?? {}),
|
|
823
|
+
};
|
|
824
|
+
for (const [compKey, compValue] of Object.entries(subValue)) {
|
|
825
|
+
if (compValue === null)
|
|
826
|
+
delete mergedCompaction[compKey];
|
|
827
|
+
else
|
|
828
|
+
mergedCompaction[compKey] = compValue;
|
|
829
|
+
}
|
|
830
|
+
if (Object.keys(mergedCompaction).length === 0)
|
|
831
|
+
delete sub.compaction;
|
|
832
|
+
else
|
|
833
|
+
sub.compaction = mergedCompaction;
|
|
834
|
+
}
|
|
835
|
+
else if (subKey === "memorySeam" &&
|
|
836
|
+
typeof subValue === "object") {
|
|
837
|
+
// Same sub-merge as compaction: a flat record, merged per-key
|
|
838
|
+
// (a wholesale replace would silently drop unset knobs).
|
|
839
|
+
const mergedSeam = {
|
|
840
|
+
...(sub.memorySeam ?? {}),
|
|
841
|
+
};
|
|
842
|
+
for (const [seamKey, seamValue] of Object.entries(subValue)) {
|
|
843
|
+
if (seamValue === null)
|
|
844
|
+
delete mergedSeam[seamKey];
|
|
845
|
+
else
|
|
846
|
+
mergedSeam[seamKey] = seamValue;
|
|
847
|
+
}
|
|
848
|
+
if (Object.keys(mergedSeam).length === 0)
|
|
849
|
+
delete sub.memorySeam;
|
|
850
|
+
else
|
|
851
|
+
sub.memorySeam = mergedSeam;
|
|
852
|
+
}
|
|
853
|
+
else if (subKey === "providers" &&
|
|
854
|
+
typeof subValue === "object") {
|
|
855
|
+
// Same sub-merge for the provider runtime knobs: a flat
|
|
856
|
+
// record merged per-key (the refresh cadence PATCH never
|
|
857
|
+
// drops the section's other server fields).
|
|
858
|
+
const mergedProviders = {
|
|
859
|
+
...(sub.providers ?? {}),
|
|
860
|
+
};
|
|
861
|
+
for (const [provKey, provValue] of Object.entries(subValue)) {
|
|
862
|
+
if (provValue === null)
|
|
863
|
+
delete mergedProviders[provKey];
|
|
864
|
+
else
|
|
865
|
+
mergedProviders[provKey] = provValue;
|
|
866
|
+
}
|
|
867
|
+
if (Object.keys(mergedProviders).length === 0)
|
|
868
|
+
delete sub.providers;
|
|
869
|
+
else
|
|
870
|
+
sub.providers = mergedProviders;
|
|
871
|
+
}
|
|
872
|
+
else if (subKey === "webui" &&
|
|
873
|
+
typeof subValue === "object") {
|
|
874
|
+
// Same sub-merge for the webui channel section (build-and-webui
|
|
875
|
+
// Task 4): PATCH {server:{webui:{enabled:false}}} preserves a
|
|
876
|
+
// previously set bundle — a wholesale replace would silently
|
|
877
|
+
// drop the operator's selector.
|
|
878
|
+
const mergedWebui = {
|
|
879
|
+
...(sub.webui ?? {}),
|
|
880
|
+
};
|
|
881
|
+
for (const [webuiKey, webuiValue] of Object.entries(subValue)) {
|
|
882
|
+
if (webuiValue === null)
|
|
883
|
+
delete mergedWebui[webuiKey];
|
|
884
|
+
else
|
|
885
|
+
mergedWebui[webuiKey] = webuiValue;
|
|
886
|
+
}
|
|
887
|
+
if (Object.keys(mergedWebui).length === 0)
|
|
888
|
+
delete sub.webui;
|
|
889
|
+
else
|
|
890
|
+
sub.webui = mergedWebui;
|
|
891
|
+
}
|
|
892
|
+
else if (subKey === "auth" &&
|
|
893
|
+
typeof subValue === "object") {
|
|
894
|
+
// The auth section (core-hardening W1) sub-merges per-key like
|
|
895
|
+
// webui, with `proxy` one level deeper still: PATCH
|
|
896
|
+
// {server:{auth:{proxy:{autoProvision:false}}}} preserves a
|
|
897
|
+
// stored trustedProxies — a wholesale replace would silently
|
|
898
|
+
// turn a proxy deployment into "trust no one" (or worse, keep
|
|
899
|
+
// trusting with different headers).
|
|
900
|
+
const mergedAuth = {
|
|
901
|
+
...(sub.auth ?? {}),
|
|
902
|
+
};
|
|
903
|
+
for (const [authKey, authValue] of Object.entries(subValue)) {
|
|
904
|
+
if (authValue === null)
|
|
905
|
+
delete mergedAuth[authKey];
|
|
906
|
+
else if (authKey === "proxy" &&
|
|
907
|
+
typeof authValue === "object") {
|
|
908
|
+
const mergedProxy = {
|
|
909
|
+
...(mergedAuth.proxy ?? {}),
|
|
910
|
+
};
|
|
911
|
+
for (const [proxyKey, proxyValue] of Object.entries(authValue)) {
|
|
912
|
+
if (proxyValue === null)
|
|
913
|
+
delete mergedProxy[proxyKey];
|
|
914
|
+
else
|
|
915
|
+
mergedProxy[proxyKey] = proxyValue;
|
|
916
|
+
}
|
|
917
|
+
if (Object.keys(mergedProxy).length === 0)
|
|
918
|
+
delete mergedAuth.proxy;
|
|
919
|
+
else
|
|
920
|
+
mergedAuth.proxy = mergedProxy;
|
|
921
|
+
}
|
|
922
|
+
else
|
|
923
|
+
mergedAuth[authKey] = authValue;
|
|
924
|
+
}
|
|
925
|
+
if (Object.keys(mergedAuth).length === 0)
|
|
926
|
+
delete sub.auth;
|
|
927
|
+
else
|
|
928
|
+
sub.auth = mergedAuth;
|
|
929
|
+
}
|
|
930
|
+
else if (subKey === "metrics" &&
|
|
931
|
+
typeof subValue === "object") {
|
|
932
|
+
const mergedMetrics = {
|
|
933
|
+
...(sub.metrics ?? {}),
|
|
934
|
+
};
|
|
935
|
+
for (const [metricsKey, metricsValue] of Object.entries(subValue)) {
|
|
936
|
+
if (metricsValue === null)
|
|
937
|
+
delete mergedMetrics[metricsKey];
|
|
938
|
+
else
|
|
939
|
+
mergedMetrics[metricsKey] = metricsValue;
|
|
940
|
+
}
|
|
941
|
+
if (Object.keys(mergedMetrics).length === 0)
|
|
942
|
+
delete sub.metrics;
|
|
943
|
+
else
|
|
944
|
+
sub.metrics = mergedMetrics;
|
|
945
|
+
}
|
|
946
|
+
else
|
|
947
|
+
sub[subKey] = subValue;
|
|
948
|
+
}
|
|
949
|
+
if (Object.keys(sub).length === 0)
|
|
950
|
+
delete merged.server;
|
|
951
|
+
else
|
|
952
|
+
merged.server = sub;
|
|
953
|
+
continue;
|
|
954
|
+
}
|
|
955
|
+
if (key === "defaultProvider" &&
|
|
956
|
+
typeof value === "object" &&
|
|
957
|
+
value !== null) {
|
|
958
|
+
// Same sub-merge as retry/stream: a null sub-field clears just
|
|
959
|
+
// that field of the provider section.
|
|
960
|
+
const sub = {
|
|
961
|
+
...(merged[key] ?? {}),
|
|
962
|
+
};
|
|
963
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
964
|
+
if (subValue === null)
|
|
965
|
+
delete sub[subKey];
|
|
966
|
+
else
|
|
967
|
+
sub[subKey] = subValue;
|
|
968
|
+
}
|
|
969
|
+
if (Object.keys(sub).length === 0)
|
|
970
|
+
delete merged[key];
|
|
971
|
+
else
|
|
972
|
+
merged[key] = sub;
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
if (key === "perModelThinking" &&
|
|
976
|
+
typeof value === "object" &&
|
|
977
|
+
value !== null) {
|
|
978
|
+
// Same sub-merge for the model-ref map: a null level clears
|
|
979
|
+
// that model's override.
|
|
980
|
+
const sub = {
|
|
981
|
+
...(merged[key] ?? {}),
|
|
982
|
+
};
|
|
983
|
+
for (const [model, level] of Object.entries(value)) {
|
|
984
|
+
if (level === null)
|
|
985
|
+
delete sub[model];
|
|
986
|
+
else
|
|
987
|
+
sub[model] = level;
|
|
988
|
+
}
|
|
989
|
+
if (Object.keys(sub).length === 0)
|
|
990
|
+
delete merged[key];
|
|
991
|
+
else
|
|
992
|
+
merged[key] = sub;
|
|
993
|
+
continue;
|
|
994
|
+
}
|
|
995
|
+
if (key === "skills" &&
|
|
996
|
+
typeof value === "object" &&
|
|
997
|
+
value !== null) {
|
|
998
|
+
// Same sub-merge as defaultProvider: the live skills section
|
|
999
|
+
// merges per-key (a null sub-field clears just that knob).
|
|
1000
|
+
const sub = {
|
|
1001
|
+
...(merged[key] ?? {}),
|
|
1002
|
+
};
|
|
1003
|
+
for (const [subKey, subValue] of Object.entries(value)) {
|
|
1004
|
+
if (subValue === null)
|
|
1005
|
+
delete sub[subKey];
|
|
1006
|
+
else
|
|
1007
|
+
sub[subKey] = subValue;
|
|
1008
|
+
}
|
|
1009
|
+
if (Object.keys(sub).length === 0)
|
|
1010
|
+
delete merged[key];
|
|
1011
|
+
else
|
|
1012
|
+
merged[key] = sub;
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
merged[key] = value;
|
|
1016
|
+
}
|
|
1017
|
+
validate(merged);
|
|
1018
|
+
const file = merged;
|
|
1019
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
1020
|
+
writeFileSync(this.path, `${JSON.stringify(file, null, "\t")}\n`);
|
|
1021
|
+
return file;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* A live `HarnessSettings` object for `openHarness`: every property
|
|
1025
|
+
* reads the file AT ACCESS TIME, so pi-durable's per-resolution
|
|
1026
|
+
* `resolveSettings()` observes PATCHed changes without a restart.
|
|
1027
|
+
* Called once per harness boot; the returned object must never be
|
|
1028
|
+
* copied or snapshotted (pi-durable's contract) — `openHarness`
|
|
1029
|
+
* composes it getter-preserving (caller seam first, file second),
|
|
1030
|
+
* never via a spread, which would freeze the boot values.
|
|
1031
|
+
*/
|
|
1032
|
+
harnessSettings() {
|
|
1033
|
+
const store = this;
|
|
1034
|
+
// Eager validation: a malformed file throws HERE, at boot (the
|
|
1035
|
+
// fail-loud posture), even though the getters below defer their
|
|
1036
|
+
// first read to pi-durable's first resolution.
|
|
1037
|
+
store.read();
|
|
1038
|
+
return {
|
|
1039
|
+
get steeringMode() {
|
|
1040
|
+
return store.read().steeringMode;
|
|
1041
|
+
},
|
|
1042
|
+
get followUpMode() {
|
|
1043
|
+
return store.read().followUpMode;
|
|
1044
|
+
},
|
|
1045
|
+
get retry() {
|
|
1046
|
+
return store.read().retry;
|
|
1047
|
+
},
|
|
1048
|
+
get stream() {
|
|
1049
|
+
return store.read().stream;
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
}
|