@worca/app 0.0.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/README.md +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- package/ui/server.mjs +3573 -0
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
// src/core/settings.mjs
|
|
2
|
+
// Global Worca CC settings, persisted at a FIXED bootstrap location that never
|
|
3
|
+
// moves: <home>/.worca-cc/settings.json. Keys:
|
|
4
|
+
// root — base folder under which Worca CC keeps its .worca-cc
|
|
5
|
+
// data dir (history store, projects.json, workflows);
|
|
6
|
+
// projects.mjs#worcaHome() reads it to resolve where
|
|
7
|
+
// everything lives.
|
|
8
|
+
// runRootMode — §10 master switch, 'detached' | 'legacy'.
|
|
9
|
+
// projectsRoot — §5.1 the top-level folder the user's projects live
|
|
10
|
+
// under; the root layer of generated run context.
|
|
11
|
+
// contextMaxBytesPerFile — §5.4 per-source-file inlining cap.
|
|
12
|
+
// contextMaxBytesTotal — §5.4 total memory budget.
|
|
13
|
+
// skillMount — §5.6 'copy' (default) | 'symlink' (opt-in).
|
|
14
|
+
// pipelineCostLimitUsd — per-pipeline lifetime USD spend cap; unset = no limit.
|
|
15
|
+
// totalCostLimitUsd — windowed all-pipelines USD spend cap; unset = no limit.
|
|
16
|
+
// costLimitResetPeriod — total-budget window, 'weekly' | 'monthly' (default).
|
|
17
|
+
// models — the global model catalog (configurable-models-design.md
|
|
18
|
+
// §4.1): [{id, label?, efforts?, env?}]. Entries shadow
|
|
19
|
+
// PREDEFINED_MODELS by id; env is per-model routing env
|
|
20
|
+
// merged into the claude spawn (reserved keys rejected).
|
|
21
|
+
// All of them are OPTIONAL and read through the same read-modify-write object, so
|
|
22
|
+
// a new key needs no migration and never disturbs the others (unknown keys — e.g.
|
|
23
|
+
// written by a newer version — survive a write by the same property).
|
|
24
|
+
//
|
|
25
|
+
// node:sqlite migration note: `root` deliberately stays here in settings.json and
|
|
26
|
+
// is NOT moved into the DB — it is the bootstrap that LOCATES the DB file
|
|
27
|
+
// (worcaHome()/worca-cc.db), so it cannot live inside the DB (chicken/egg). The
|
|
28
|
+
// v1 schema has no settings table by design; every key above is either a
|
|
29
|
+
// bootstrap value or a plain scalar toggle, so a table would buy nothing.
|
|
30
|
+
//
|
|
31
|
+
// IMPORTANT: this module imports NOTHING from the core graph (Node builtins
|
|
32
|
+
// plus the zero-import model-env.mjs leaf only). projects.mjs imports it, so
|
|
33
|
+
// importing projects.mjs back would make worcaHome() -> getWorcaRoot() ->
|
|
34
|
+
// projects.mjs an infinite cycle.
|
|
35
|
+
//
|
|
36
|
+
// Reads are synchronous + never-throwing (worcaHome's callers are sync). There
|
|
37
|
+
// is deliberately no in-module cache: worcaHome() is read fresh per operation,
|
|
38
|
+
// so a saved root takes effect for new runs/listing without a server restart.
|
|
39
|
+
|
|
40
|
+
import { mkdir, writeFile, rename } from 'node:fs/promises';
|
|
41
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
42
|
+
import { join, resolve } from 'node:path';
|
|
43
|
+
import { homedir } from 'node:os';
|
|
44
|
+
import { randomBytes } from 'node:crypto';
|
|
45
|
+
import { EFFORTS, isReservedModelEnvKey } from './model-env.mjs';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The real OS home base, honoring HOME/USERPROFILE so tests can sandbox it.
|
|
49
|
+
* This is the DEFAULT Worca CC root when nothing is configured, and the parent
|
|
50
|
+
* of the fixed settings file. (Mirrors normalizeProjectPath's tilde idiom.)
|
|
51
|
+
*/
|
|
52
|
+
export function defaultRoot() {
|
|
53
|
+
return process.env.HOME || process.env.USERPROFILE || homedir();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Fixed bootstrap path — ALWAYS under defaultRoot(), never the movable root. */
|
|
57
|
+
export function settingsFile() {
|
|
58
|
+
return join(defaultRoot(), '.worca-cc', 'settings.json');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read settings synchronously. Missing/corrupt/non-object -> {}. Never throws. */
|
|
62
|
+
export function readSettings() {
|
|
63
|
+
try {
|
|
64
|
+
const data = JSON.parse(readFileSync(settingsFile(), 'utf8'));
|
|
65
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
66
|
+
} catch {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The configured root base, or '' when unset/blank. Synchronous, never throws. */
|
|
72
|
+
export function getWorcaRoot() {
|
|
73
|
+
const r = readSettings().root;
|
|
74
|
+
return typeof r === 'string' && r.trim() ? r : '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const DEFAULT_RUN_ROOT_MODE = 'detached'; // Phase-5 flip landed; WORCA_RUN_ROOT=legacy is the §10 rollback
|
|
78
|
+
|
|
79
|
+
/** Effective run-root mode. Precedence: WORCA_RUN_ROOT env → settings.runRootMode →
|
|
80
|
+
* DEFAULT_RUN_ROOT_MODE. Values are validated to 'detached' | 'legacy'; anything
|
|
81
|
+
* else falls back to the default with a console warning naming the bad value.
|
|
82
|
+
* Read FRESH on every call — never cached at module load (tests pin the env per
|
|
83
|
+
* test; the orchestrator reads it exactly once per pipeline, at _setupRunRoot). */
|
|
84
|
+
export function runRootMode() {
|
|
85
|
+
const env = process.env.WORCA_RUN_ROOT;
|
|
86
|
+
const cfg = readSettings().runRootMode;
|
|
87
|
+
const raw = (env && env.trim()) || (typeof cfg === 'string' && cfg.trim()) || DEFAULT_RUN_ROOT_MODE;
|
|
88
|
+
if (raw === 'detached' || raw === 'legacy') return raw;
|
|
89
|
+
console.warn(`[worca] invalid run-root mode ${JSON.stringify(raw)} — using ${DEFAULT_RUN_ROOT_MODE}`);
|
|
90
|
+
return DEFAULT_RUN_ROOT_MODE;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function expandTilde(p) {
|
|
94
|
+
return p.startsWith('~') ? join(defaultRoot(), p.slice(1)) : p;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Atomically persist the whole settings object (temp+rename), pre-creating the
|
|
99
|
+
* fixed bootstrap dir the settings file lives in. Every setter below funnels
|
|
100
|
+
* through here, so they all share one write shape and one atomicity guarantee.
|
|
101
|
+
* Callers pass the object they got from readSettings() with their own key
|
|
102
|
+
* added/deleted — that read-modify-write is what makes unknown keys survive
|
|
103
|
+
* (no migration, no key loss; §5.1 storage note).
|
|
104
|
+
*/
|
|
105
|
+
async function persistSettings(settings) {
|
|
106
|
+
await mkdir(join(defaultRoot(), '.worca-cc'), { recursive: true }); // bootstrap dir
|
|
107
|
+
const file = settingsFile();
|
|
108
|
+
const tmp = `${file}.${randomBytes(4).toString('hex')}.tmp`;
|
|
109
|
+
await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
110
|
+
await rename(tmp, file);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Persist the chosen root base. Pass '' / null / non-string to CLEAR it (reset
|
|
115
|
+
* to default). A non-empty value is resolved to an absolute path and validated:
|
|
116
|
+
* it must not be an existing non-directory, and <base>/.worca-cc must be
|
|
117
|
+
* creatable (this both validates writability and pre-creates the dir). Atomic
|
|
118
|
+
* temp+rename. Returns { root, default } describing the resulting state.
|
|
119
|
+
* @throws {Error} when the path cannot be used as a root.
|
|
120
|
+
*/
|
|
121
|
+
export async function setWorcaRoot(input) {
|
|
122
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
123
|
+
const settings = readSettings();
|
|
124
|
+
|
|
125
|
+
if (!raw) {
|
|
126
|
+
delete settings.root; // reset to default
|
|
127
|
+
} else {
|
|
128
|
+
const base = resolve(expandTilde(raw));
|
|
129
|
+
if (existsSync(base) && !statSync(base).isDirectory()) {
|
|
130
|
+
throw new Error('path is not a directory');
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
await mkdir(join(base, '.worca-cc'), { recursive: true });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
throw new Error(`cannot use this folder as the Worca CC root: ${err.message}`);
|
|
136
|
+
}
|
|
137
|
+
settings.root = base;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await persistSettings(settings);
|
|
141
|
+
return { root: settings.root || '', default: defaultRoot() };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// §5.1 projectsRoot — "the top-level folder under which your projects live".
|
|
146
|
+
//
|
|
147
|
+
// DELIBERATELY NOT derived from `root`: `root` means "where worca-cc's data
|
|
148
|
+
// lives", so relocating the data dir to an external volume must not silently
|
|
149
|
+
// relocate the user's instruction root.
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The effective projects root. Precedence: WORCA_PROJECTS_ROOT env →
|
|
154
|
+
* settings.projectsRoot → defaultRoot(). ALWAYS an absolute path (never '',
|
|
155
|
+
* unlike getWorcaRoot()) — the root context layer must always have a base.
|
|
156
|
+
*
|
|
157
|
+
* Only the SETTER validates dir-ness. The env tier passes through unchecked, so a
|
|
158
|
+
* WORCA_PROJECTS_ROOT pointing at a nonexistent path degrades at *read* time
|
|
159
|
+
* (the root layer contributes nothing + one named warning, §8.20) instead of
|
|
160
|
+
* throwing here. Read fresh on every call — never cached.
|
|
161
|
+
*/
|
|
162
|
+
export function getProjectsRoot() {
|
|
163
|
+
const env = process.env.WORCA_PROJECTS_ROOT;
|
|
164
|
+
if (env && env.trim()) return resolve(expandTilde(env.trim()));
|
|
165
|
+
const r = readSettings().projectsRoot;
|
|
166
|
+
return typeof r === 'string' && r.trim() ? resolve(expandTilde(r.trim())) : defaultRoot();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The RAW persisted projectsRoot — '' when the key is absent, blank, or not a
|
|
171
|
+
* string. The exact mirror of getWorcaRoot(), and what the settings UI puts in
|
|
172
|
+
* its field: "unset" must stay distinguishable from "explicitly set", so a blank
|
|
173
|
+
* field can round-trip as blank and the "leave blank to use your home folder"
|
|
174
|
+
* affordance is real.
|
|
175
|
+
*
|
|
176
|
+
* DELIBERATELY ignores the WORCA_PROJECTS_ROOT env tier: the env is an
|
|
177
|
+
* override, not a setting, and surfacing it as the field value would let a plain
|
|
178
|
+
* Save promote it into settings.json — persisting a root the user never authored
|
|
179
|
+
* and outliving the env var. Runs still resolve through getProjectsRoot(), which
|
|
180
|
+
* is the sole authority on the EFFECTIVE value and is unchanged by this reader.
|
|
181
|
+
*/
|
|
182
|
+
export function rawProjectsRoot() {
|
|
183
|
+
const r = readSettings().projectsRoot;
|
|
184
|
+
return typeof r === 'string' && r.trim() ? resolve(expandTilde(r.trim())) : '';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* What applies when projectsRoot is left blank — the env tier if it is exported,
|
|
189
|
+
* else defaultRoot(). This is the settings-UI placeholder, i.e. the honest answer
|
|
190
|
+
* to "what do I get if I clear this field?", and it is why the API cannot just
|
|
191
|
+
* reuse `default` (which is defaultRoot(), the worca-cc root default, and would
|
|
192
|
+
* lie whenever WORCA_PROJECTS_ROOT is set).
|
|
193
|
+
*
|
|
194
|
+
* Mirrors the first and last tiers of getProjectsRoot()'s precedence; that
|
|
195
|
+
* function stays the single source of truth for the effective value consumed by
|
|
196
|
+
* a run and is intentionally left untouched.
|
|
197
|
+
*/
|
|
198
|
+
export function defaultProjectsRoot() {
|
|
199
|
+
const env = process.env.WORCA_PROJECTS_ROOT;
|
|
200
|
+
if (env && env.trim()) return resolve(expandTilde(env.trim()));
|
|
201
|
+
return defaultRoot();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Persist the projects root. Pass '' / null / non-string to CLEAR it (reset to
|
|
206
|
+
* defaultRoot()). A non-empty value is `~`-expanded, resolved absolute, and must
|
|
207
|
+
* be an EXISTING directory — worca-cc never writes anything under projectsRoot
|
|
208
|
+
* (this is the one divergence from setWorcaRoot, which pre-creates
|
|
209
|
+
* <base>/.worca-cc to prove writability; there is nothing to pre-create here, and
|
|
210
|
+
* silently mkdir-ing a mistyped path would be worse than rejecting it).
|
|
211
|
+
* Atomic temp+rename over the same read-modify-write object, so `root` and every
|
|
212
|
+
* other/unknown key survive.
|
|
213
|
+
* @returns {{projectsRoot: string, default: string}} the RAW persisted state
|
|
214
|
+
* ('' after a reset), exactly as setWorcaRoot reports `root`. Raw, not
|
|
215
|
+
* effective, so a save round-trips to what the caller typed — a blank stays
|
|
216
|
+
* blank instead of echoing the default (or an env override) back as if it had
|
|
217
|
+
* been stored. `default` is what applies when it is blank (defaultProjectsRoot).
|
|
218
|
+
* @throws {Error} when the path cannot be used as the projects root.
|
|
219
|
+
*/
|
|
220
|
+
export async function setProjectsRoot(input) {
|
|
221
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
222
|
+
const settings = readSettings();
|
|
223
|
+
|
|
224
|
+
if (!raw) {
|
|
225
|
+
delete settings.projectsRoot; // reset to default
|
|
226
|
+
} else {
|
|
227
|
+
const base = resolve(expandTilde(raw));
|
|
228
|
+
if (!existsSync(base)) throw new Error('path does not exist');
|
|
229
|
+
if (!statSync(base).isDirectory()) throw new Error('path is not a directory');
|
|
230
|
+
settings.projectsRoot = base;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
await persistSettings(settings);
|
|
234
|
+
return { projectsRoot: settings.projectsRoot || '', default: defaultProjectsRoot() };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// §5.4 / §5.6 scalars. Settings-file-only in this change (no UI field, no API):
|
|
239
|
+
// the escape hatch for a member whose memory exceeds a cap, and the opt-in
|
|
240
|
+
// write-through skill mount. Readers mirror runRootMode(): validate, and on an
|
|
241
|
+
// invalid hand-written value fall back to the default with a warning naming it
|
|
242
|
+
// (reads are never-throwing by this module's contract). Setters reject instead —
|
|
243
|
+
// a programmatic write of a bad value is a caller bug, not a degraded file.
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
export const DEFAULT_CONTEXT_MAX_BYTES_PER_FILE = 20480; // 20 KB per source file
|
|
247
|
+
export const DEFAULT_CONTEXT_MAX_BYTES_TOTAL = 65536; // 64 KB total memory budget
|
|
248
|
+
export const DEFAULT_SKILL_MOUNT = 'copy'; // 'symlink' is the opt-in variant
|
|
249
|
+
|
|
250
|
+
const SKILL_MOUNTS = ['copy', 'symlink'];
|
|
251
|
+
|
|
252
|
+
/** A byte cap must be a positive whole number of bytes; nothing else is meaningful. */
|
|
253
|
+
const isByteCap = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v > 0;
|
|
254
|
+
|
|
255
|
+
/** Read a numeric cap key, falling back (loudly) to `fallback` on a bad value. */
|
|
256
|
+
function readByteCap(key, fallback) {
|
|
257
|
+
const v = readSettings()[key];
|
|
258
|
+
if (v === undefined) return fallback;
|
|
259
|
+
if (isByteCap(v)) return v;
|
|
260
|
+
console.warn(`[worca] invalid ${key} ${JSON.stringify(v)} — using ${fallback}`);
|
|
261
|
+
return fallback;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Per-source-file inlining cap for generated run context (§5.4). */
|
|
265
|
+
export function contextMaxBytesPerFile() {
|
|
266
|
+
return readByteCap('contextMaxBytesPerFile', DEFAULT_CONTEXT_MAX_BYTES_PER_FILE);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Total memory budget for generated run context (§5.4). */
|
|
270
|
+
export function contextMaxBytesTotal() {
|
|
271
|
+
return readByteCap('contextMaxBytesTotal', DEFAULT_CONTEXT_MAX_BYTES_TOTAL);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Skill delivery mechanism (§5.6): 'copy' (default, isolated) | 'symlink' (write-through). */
|
|
275
|
+
export function skillMount() {
|
|
276
|
+
const v = readSettings().skillMount;
|
|
277
|
+
if (v === undefined) return DEFAULT_SKILL_MOUNT;
|
|
278
|
+
if (SKILL_MOUNTS.includes(v)) return v;
|
|
279
|
+
console.warn(`[worca] invalid skillMount ${JSON.stringify(v)} — using ${DEFAULT_SKILL_MOUNT}`);
|
|
280
|
+
return DEFAULT_SKILL_MOUNT;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Write (or, on '' / null / undefined input, delete) a numeric cap key. */
|
|
284
|
+
async function setByteCap(key, input, fallback) {
|
|
285
|
+
const settings = readSettings();
|
|
286
|
+
if (input === '' || input === null || input === undefined) {
|
|
287
|
+
delete settings[key]; // reset to the built-in default
|
|
288
|
+
} else if (isByteCap(input)) {
|
|
289
|
+
settings[key] = input;
|
|
290
|
+
} else {
|
|
291
|
+
throw new Error(`${key} must be a positive integer number of bytes`);
|
|
292
|
+
}
|
|
293
|
+
await persistSettings(settings);
|
|
294
|
+
return { [key]: readByteCap(key, fallback) }; // the EFFECTIVE value
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** @throws {Error} unless `input` is a positive integer (or empty, which resets). */
|
|
298
|
+
export const setContextMaxBytesPerFile = (input) =>
|
|
299
|
+
setByteCap('contextMaxBytesPerFile', input, DEFAULT_CONTEXT_MAX_BYTES_PER_FILE);
|
|
300
|
+
|
|
301
|
+
/** @throws {Error} unless `input` is a positive integer (or empty, which resets). */
|
|
302
|
+
export const setContextMaxBytesTotal = (input) =>
|
|
303
|
+
setByteCap('contextMaxBytesTotal', input, DEFAULT_CONTEXT_MAX_BYTES_TOTAL);
|
|
304
|
+
|
|
305
|
+
/** @throws {Error} unless `input` is 'copy' | 'symlink' (or empty, which resets). */
|
|
306
|
+
export async function setSkillMount(input) {
|
|
307
|
+
const settings = readSettings();
|
|
308
|
+
if (input === '' || input === null || input === undefined) {
|
|
309
|
+
delete settings.skillMount; // reset to 'copy'
|
|
310
|
+
} else if (SKILL_MOUNTS.includes(input)) {
|
|
311
|
+
settings.skillMount = input;
|
|
312
|
+
} else {
|
|
313
|
+
throw new Error(`skillMount must be one of ${SKILL_MOUNTS.join(' | ')}`);
|
|
314
|
+
}
|
|
315
|
+
await persistSettings(settings);
|
|
316
|
+
return { skillMount: skillMount() };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Cost limits (spec 2026-08-07). Readers fall back loudly to null (= no limit)
|
|
321
|
+
// or the default period; setters throw; '' / null / undefined clears the key.
|
|
322
|
+
|
|
323
|
+
export const COST_RESET_PERIODS = ['weekly', 'monthly'];
|
|
324
|
+
export const DEFAULT_COST_RESET_PERIOD = 'monthly';
|
|
325
|
+
|
|
326
|
+
/** A USD cap is a positive finite number (fractional dollars allowed). */
|
|
327
|
+
const isUsdCap = (v) => typeof v === 'number' && Number.isFinite(v) && v > 0;
|
|
328
|
+
|
|
329
|
+
/** Read a USD cap key: number, or null = unlimited. */
|
|
330
|
+
function readUsdCap(key) {
|
|
331
|
+
const v = readSettings()[key];
|
|
332
|
+
if (v === undefined) return null;
|
|
333
|
+
if (isUsdCap(v)) return v;
|
|
334
|
+
console.warn(`[worca] invalid ${key} ${JSON.stringify(v)} — treating as unset (no limit)`);
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Per-pipeline lifetime spend cap in USD, or null (no limit). */
|
|
339
|
+
export function pipelineCostLimitUsd() { return readUsdCap('pipelineCostLimitUsd'); }
|
|
340
|
+
/** Windowed all-pipelines spend cap in USD, or null (no limit). */
|
|
341
|
+
export function totalCostLimitUsd() { return readUsdCap('totalCostLimitUsd'); }
|
|
342
|
+
|
|
343
|
+
/** Reset period for the total budget window: 'weekly' (Mon 00:00) | 'monthly' (1st 00:00). */
|
|
344
|
+
export function costLimitResetPeriod() {
|
|
345
|
+
const v = readSettings().costLimitResetPeriod;
|
|
346
|
+
if (v === undefined) return DEFAULT_COST_RESET_PERIOD;
|
|
347
|
+
if (COST_RESET_PERIODS.includes(v)) return v;
|
|
348
|
+
console.warn(`[worca] invalid costLimitResetPeriod ${JSON.stringify(v)} — using ${DEFAULT_COST_RESET_PERIOD}`);
|
|
349
|
+
return DEFAULT_COST_RESET_PERIOD;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** '' / null / undefined all mean "clear this key" on the write path. */
|
|
353
|
+
const isClearInput = (v) => v === '' || v === null || v === undefined;
|
|
354
|
+
|
|
355
|
+
/** @throws {Error} unless `input` is a positive finite number (or a clear). */
|
|
356
|
+
function assertUsdCapInput(key, input) {
|
|
357
|
+
if (!isClearInput(input) && !isUsdCap(input)) {
|
|
358
|
+
throw new Error(`${key} must be a positive number of USD`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** @throws {Error} unless `input` is 'weekly' | 'monthly' (or a clear). */
|
|
363
|
+
function assertResetPeriodInput(input) {
|
|
364
|
+
if (!isClearInput(input) && !COST_RESET_PERIODS.includes(input)) {
|
|
365
|
+
throw new Error(`costLimitResetPeriod must be one of ${COST_RESET_PERIODS.join(' | ')}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Validate a whole cost-limit write SET before any of it is persisted. The three
|
|
371
|
+
* setters each persist on their own, so a multi-key write whose second key is
|
|
372
|
+
* invalid would otherwise leave the first one on disk and still fail — a caller
|
|
373
|
+
* that reports the failure (and repaints its pre-save values) would then be out of
|
|
374
|
+
* sync with a half-applied settings file. Only the keys PRESENT on `inputs` are
|
|
375
|
+
* checked; a key set to undefined means "clear", so use hasOwnProperty semantics
|
|
376
|
+
* at the call site to decide what to include.
|
|
377
|
+
* @param {{pipelineCostLimitUsd?: *, totalCostLimitUsd?: *, costLimitResetPeriod?: *}} inputs
|
|
378
|
+
* @throws {Error} on the first invalid input
|
|
379
|
+
*/
|
|
380
|
+
export function assertCostLimitInputs(inputs = {}) {
|
|
381
|
+
const has = (k) => Object.prototype.hasOwnProperty.call(inputs, k);
|
|
382
|
+
if (has('pipelineCostLimitUsd')) assertUsdCapInput('pipelineCostLimitUsd', inputs.pipelineCostLimitUsd);
|
|
383
|
+
if (has('totalCostLimitUsd')) assertUsdCapInput('totalCostLimitUsd', inputs.totalCostLimitUsd);
|
|
384
|
+
if (has('costLimitResetPeriod')) assertResetPeriodInput(inputs.costLimitResetPeriod);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Write (or clear) a USD cap key. @throws {Error} unless positive finite number (or empty). */
|
|
388
|
+
async function setUsdCap(key, input) {
|
|
389
|
+
assertUsdCapInput(key, input);
|
|
390
|
+
const settings = readSettings();
|
|
391
|
+
if (isClearInput(input)) delete settings[key]; // reset to unlimited
|
|
392
|
+
else settings[key] = input;
|
|
393
|
+
await persistSettings(settings);
|
|
394
|
+
return { [key]: readUsdCap(key) }; // the EFFECTIVE value
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** @throws {Error} unless `input` is a positive number (or empty, which clears). */
|
|
398
|
+
export const setPipelineCostLimitUsd = (input) => setUsdCap('pipelineCostLimitUsd', input);
|
|
399
|
+
|
|
400
|
+
/** @throws {Error} unless `input` is a positive number (or empty, which clears). */
|
|
401
|
+
export const setTotalCostLimitUsd = (input) => setUsdCap('totalCostLimitUsd', input);
|
|
402
|
+
|
|
403
|
+
// ── chat notification preferences (chat-connectivity-design.md §4.5) ─────────
|
|
404
|
+
|
|
405
|
+
const CHAT_NOTIFY_EVENTS = ['done', 'error', 'question', 'paused'];
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Effective chat notification prefs. Every event defaults ON; channels default
|
|
409
|
+
* enabled (an absent "<plugin>/<channelId>" key means enabled — presence with
|
|
410
|
+
* {enabled:false} is the opt-out record).
|
|
411
|
+
* @returns {{notify: Record<string, boolean>, channels: Record<string, {enabled: boolean}>}}
|
|
412
|
+
*/
|
|
413
|
+
export function chatPrefs() {
|
|
414
|
+
const raw = readSettings().chat;
|
|
415
|
+
const chat = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
416
|
+
const notify = {};
|
|
417
|
+
for (const ev of CHAT_NOTIFY_EVENTS) notify[ev] = chat.notify?.[ev] !== false;
|
|
418
|
+
const channels = {};
|
|
419
|
+
for (const [key, v] of Object.entries(chat.channels && typeof chat.channels === 'object' ? chat.channels : {})) {
|
|
420
|
+
channels[key] = { enabled: v?.enabled !== false };
|
|
421
|
+
}
|
|
422
|
+
return { notify, channels };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Merge-patch the chat prefs: {notify?: {done?, error?, question?, paused?},
|
|
427
|
+
* channels?: {"<plugin>/<id>"?: {enabled: boolean}}}. Unknown notify keys are
|
|
428
|
+
* rejected (400 at the API layer); channels merge per key.
|
|
429
|
+
*/
|
|
430
|
+
export async function setChatPrefs(patch = {}) {
|
|
431
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('chat prefs must be an object');
|
|
432
|
+
for (const k of Object.keys(patch.notify || {})) {
|
|
433
|
+
if (!CHAT_NOTIFY_EVENTS.includes(k)) throw new Error(`unknown chat notify event "${k}"`);
|
|
434
|
+
}
|
|
435
|
+
const settings = readSettings();
|
|
436
|
+
const cur = settings.chat && typeof settings.chat === 'object' ? settings.chat : {};
|
|
437
|
+
settings.chat = {
|
|
438
|
+
...cur,
|
|
439
|
+
...(patch.notify ? { notify: { ...cur.notify, ...Object.fromEntries(Object.entries(patch.notify).map(([k, v]) => [k, v !== false])) } } : {}),
|
|
440
|
+
...(patch.channels ? {
|
|
441
|
+
channels: {
|
|
442
|
+
...cur.channels,
|
|
443
|
+
...Object.fromEntries(Object.entries(patch.channels).map(([k, v]) => [k, { enabled: v?.enabled !== false }])),
|
|
444
|
+
},
|
|
445
|
+
} : {}),
|
|
446
|
+
};
|
|
447
|
+
await persistSettings(settings);
|
|
448
|
+
return chatPrefs();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** @throws {Error} unless `input` is 'weekly' | 'monthly' (or empty, which resets). */
|
|
452
|
+
export async function setCostLimitResetPeriod(input) {
|
|
453
|
+
assertResetPeriodInput(input);
|
|
454
|
+
const settings = readSettings();
|
|
455
|
+
if (isClearInput(input)) delete settings.costLimitResetPeriod; // reset to 'monthly'
|
|
456
|
+
else settings.costLimitResetPeriod = input;
|
|
457
|
+
await persistSettings(settings);
|
|
458
|
+
return { costLimitResetPeriod: costLimitResetPeriod() };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// Global model catalog (configurable-models-design.md §4.1). Stored entries are
|
|
463
|
+
// MINIMAL — label only when it differs from id, efforts only when a proper
|
|
464
|
+
// subset, env only when non-empty — so an entry with default metadata keeps
|
|
465
|
+
// tracking a future EFFORTS change instead of freezing today's list. Readers
|
|
466
|
+
// are loud-and-lenient per this module's contract; setters throw. Effort
|
|
467
|
+
// SUBSET validation happens here; catalog COMPOSITION (shadowing
|
|
468
|
+
// PREDEFINED_MODELS, legacy per-project entries) is config.mjs's job.
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
/** Order-normalize an efforts subset to EFFORTS order, deduplicated. */
|
|
472
|
+
const orderEfforts = (list) => EFFORTS.filter((e) => list.includes(e));
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Sanitize one raw catalog entry to its EFFECTIVE shape, or null when it is
|
|
476
|
+
* not salvageable (no id). Unknown efforts and reserved/malformed env pairs
|
|
477
|
+
* are dropped with a warning naming the entry — a reserved key here means a
|
|
478
|
+
* hand-edited settings file, since setters reject them.
|
|
479
|
+
*/
|
|
480
|
+
function sanitizeGlobalModel(raw) {
|
|
481
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
482
|
+
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
|
483
|
+
if (!id) return null;
|
|
484
|
+
const label = (typeof raw.label === 'string' && raw.label.trim()) || id;
|
|
485
|
+
const efforts = Array.isArray(raw.efforts) ? orderEfforts(raw.efforts) : [];
|
|
486
|
+
const env = {};
|
|
487
|
+
const rawEnv = raw.env && typeof raw.env === 'object' && !Array.isArray(raw.env) ? raw.env : {};
|
|
488
|
+
for (const [k, v] of Object.entries(rawEnv)) {
|
|
489
|
+
if (isReservedModelEnvKey(k) || typeof v !== 'string' || !v) {
|
|
490
|
+
console.warn(`[worca] models entry ${JSON.stringify(id)}: dropping env key ${JSON.stringify(k)} (reserved or not a non-empty string)`);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
env[k] = v;
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
id,
|
|
497
|
+
label,
|
|
498
|
+
efforts: efforts.length ? efforts : [...EFFORTS],
|
|
499
|
+
...(Object.keys(env).length ? { env } : {}),
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* The sanitized global model catalog: [{id, label, efforts, env?}], effective
|
|
505
|
+
* shape (label/efforts always present). Missing/corrupt -> []. Malformed and
|
|
506
|
+
* case-insensitively duplicate entries are dropped loudly (first wins). Never
|
|
507
|
+
* throws.
|
|
508
|
+
*/
|
|
509
|
+
export function listGlobalModels() {
|
|
510
|
+
// Under the node:test runner the real ~/.worca-cc/settings.json must not
|
|
511
|
+
// leak models into tests (mirrors projects.mjs#worcaHome's guard): treat the
|
|
512
|
+
// catalog as EMPTY unless the test sandboxes HOME/USERPROFILE itself and
|
|
513
|
+
// says so via WORCA_TEST_ALLOW_HOME_FALLBACK. Reads never throw, so empty —
|
|
514
|
+
// not an error — is the degradation.
|
|
515
|
+
if (process.env.NODE_TEST_CONTEXT && !process.env.WORCA_TEST_ALLOW_HOME_FALLBACK) return [];
|
|
516
|
+
const raw = readSettings().models;
|
|
517
|
+
if (raw === undefined) return [];
|
|
518
|
+
if (!Array.isArray(raw)) {
|
|
519
|
+
console.warn(`[worca] invalid models ${JSON.stringify(raw)} — treating as empty`);
|
|
520
|
+
return [];
|
|
521
|
+
}
|
|
522
|
+
const out = [];
|
|
523
|
+
const seen = new Set();
|
|
524
|
+
for (const entry of raw) {
|
|
525
|
+
const m = sanitizeGlobalModel(entry);
|
|
526
|
+
if (!m) {
|
|
527
|
+
console.warn(`[worca] invalid models entry ${JSON.stringify(entry)} — ignored`);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const key = m.id.toLowerCase();
|
|
531
|
+
if (seen.has(key)) {
|
|
532
|
+
console.warn(`[worca] duplicate models id ${JSON.stringify(m.id)} — keeping the first`);
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
seen.add(key);
|
|
536
|
+
out.push(m);
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** @throws {Error} unless `id` is a non-empty string; returns it trimmed. */
|
|
542
|
+
function assertModelId(id) {
|
|
543
|
+
const v = typeof id === 'string' ? id.trim() : '';
|
|
544
|
+
if (!v) throw new Error('model id must be a non-empty string');
|
|
545
|
+
return v;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/** @throws {Error} unless every member is a known effort; returns EFFORTS-ordered subset ([] = default/full). */
|
|
549
|
+
function assertEfforts(input) {
|
|
550
|
+
if (isClearInput(input) || (Array.isArray(input) && input.length === 0)) return [];
|
|
551
|
+
if (!Array.isArray(input)) throw new Error(`efforts must be an array drawn from ${EFFORTS.join(' | ')}`);
|
|
552
|
+
for (const e of input) {
|
|
553
|
+
if (!EFFORTS.includes(e)) throw new Error(`unknown effort ${JSON.stringify(e)} — must be one of ${EFFORTS.join(' | ')}`);
|
|
554
|
+
}
|
|
555
|
+
return orderEfforts(input);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** @throws {Error} on a reserved key or a non-string value. `allowNull` admits
|
|
559
|
+
* the PATCH delete marker (env: {KEY: null}). Returns entries as given. */
|
|
560
|
+
function assertEnvPairs(env, { allowNull = false } = {}) {
|
|
561
|
+
if (isClearInput(env)) return {};
|
|
562
|
+
if (typeof env !== 'object' || Array.isArray(env)) throw new Error('env must be an object of string values');
|
|
563
|
+
for (const [k, v] of Object.entries(env)) {
|
|
564
|
+
if (isReservedModelEnvKey(k)) throw new Error(`env key ${JSON.stringify(k)} is reserved and cannot be set on a model`);
|
|
565
|
+
if (allowNull && v === null) continue;
|
|
566
|
+
if (typeof v !== 'string' || !v) throw new Error(`env value for ${JSON.stringify(k)} must be a non-empty string`);
|
|
567
|
+
}
|
|
568
|
+
return { ...env };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** Catalog WRITES under node:test would hit the user's REAL settings.json —
|
|
572
|
+
* throw unless the test sandboxes HOME and opts in (worcaHome-guard mirror). */
|
|
573
|
+
function assertTestSettingsAccess() {
|
|
574
|
+
if (process.env.NODE_TEST_CONTEXT && !process.env.WORCA_TEST_ALLOW_HOME_FALLBACK) {
|
|
575
|
+
throw new Error(
|
|
576
|
+
'global model catalog write under the node:test runner — sandbox HOME/USERPROFILE ' +
|
|
577
|
+
'and set WORCA_TEST_ALLOW_HOME_FALLBACK=1 (tests must never touch the real ~/.worca-cc)'
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** The MINIMAL stored shape for validated parts (see section comment). */
|
|
583
|
+
function storedModelShape(id, label, efforts, env) {
|
|
584
|
+
return {
|
|
585
|
+
id,
|
|
586
|
+
...(label && label !== id ? { label } : {}),
|
|
587
|
+
...(efforts.length && efforts.length !== EFFORTS.length ? { efforts } : {}),
|
|
588
|
+
...(Object.keys(env).length ? { env } : {}),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Find the index of the raw `models` entry matching `id` (case-insensitive). */
|
|
593
|
+
function findModelIndex(models, id) {
|
|
594
|
+
const key = id.toLowerCase();
|
|
595
|
+
return models.findIndex((e) => e && typeof e === 'object'
|
|
596
|
+
&& typeof e.id === 'string' && e.id.trim().toLowerCase() === key);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Read the raw models array for a read-modify-write (non-array -> []). */
|
|
600
|
+
function rawModels(settings) {
|
|
601
|
+
return Array.isArray(settings.models) ? settings.models : [];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Add a global catalog entry. `label` defaults to the id; `efforts` must be a
|
|
606
|
+
* subset of EFFORTS (empty/absent = all); `env` keys must not be reserved.
|
|
607
|
+
* @returns {Promise<{id:string,label:string,efforts:string[],env?:object}>} the effective entry
|
|
608
|
+
* @throws {Error} on invalid input or a case-insensitively duplicate id
|
|
609
|
+
*/
|
|
610
|
+
export async function addGlobalModel({ id, label, efforts, env } = {}) {
|
|
611
|
+
assertTestSettingsAccess();
|
|
612
|
+
const vid = assertModelId(id);
|
|
613
|
+
if (!isClearInput(label) && typeof label !== 'string') throw new Error('label must be a string');
|
|
614
|
+
const vefforts = assertEfforts(efforts);
|
|
615
|
+
const venv = assertEnvPairs(env);
|
|
616
|
+
const settings = readSettings();
|
|
617
|
+
const models = rawModels(settings);
|
|
618
|
+
if (findModelIndex(models, vid) !== -1) throw new Error(`a model with id ${JSON.stringify(vid)} already exists`);
|
|
619
|
+
const vlabel = (typeof label === 'string' && label.trim()) || vid;
|
|
620
|
+
settings.models = [...models, storedModelShape(vid, vlabel, vefforts, venv)];
|
|
621
|
+
await persistSettings(settings);
|
|
622
|
+
return listGlobalModels().find((m) => m.id.toLowerCase() === vid.toLowerCase());
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Patch a global catalog entry. Omitted fields are kept. `label`: ''/null
|
|
627
|
+
* resets to the id. `efforts`: []/null resets to all. `env`: null clears the
|
|
628
|
+
* whole map; an object merges per key, where a null value DELETES that key and
|
|
629
|
+
* a string sets it (write-only PATCH semantics, design §4.10).
|
|
630
|
+
* @returns {Promise<object>} the effective entry
|
|
631
|
+
* @throws {Error} on an unknown id or invalid input
|
|
632
|
+
*/
|
|
633
|
+
export async function updateGlobalModel(id, { label, efforts, env } = {}) {
|
|
634
|
+
assertTestSettingsAccess();
|
|
635
|
+
const vid = assertModelId(id);
|
|
636
|
+
const settings = readSettings();
|
|
637
|
+
const models = rawModels(settings);
|
|
638
|
+
const idx = findModelIndex(models, vid);
|
|
639
|
+
if (idx === -1) throw new Error(`unknown model id ${JSON.stringify(vid)}`);
|
|
640
|
+
const current = sanitizeGlobalModel(models[idx]);
|
|
641
|
+
|
|
642
|
+
let nextLabel = current.label;
|
|
643
|
+
if (label !== undefined) {
|
|
644
|
+
if (!isClearInput(label) && typeof label !== 'string') throw new Error('label must be a string');
|
|
645
|
+
nextLabel = (typeof label === 'string' && label.trim()) || current.id;
|
|
646
|
+
}
|
|
647
|
+
const nextEfforts = efforts === undefined
|
|
648
|
+
? orderEfforts(current.efforts)
|
|
649
|
+
: assertEfforts(efforts);
|
|
650
|
+
let nextEnv = { ...(current.env || {}) };
|
|
651
|
+
if (env === null) {
|
|
652
|
+
nextEnv = {};
|
|
653
|
+
} else if (env !== undefined) {
|
|
654
|
+
const patch = assertEnvPairs(env, { allowNull: true });
|
|
655
|
+
for (const [k, v] of Object.entries(patch)) {
|
|
656
|
+
if (v === null) delete nextEnv[k];
|
|
657
|
+
else nextEnv[k] = v;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
settings.models = models.slice();
|
|
662
|
+
settings.models[idx] = storedModelShape(current.id, nextLabel, nextEfforts, nextEnv);
|
|
663
|
+
await persistSettings(settings);
|
|
664
|
+
return listGlobalModels().find((m) => m.id.toLowerCase() === vid.toLowerCase());
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Remove a global catalog entry. Dangling per-node/per-step refs are the
|
|
669
|
+
* caller's (config.mjs's) responsibility — design §4.5.
|
|
670
|
+
* @throws {Error} on an unknown id
|
|
671
|
+
*/
|
|
672
|
+
export async function removeGlobalModel(id) {
|
|
673
|
+
assertTestSettingsAccess();
|
|
674
|
+
const vid = assertModelId(id);
|
|
675
|
+
const settings = readSettings();
|
|
676
|
+
const models = rawModels(settings);
|
|
677
|
+
const idx = findModelIndex(models, vid);
|
|
678
|
+
if (idx === -1) throw new Error(`unknown model id ${JSON.stringify(vid)}`);
|
|
679
|
+
settings.models = models.slice(0, idx).concat(models.slice(idx + 1));
|
|
680
|
+
if (!settings.models.length) delete settings.models;
|
|
681
|
+
await persistSettings(settings);
|
|
682
|
+
}
|