@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.
Files changed (114) hide show
  1. package/README.md +403 -0
  2. package/agents/clarify.meta.json +19 -0
  3. package/agents/decomposer.meta.json +21 -0
  4. package/agents/implementer.meta.json +20 -0
  5. package/agents/manualTestsChecklist.meta.json +18 -0
  6. package/agents/manualWebUiTesting.meta.json +18 -0
  7. package/agents/planReviewer.meta.json +19 -0
  8. package/agents/planner.meta.json +20 -0
  9. package/agents/refiner.meta.json +19 -0
  10. package/agents/reviewer.meta.json +19 -0
  11. package/agents/worca-cc-clarify.md +67 -0
  12. package/agents/worca-cc-code-reviewer.md +66 -0
  13. package/agents/worca-cc-decomposer.md +84 -0
  14. package/agents/worca-cc-implementer.md +69 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +63 -0
  16. package/agents/worca-cc-manual-web-ui-testing.md +64 -0
  17. package/agents/worca-cc-plan-refiner.md +69 -0
  18. package/agents/worca-cc-plan-reviewer.md +70 -0
  19. package/agents/worca-cc-planner.md +70 -0
  20. package/agents/worca-cc-workspace-reviewer.md +56 -0
  21. package/agents/worca-cc-workspace-scanner.md +55 -0
  22. package/agents/workspaceReviewer.meta.json +20 -0
  23. package/agents/workspaceScanner.meta.json +18 -0
  24. package/package.json +61 -0
  25. package/scripts/install.mjs +209 -0
  26. package/skills/worca/SKILL.md +66 -0
  27. package/src/cli/worca-cc.mjs +1520 -0
  28. package/src/core/agent-gen.mjs +206 -0
  29. package/src/core/agent-registry.mjs +417 -0
  30. package/src/core/agent-store.mjs +143 -0
  31. package/src/core/artifacts.mjs +2019 -0
  32. package/src/core/channels.mjs +302 -0
  33. package/src/core/chat/allowlist.mjs +27 -0
  34. package/src/core/chat/channel-host.mjs +562 -0
  35. package/src/core/chat/channel-protocol.mjs +117 -0
  36. package/src/core/chat/channel-worker-child.mjs +211 -0
  37. package/src/core/chat/chat-context.mjs +66 -0
  38. package/src/core/chat/command-router.mjs +343 -0
  39. package/src/core/chat/notifier.mjs +120 -0
  40. package/src/core/chat/parser.mjs +30 -0
  41. package/src/core/chat/rate-limiter.mjs +133 -0
  42. package/src/core/chat/redact.mjs +27 -0
  43. package/src/core/chat/renderers.mjs +136 -0
  44. package/src/core/claude-runner.mjs +1356 -0
  45. package/src/core/config.mjs +882 -0
  46. package/src/core/cost-budget.mjs +103 -0
  47. package/src/core/db.mjs +864 -0
  48. package/src/core/fanout.mjs +48 -0
  49. package/src/core/folder-dialog.mjs +138 -0
  50. package/src/core/fs-browse.mjs +49 -0
  51. package/src/core/git-info.mjs +200 -0
  52. package/src/core/guardrail-store.mjs +204 -0
  53. package/src/core/guardrails.mjs +302 -0
  54. package/src/core/marketplaces.mjs +267 -0
  55. package/src/core/migrate-fs-to-db.mjs +612 -0
  56. package/src/core/model-env.mjs +74 -0
  57. package/src/core/orchestrator.mjs +4279 -0
  58. package/src/core/overview-agent.mjs +124 -0
  59. package/src/core/phases.mjs +1279 -0
  60. package/src/core/pipeline-delete.mjs +428 -0
  61. package/src/core/plugin-api.mjs +13 -0
  62. package/src/core/plugin-config.mjs +100 -0
  63. package/src/core/plugin-inventory.mjs +50 -0
  64. package/src/core/plugin-manifest.mjs +447 -0
  65. package/src/core/plugin-models.mjs +130 -0
  66. package/src/core/plugin-repo.mjs +303 -0
  67. package/src/core/plugin-shim-child.mjs +76 -0
  68. package/src/core/plugin-shim.mjs +197 -0
  69. package/src/core/plugin-store.mjs +485 -0
  70. package/src/core/plugin-workflows.mjs +179 -0
  71. package/src/core/plugins-lock.mjs +49 -0
  72. package/src/core/preflight-node.mjs +122 -0
  73. package/src/core/preflight.mjs +341 -0
  74. package/src/core/projects.mjs +157 -0
  75. package/src/core/protocol.mjs +257 -0
  76. package/src/core/recoverable-error.mjs +51 -0
  77. package/src/core/results.mjs +188 -0
  78. package/src/core/run-context.mjs +1375 -0
  79. package/src/core/run-log.mjs +64 -0
  80. package/src/core/run-manifest.mjs +317 -0
  81. package/src/core/runners.mjs +167 -0
  82. package/src/core/settings.mjs +682 -0
  83. package/src/core/skills.mjs +210 -0
  84. package/src/core/sources.mjs +232 -0
  85. package/src/core/stats.mjs +182 -0
  86. package/src/core/store.mjs +67 -0
  87. package/src/core/title.mjs +64 -0
  88. package/src/core/workflow-validator.mjs +185 -0
  89. package/src/core/workflows.mjs +568 -0
  90. package/src/core/workspace-scan.mjs +420 -0
  91. package/src/core/workspaces.mjs +353 -0
  92. package/src/core/worktree.mjs +708 -0
  93. package/src/feature.mjs +9 -0
  94. package/ui/public/app.js +10647 -0
  95. package/ui/public/assets/worca-favicon.png +0 -0
  96. package/ui/public/assets/worca-logo.png +0 -0
  97. package/ui/public/chat-settings-view.mjs +89 -0
  98. package/ui/public/composer-core.mjs +211 -0
  99. package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  100. package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
  101. package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
  102. package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
  103. package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
  104. package/ui/public/guardrails-view.mjs +244 -0
  105. package/ui/public/index.html +1145 -0
  106. package/ui/public/log-filter.mjs +81 -0
  107. package/ui/public/log-line.mjs +86 -0
  108. package/ui/public/models-view.mjs +433 -0
  109. package/ui/public/plugins-view.mjs +430 -0
  110. package/ui/public/results-view.mjs +121 -0
  111. package/ui/public/source-pane.mjs +156 -0
  112. package/ui/public/stats-view.mjs +523 -0
  113. package/ui/public/style.css +1557 -0
  114. package/ui/server.mjs +3573 -0
@@ -0,0 +1,882 @@
1
+ // src/core/config.mjs
2
+ // Per-project model + effort selection for each AGENT step of the pipeline.
3
+ //
4
+ // node:sqlite migration: now persisted in the `project_config`/`config_workflow_*`
5
+ // tables; path helpers vestigial.
6
+ //
7
+ // Agent steps are keyed by their orchestrator role name:
8
+ // planner | refiner | implementer | reviewer
9
+ // (preflight and done are not agents, so they carry no model/effort.)
10
+ //
11
+ // Reads never throw (missing/corrupt => safe defaults); writes validate then
12
+ // persist inside a single db.mjs tx(). All per-project config is keyed by
13
+ // projectKey(projectDir) (store.mjs), so every worktree of a repo maps to one row.
14
+
15
+ import { getDb, prepare, tx } from './db.mjs';
16
+ import { projectKey } from './store.mjs';
17
+ import { loadAgentRegistry, registryToSteps } from './agent-registry.mjs';
18
+ import { EFFORTS, prepareModelEnv } from './model-env.mjs';
19
+ import { listGlobalModels, addGlobalModel, removeGlobalModel } from './settings.mjs';
20
+ import { listPluginModels, allPluginModels, flattenPluginModelEnv } from './plugin-models.mjs';
21
+
22
+ /**
23
+ * Recompute the agent step list FRESH from the layered registry (repo agents/ +
24
+ * ~/.worca-cc/agents). Use this instead of AGENT_STEPS anywhere a user-added agent
25
+ * must appear without a process restart (the registry re-scans per call).
26
+ * @returns {Array<{key:string,label:string,fanOut:boolean}>}
27
+ */
28
+ export function agentSteps() {
29
+ return registryToSteps(loadAgentRegistry());
30
+ }
31
+
32
+ /**
33
+ * Boot-time snapshot of agentSteps(), kept for import-compat (UI boot payloads,
34
+ * tests). PREFER agentSteps(): this constant goes stale when a user agent is
35
+ * added/removed at runtime.
36
+ */
37
+ export const AGENT_STEPS = agentSteps();
38
+
39
+ /** Live key set (recomputed per call so runtime-added user agents validate). */
40
+ const stepKeys = () => new Set(agentSteps().map((s) => s.key));
41
+
42
+ /** All effort levels the UI can offer (ordering is not a ranking). Canonical
43
+ * home is model-env.mjs (so settings.mjs can validate catalog entries without
44
+ * importing the core graph); re-exported here for import-compat. */
45
+ export { EFFORTS };
46
+
47
+ /**
48
+ * Built-in models. `efforts` is the subset of EFFORTS each model supports.
49
+ * `xhigh` is listed only on models that support it; medium/high/max are broad.
50
+ *
51
+ * IMPORTANT: these ids are the aliases the installed `claude` CLI is expected to
52
+ * accept. Verify them against your CLI (see "How success is verified"). The
53
+ * canonical dated id for Haiku 4.5 is `claude-haiku-4-5-20251001`; the bare
54
+ * alias `claude-haiku-4-5` is used here and must be confirmed to resolve. Any id
55
+ * that does not resolve can be replaced here or added as a custom model.
56
+ *
57
+ * The `[1m]` suffix selects the 1M-token long-context variant. Opus 4.6–4.8 and
58
+ * Sonnet 4.6 1M ids were verified to resolve via `claude --model`; Haiku 4.5 1M
59
+ * is intentionally omitted — the CLI rejects it ("long context beta is not yet
60
+ * available for this subscription"). Fable 5 needs no `[1m]` suffix: its context
61
+ * window is 1M by default (verified to resolve via `claude --model`). Opus 5
62
+ * (`claude-opus-5`) is likewise 1M-only and carries no `[1m]` twin.
63
+ */
64
+ export const PREDEFINED_MODELS = [
65
+ { id: 'claude-opus-5', label: 'Opus 5', efforts: ['medium', 'high', 'xhigh', 'max'] },
66
+ { id: 'claude-fable-5', label: 'Fable 5 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
67
+ { id: 'claude-opus-4-8', label: 'Opus 4.8', efforts: ['medium', 'high', 'xhigh', 'max'] },
68
+ { id: 'claude-opus-4-8[1m]', label: 'Opus 4.8 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
69
+ { id: 'claude-opus-4-7', label: 'Opus 4.7', efforts: ['medium', 'high', 'xhigh', 'max'] },
70
+ { id: 'claude-opus-4-7[1m]', label: 'Opus 4.7 (1M)', efforts: ['medium', 'high', 'xhigh', 'max'] },
71
+ { id: 'claude-opus-4-6', label: 'Opus 4.6', efforts: ['medium', 'high', 'max'] },
72
+ { id: 'claude-opus-4-6[1m]', label: 'Opus 4.6 (1M)', efforts: ['medium', 'high', 'max'] },
73
+ { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6', efforts: ['medium', 'high', 'max'] },
74
+ { id: 'claude-sonnet-4-6[1m]', label: 'Sonnet 4.6 (1M)', efforts: ['medium', 'high', 'max'] },
75
+ { id: 'claude-haiku-4-5', label: 'Haiku 4.5', efforts: ['medium', 'high'] },
76
+ ];
77
+
78
+ /** @deprecated config moved to the DB (project_config). Kept for import-compat only. */
79
+ export function configDir(projectDir) { return String(projectDir ?? ''); }
80
+ /** @deprecated config moved to the DB (project_config). Kept for import-compat only. */
81
+ export function configFile(projectDir) { return String(projectDir ?? ''); }
82
+
83
+ function defaultConfig() {
84
+ return { steps: {}, customModels: [] };
85
+ }
86
+
87
+ /** Keep only known step keys carrying a non-empty model/effort and/or a fanOut/askQuestions boolean. */
88
+ function sanitizeSteps(steps) {
89
+ const out = {};
90
+ const keys = stepKeys();
91
+ for (const [k, v] of Object.entries(steps || {})) {
92
+ if (!keys.has(k) || !v || typeof v !== 'object') continue;
93
+ const model = typeof v.model === 'string' ? v.model.trim() : '';
94
+ const effort = typeof v.effort === 'string' ? v.effort.trim() : '';
95
+ const fanOut = typeof v.fanOut === 'boolean' ? v.fanOut : undefined;
96
+ const askQuestions = typeof v.askQuestions === 'boolean' ? v.askQuestions : undefined;
97
+ if (model || effort || fanOut !== undefined || askQuestions !== undefined) {
98
+ out[k] = {
99
+ ...(model && { model }),
100
+ ...(effort && { effort }),
101
+ ...(fanOut !== undefined && { fanOut }),
102
+ ...(askQuestions !== undefined && { askQuestions }),
103
+ };
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ /** Keep well-formed, de-duplicated custom models that don't shadow a predefined id. */
110
+ function sanitizeCustom(list) {
111
+ const seen = new Set(PREDEFINED_MODELS.map((m) => m.id.toLowerCase()));
112
+ const out = [];
113
+ for (const e of Array.isArray(list) ? list : []) {
114
+ if (!e || typeof e !== 'object') continue;
115
+ const id = typeof e.id === 'string' ? e.id.trim() : '';
116
+ if (!id || seen.has(id.toLowerCase())) continue;
117
+ seen.add(id.toLowerCase());
118
+ out.push({ id, label: (typeof e.label === 'string' && e.label.trim()) || id });
119
+ }
120
+ return out;
121
+ }
122
+
123
+ /** Fail-safe JSON parse: returns `fallback` on any error / non-matching shape. */
124
+ function parseJson(text, fallback) {
125
+ if (typeof text !== 'string' || !text) return fallback;
126
+ try {
127
+ const v = JSON.parse(text);
128
+ return v && typeof v === 'object' ? v : fallback;
129
+ } catch {
130
+ return fallback;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Read the project_config row for a projectKey, or null when absent. Synchronous.
136
+ * @param {string} key
137
+ * @returns {{steps:string,custom_models:string,active_workflow_id:(string|null),extra:string}|null}
138
+ */
139
+ function readConfigRow(key) {
140
+ getDb();
141
+ return prepare(
142
+ 'SELECT steps, custom_models, active_workflow_id, extra FROM project_config WHERE project_key = ?'
143
+ ).get(key) || null;
144
+ }
145
+
146
+ /**
147
+ * Read + sanitize the legacy {steps, customModels} view from the project_config
148
+ * row. Missing/corrupt => { steps:{}, customModels:[] }. Never throws.
149
+ * @param {string} projectDir
150
+ * @returns {{steps:object, customModels:Array}}
151
+ */
152
+ function readRaw(projectDir) {
153
+ const row = readConfigRow(projectKey(projectDir));
154
+ if (!row) return defaultConfig();
155
+ return {
156
+ steps: sanitizeSteps(parseJson(row.steps, {})),
157
+ customModels: sanitizeCustom(parseJson(row.custom_models, [])),
158
+ };
159
+ }
160
+
161
+ /** Public read of the sanitized legacy {steps, customModels} view. Never throws. */
162
+ export async function readConfig(projectDir) {
163
+ return readRaw(projectDir);
164
+ }
165
+
166
+ /**
167
+ * Compose the EFFECTIVE catalog (configurable-models-design.md §4.2, §9.2):
168
+ * predefined ⊕ plugin models ⊕ global settings entries ⊕ legacy per-project
169
+ * custom models. Precedence on an id collision: global (user) beats plugin
170
+ * beats predefined; legacy ranks lowest and is dropped. A shadowing entry
171
+ * keeps the shadowed id's casing so existing step/node refs stay stable.
172
+ * `custom` is false | 'global' | 'plugin' | 'project' (strings truthy, so
173
+ * existing `m.custom` checks keep working); plugin entries also carry
174
+ * `plugin: '<name>'`; `hasEnv` advertises routing env WITHOUT the values
175
+ * (this shape feeds UI dropdowns).
176
+ */
177
+ function composeCatalog(projectCustom = []) {
178
+ const globals = listGlobalModels();
179
+ const globalByIdLc = new Map(globals.map((m) => [m.id.toLowerCase(), m]));
180
+ const plugins = listPluginModels();
181
+ const pluginByIdLc = new Map(plugins.map((m) => [m.id.toLowerCase(), m]));
182
+ const flagged = costUnreliableModelIds();
183
+ const out = [];
184
+ const seen = new Set();
185
+ const unreliable = (lc) => (flagged.has(lc) ? { costUnreliable: true } : {});
186
+ const pluginShape = (id, m, lc) => ({
187
+ id, label: m.label, efforts: [...m.efforts], custom: 'plugin', plugin: m.plugin,
188
+ hasEnv: !!m.env, ...unreliable(lc),
189
+ });
190
+ for (const m of PREDEFINED_MODELS) {
191
+ const lc = m.id.toLowerCase();
192
+ const shadow = globalByIdLc.get(lc);
193
+ const pshadow = pluginByIdLc.get(lc);
194
+ out.push(shadow
195
+ ? { id: m.id, label: shadow.label, efforts: [...shadow.efforts], custom: 'global', hasEnv: !!shadow.env, ...unreliable(lc) }
196
+ : pshadow
197
+ ? pluginShape(m.id, pshadow, lc)
198
+ : { ...m, custom: false, hasEnv: false });
199
+ seen.add(lc);
200
+ }
201
+ for (const m of globals) {
202
+ const lc = m.id.toLowerCase();
203
+ if (seen.has(lc)) continue; // predefined shadow, already emitted
204
+ seen.add(lc);
205
+ out.push({ id: m.id, label: m.label, efforts: [...m.efforts], custom: 'global', hasEnv: !!m.env, ...unreliable(lc) });
206
+ }
207
+ for (const m of plugins) {
208
+ const lc = m.id.toLowerCase();
209
+ if (seen.has(lc)) continue; // predefined/global shadow wins
210
+ seen.add(lc);
211
+ out.push(pluginShape(m.id, m, lc));
212
+ }
213
+ for (const m of projectCustom) {
214
+ if (seen.has(m.id.toLowerCase())) continue; // predefined/global/plugin wins
215
+ seen.add(m.id.toLowerCase());
216
+ out.push({ id: m.id, label: m.label, efforts: [...EFFORTS], custom: 'project', hasEnv: false });
217
+ }
218
+ return out;
219
+ }
220
+
221
+ // ── cost-reliability observations (design §4.6) ───────────────────────────────
222
+ // DERIVED state in the central DB (model_cost_flags): an env-routed endpoint
223
+ // that reports no cost while consuming tokens gets flagged; a later positive-
224
+ // cost run of the same model auto-clears it. Never assumed from config alone —
225
+ // a proxy that reports real costs is never badged.
226
+
227
+ /** Whether the model's env-carrying entry (user GLOBAL first, else the winning
228
+ * PLUGIN entry — design §9.3) declares an ANTHROPIC_BASE_URL override
229
+ * (directly, as a ${VAR} ref, or as a {secret} placeholder — key presence is
230
+ * the signal). Only such models are ever observed; the direct Anthropic path
231
+ * is never flagged. */
232
+ export function modelHasBaseUrlRouting(modelId) {
233
+ const id = typeof modelId === 'string' ? modelId.trim() : '';
234
+ if (!id) return false;
235
+ const lc = id.toLowerCase();
236
+ const entry = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
237
+ if (entry) return !!(entry.env && 'ANTHROPIC_BASE_URL' in entry.env);
238
+ const pm = listPluginModels().find((m) => m.id.toLowerCase() === lc);
239
+ return !!(pm && pm.env && 'ANTHROPIC_BASE_URL' in pm.env);
240
+ }
241
+
242
+ /** Lowercased ids currently flagged cost-unreliable. Never throws ({} on any
243
+ * DB trouble) — reads feed catalog composition, which must never fail. */
244
+ export function costUnreliableModelIds() {
245
+ try {
246
+ getDb();
247
+ return new Set(prepare('SELECT model_id FROM model_cost_flags').all()
248
+ .map((r) => String(r.model_id).toLowerCase()));
249
+ } catch {
250
+ return new Set();
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Evaluate one terminal result event for cost reliability (design §4.6).
256
+ * Applies ONLY to models with base-URL routing; the caller must already have
257
+ * excluded mock runs. `usage` is the raw result event's usage object.
258
+ * @param {string} modelId
259
+ * @param {number|null} costUsd the reported cost (null when absent)
260
+ * @param {object} [usage]
261
+ * @returns {'flagged'|'cleared'|null} what changed — 'flagged' asks the caller
262
+ * to surface its one-per-run warning; null = no observation recorded
263
+ */
264
+ export function observeModelCost(modelId, costUsd, usage) {
265
+ if (!modelHasBaseUrlRouting(modelId)) return null;
266
+ const u = usage && typeof usage === 'object' ? usage : {};
267
+ const tokens = ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens']
268
+ .reduce((n, k) => n + (Number(u[k]) || 0), 0);
269
+ const id = String(modelId).trim();
270
+ getDb();
271
+ if (Number.isFinite(costUsd) && costUsd > 0) {
272
+ // Positive cost = the endpoint reports real spend — auto-clear.
273
+ const cleared = prepare('DELETE FROM model_cost_flags WHERE model_id = ?').run(id).changes > 0;
274
+ return cleared ? 'cleared' : null;
275
+ }
276
+ if (tokens > 0) {
277
+ // Tokens consumed, cost absent/zero — the USD budget cannot see this spend.
278
+ prepare(`
279
+ INSERT INTO model_cost_flags (model_id, flagged_at) VALUES (?, ?)
280
+ ON CONFLICT(model_id) DO NOTHING
281
+ `).run(id, new Date().toISOString());
282
+ return 'flagged';
283
+ }
284
+ return null; // no cost AND no tokens (e.g. an errored run) — no signal either way
285
+ }
286
+
287
+ /**
288
+ * All selectable models for a project = the effective catalog (predefined ⊕
289
+ * global ⊕ this project's legacy custom models). Legacy custom models
290
+ * advertise the full effort set (their support is unknown — the user owns the
291
+ * raw id); global entries advertise their configured subset.
292
+ */
293
+ export async function listModels(projectDir) {
294
+ if (!projectDir) return composeCatalog([]); // project-less: predefined ⊕ global only
295
+ const { customModels } = readRaw(projectDir);
296
+ return composeCatalog(customModels);
297
+ }
298
+
299
+ /**
300
+ * The routing env for a model id (design §4.4, §9.3), or undefined when none
301
+ * is configured. The user's GLOBAL entry wins; otherwise the winning enabled
302
+ * PLUGIN entry applies, with {secret} placeholders resolved from that plugin's
303
+ * secrets store (unset secrets dropped with a warning naming plugin and key).
304
+ * Whole-value ${VAR} refs are expanded from worca's own process env HERE (the
305
+ * resolution point); reserved or unresolvable keys are dropped with a warning —
306
+ * write-time validation rejects reserved keys, so a drop means a hand-edited
307
+ * file (or manifest). Synchronous; never throws.
308
+ * @param {string} modelId
309
+ * @returns {Record<string,string>|undefined}
310
+ */
311
+ export function resolveModelEnv(modelId) {
312
+ const id = typeof modelId === 'string' ? modelId.trim() : '';
313
+ if (!id) return undefined;
314
+ const lc = id.toLowerCase();
315
+ let rawEnv;
316
+ let who;
317
+ const entry = listGlobalModels().find((m) => m.id.toLowerCase() === lc);
318
+ if (entry && entry.env) {
319
+ rawEnv = entry.env;
320
+ who = JSON.stringify(entry.id);
321
+ } else if (!entry) {
322
+ const pm = listPluginModels().find((m) => m.id.toLowerCase() === lc);
323
+ if (pm && pm.env) {
324
+ const { env, droppedSecrets } = flattenPluginModelEnv(pm);
325
+ for (const d of droppedSecrets) {
326
+ console.warn(`[worca] plugin "${pm.plugin}" model ${JSON.stringify(pm.id)}: dropping env ${d} — set it in the plugin's Model secrets`);
327
+ }
328
+ rawEnv = env;
329
+ who = `${JSON.stringify(pm.id)} (plugin "${pm.plugin}")`;
330
+ }
331
+ }
332
+ if (!rawEnv) return undefined;
333
+ const { env, dropped } = prepareModelEnv(rawEnv);
334
+ for (const k of dropped) {
335
+ console.warn(`[worca] model ${who}: dropping env key ${JSON.stringify(k)} (reserved or unresolvable \${VAR} ref)`);
336
+ }
337
+ return Object.keys(env).length ? env : undefined;
338
+ }
339
+
340
+ /**
341
+ * Resolve the effective per-role { model, effort } for a run. A role with no
342
+ * configured model inherits `fallbackModel` (the global --model). Effort has no
343
+ * global fallback, so it is undefined when unset.
344
+ * @returns {Promise<Record<string,{model:(string|undefined),effort:(string|undefined)}>>}
345
+ */
346
+ export async function resolveStepModels(projectDir, fallbackModel) {
347
+ const cfg = readRaw(projectDir);
348
+ const out = {};
349
+ for (const { key } of agentSteps()) {
350
+ const sel = cfg.steps[key] || {};
351
+ out[key] = { model: sel.model || fallbackModel || undefined, effort: sel.effort || undefined };
352
+ }
353
+ return out;
354
+ }
355
+
356
+ /**
357
+ * Upsert the legacy {steps, customModels} columns of the project_config row,
358
+ * leaving active_workflow_id + extra intact. JSON-encodes both columns. Runs in a
359
+ * single transaction. Used by setStep/addCustomModel/removeCustomModel.
360
+ * @param {string} key projectKey
361
+ * @param {{steps:object, customModels:Array}} cfg sanitized legacy view
362
+ */
363
+ function writeLegacy(key, cfg) {
364
+ const stepsJson = JSON.stringify(cfg.steps || {});
365
+ const customJson = JSON.stringify(cfg.customModels || []);
366
+ tx(() => {
367
+ prepare(`
368
+ INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
369
+ VALUES (?, ?, ?, NULL, '{}')
370
+ ON CONFLICT(project_key) DO UPDATE SET steps = excluded.steps, custom_models = excluded.custom_models
371
+ `).run(key, stepsJson, customJson);
372
+ });
373
+ }
374
+
375
+ /**
376
+ * Tri-state resolution for the boolean toggles (fanOut / askQuestions), shared by
377
+ * setStep and setNodeModel so the two write paths cannot drift:
378
+ * boolean -> that value (the toggle sent it)
379
+ * null -> undefined = cleared (an explicit "inherit the default again")
380
+ * absent -> the previous value (a model/effort write must not wipe a toggle)
381
+ * @param {unknown} next
382
+ * @param {unknown} prev
383
+ * @returns {boolean|undefined}
384
+ */
385
+ function inheritOr(next, prev) {
386
+ if (typeof next === 'boolean') return next;
387
+ if (next === null) return undefined;
388
+ return typeof prev === 'boolean' ? prev : undefined;
389
+ }
390
+
391
+ /**
392
+ * Set (or clear) the model + effort for one agent step. An empty model => inherit
393
+ * the global/CLI default; an empty effort => model default. Effort must be supported
394
+ * by the chosen model. fanOut is preserved when the caller omits it (only the toggle
395
+ * sends it) and set when a boolean. Returns the updated legacy view.
396
+ * @returns {Promise<{steps:object, customModels:Array}>}
397
+ */
398
+ export async function setStep(projectDir, step, selection = {}) {
399
+ if (!stepKeys().has(step)) throw new Error(`unknown step "${step}"`);
400
+ const model = typeof selection.model === 'string' ? selection.model.trim() : '';
401
+ const effort = typeof selection.effort === 'string' ? selection.effort.trim() : '';
402
+
403
+ const models = await listModels(projectDir);
404
+ const entry = model ? models.find((m) => m.id === model) : null;
405
+ if (model && !entry) throw new Error(`unknown model "${model}"`);
406
+ if (effort) {
407
+ if (!EFFORTS.includes(effort)) throw new Error(`unknown effort "${effort}"`);
408
+ if (!entry) throw new Error('select a model before choosing an effort');
409
+ if (!entry.efforts.includes(effort)) {
410
+ throw new Error(`model "${model}" does not support effort "${effort}"`);
411
+ }
412
+ }
413
+
414
+ const key = projectKey(projectDir);
415
+ const cfg = readRaw(projectDir);
416
+ const prev = cfg.steps[step] || {};
417
+ // model/effort keep replace semantics (undefined => cleared); fanOut is preserved
418
+ // when the caller omits it (only the toggle sends it), set when a boolean, and
419
+ // CLEARED on an explicit null — the New-Pipeline accordion prunes a toggle back
420
+ // to "inherit" when it matches the resolved default (newpipeline-ux-design.md §4.5).
421
+ const fanOut = inheritOr(selection.fanOut, prev.fanOut);
422
+ // askQuestions mirrors fanOut: preserved when omitted (only the toggle sends
423
+ // it), set when a boolean (spec 2026-07-11 §4), cleared on null.
424
+ const askQuestions = inheritOr(selection.askQuestions, prev.askQuestions);
425
+
426
+ const steps = { ...cfg.steps };
427
+ if (!model && !effort && fanOut === undefined && askQuestions === undefined) delete steps[step];
428
+ else steps[step] = {
429
+ ...(model && { model }),
430
+ ...(effort && { effort }),
431
+ ...(fanOut !== undefined && { fanOut }),
432
+ ...(askQuestions !== undefined && { askQuestions }),
433
+ };
434
+
435
+ const updated = { ...cfg, steps };
436
+ writeLegacy(key, updated);
437
+ return updated;
438
+ }
439
+
440
+ /** Add a custom model by raw id (optional label). Rejects empties + duplicates. */
441
+ export async function addCustomModel(projectDir, input = {}) {
442
+ const id = typeof input.id === 'string' ? input.id.trim() : '';
443
+ if (!id) throw new Error('model id is required');
444
+ if (PREDEFINED_MODELS.some((m) => m.id.toLowerCase() === id.toLowerCase())) {
445
+ throw new Error(`"${id}" is already a predefined model`);
446
+ }
447
+ if (listGlobalModels().some((m) => m.id.toLowerCase() === id.toLowerCase())) {
448
+ throw new Error(`"${id}" is already a global model`);
449
+ }
450
+ const key = projectKey(projectDir);
451
+ const cfg = readRaw(projectDir);
452
+ if (cfg.customModels.some((m) => m.id.toLowerCase() === id.toLowerCase())) {
453
+ throw new Error(`a model with id "${id}" already exists`);
454
+ }
455
+ const label = (typeof input.label === 'string' && input.label.trim()) || id;
456
+ const updated = { ...cfg, customModels: [...cfg.customModels, { id, label }] };
457
+ writeLegacy(key, updated);
458
+ return updated;
459
+ }
460
+
461
+ /**
462
+ * Remove a custom model (case-insensitive). Also: (1) clears any legacy step that
463
+ * referenced it, and (2) deletes any normalized config_workflow_nodes row that
464
+ * referenced it (per the migration spec — no dangling node->model refs survive).
465
+ * Returns the updated legacy view.
466
+ */
467
+ export async function removeCustomModel(projectDir, id) {
468
+ const target = (typeof id === 'string' ? id : '').trim();
469
+ const lc = target.toLowerCase();
470
+ const key = projectKey(projectDir);
471
+ const cfg = readRaw(projectDir);
472
+
473
+ const customModels = cfg.customModels.filter((m) => m.id.toLowerCase() !== lc);
474
+ const steps = {};
475
+ for (const [k, v] of Object.entries(cfg.steps)) {
476
+ if (v?.model && v.model.toLowerCase() === lc) continue; // drop dangling legacy reference
477
+ steps[k] = v;
478
+ }
479
+ const updated = { ...cfg, customModels, steps };
480
+
481
+ // One transaction: rewrite the legacy columns AND purge normalized node refs.
482
+ tx(() => {
483
+ prepare(`
484
+ INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
485
+ VALUES (?, ?, ?, NULL, '{}')
486
+ ON CONFLICT(project_key) DO UPDATE SET steps = excluded.steps, custom_models = excluded.custom_models
487
+ `).run(key, JSON.stringify(steps), JSON.stringify(customModels));
488
+ // Spec: removing a custom model also clears any per-node override pointing at it.
489
+ prepare(
490
+ 'DELETE FROM config_workflow_nodes WHERE project_key = ? AND model = ? COLLATE NOCASE'
491
+ ).run(key, target);
492
+ });
493
+ return updated;
494
+ }
495
+
496
+ /**
497
+ * Promote a legacy per-project custom model into the GLOBAL catalog (design
498
+ * §4.9): create the global entry (skipped when one with that id already
499
+ * exists) and drop only the project-local entry. Deliberately NOT
500
+ * addGlobalModel + removeCustomModel — the latter purges node/step refs, and
501
+ * promotion must be invisible to refs (the id keeps resolving, now globally).
502
+ * @returns {Promise<{steps:object, customModels:Array}>} the updated legacy view
503
+ * @throws {Error} when the project has no such custom model
504
+ */
505
+ export async function promoteCustomModel(projectDir, id) {
506
+ const target = (typeof id === 'string' ? id : '').trim();
507
+ const lc = target.toLowerCase();
508
+ const cfg = readRaw(projectDir);
509
+ const entry = cfg.customModels.find((m) => m.id.toLowerCase() === lc);
510
+ if (!entry) throw new Error(`unknown project model "${target}"`);
511
+ if (!listGlobalModels().some((m) => m.id.toLowerCase() === lc)) {
512
+ await addGlobalModel({ id: entry.id, label: entry.label });
513
+ }
514
+ const updated = { ...cfg, customModels: cfg.customModels.filter((m) => m !== entry) };
515
+ writeLegacy(projectKey(projectDir), updated);
516
+ return updated;
517
+ }
518
+
519
+ // ── run-config: per-project model/effort/cycles for composed workflows ─────────
520
+ // The legacy { steps, customModels } view lives in project_config.steps /
521
+ // project_config.custom_models. The nested run-config `workflows` map is NORMALIZED
522
+ // into config_workflow_nodes + config_workflow_feedbacks; readRunConfig rebuilds the
523
+ // nested shape from those rows. activeWorkflowId is project_config.active_workflow_id;
524
+ // unknown top-level keys (e.g. webUiTesting) round-trip via project_config.extra.
525
+
526
+ /** Coerce a per-node selection to a clean {model?,effort?,fanOut?,askQuestions?} or null (all empty). */
527
+ function cleanNodeSel(selection) {
528
+ const model = typeof selection?.model === 'string' ? selection.model.trim() : '';
529
+ const effort = typeof selection?.effort === 'string' ? selection.effort.trim() : '';
530
+ const fanOut = typeof selection?.fanOut === 'boolean' ? selection.fanOut : undefined;
531
+ const askQuestions = typeof selection?.askQuestions === 'boolean' ? selection.askQuestions : undefined;
532
+ if (!model && !effort && fanOut === undefined && askQuestions === undefined) return null;
533
+ return {
534
+ ...(model && { model }),
535
+ ...(effort && { effort }),
536
+ ...(fanOut !== undefined && { fanOut }),
537
+ ...(askQuestions !== undefined && { askQuestions }),
538
+ };
539
+ }
540
+
541
+ /**
542
+ * Rebuild the nested workflows map { [workflowId]: { nodes, feedbacks } } from the
543
+ * normalized config_workflow_nodes + config_workflow_feedbacks rows for a project.
544
+ * Mirrors today's config.json `workflows` shape exactly. Synchronous; never throws.
545
+ * @param {string} key projectKey
546
+ * @returns {Record<string,{nodes:object,feedbacks:object}>}
547
+ */
548
+ function readWorkflowsMap(key) {
549
+ getDb();
550
+ const workflows = {};
551
+ const ensure = (wf) => {
552
+ if (!workflows[wf]) workflows[wf] = { nodes: {}, feedbacks: {} };
553
+ return workflows[wf];
554
+ };
555
+ for (const r of prepare(
556
+ 'SELECT workflow_id, node_id, model, effort, fan_out, ask_questions FROM config_workflow_nodes WHERE project_key = ?'
557
+ ).all(key)) {
558
+ const sel = {};
559
+ if (r.model) sel.model = r.model;
560
+ if (r.effort) sel.effort = r.effort;
561
+ if (r.fan_out !== null && r.fan_out !== undefined) sel.fanOut = !!r.fan_out;
562
+ if (r.ask_questions !== null && r.ask_questions !== undefined) sel.askQuestions = !!r.ask_questions;
563
+ // Only attach a node entry that carries something (matches cleanNodeSel output).
564
+ if (Object.keys(sel).length) ensure(r.workflow_id).nodes[r.node_id] = sel;
565
+ }
566
+ for (const r of prepare(
567
+ 'SELECT workflow_id, fb_id, max_cycles FROM config_workflow_feedbacks WHERE project_key = ?'
568
+ ).all(key)) {
569
+ ensure(r.workflow_id).feedbacks[r.fb_id] = { maxCycles: r.max_cycles };
570
+ }
571
+ return workflows;
572
+ }
573
+
574
+ /**
575
+ * Read the full RunConfig: the sanitized legacy view (steps/customModels) plus the
576
+ * run-config layer (workflows + activeWorkflowId) and any preserved unknown keys
577
+ * (e.g. webUiTesting from project_config.extra). Missing => empty layers. Never throws.
578
+ * @param {string} projectDir
579
+ * @returns {Promise<{steps:object,customModels:Array,workflows:object,activeWorkflowId?:string,webUiTesting?:object}>}
580
+ */
581
+ export async function readRunConfig(projectDir) {
582
+ const key = projectKey(projectDir);
583
+ const row = readConfigRow(key);
584
+ const legacy = row
585
+ ? { steps: sanitizeSteps(parseJson(row.steps, {})), customModels: sanitizeCustom(parseJson(row.custom_models, [])) }
586
+ : defaultConfig();
587
+ const out = { ...legacy, workflows: readWorkflowsMap(key) };
588
+ // Preserve unknown top-level keys (today: webUiTesting) from project_config.extra.
589
+ const extra = row ? parseJson(row.extra, {}) : {};
590
+ if (extra.webUiTesting && typeof extra.webUiTesting === 'object') out.webUiTesting = extra.webUiTesting;
591
+ // Forward any OTHER unknown keys verbatim too (future-proof, matches "preserve unknown").
592
+ for (const [k, v] of Object.entries(extra)) {
593
+ if (k !== 'webUiTesting' && !(k in out)) out[k] = v;
594
+ }
595
+ const active = row && typeof row.active_workflow_id === 'string' ? row.active_workflow_id.trim() : '';
596
+ if (active) out.activeWorkflowId = active;
597
+ return out;
598
+ }
599
+
600
+ /**
601
+ * Set (or clear) the model+effort+fanOut+askQuestions for one node instance of a
602
+ * workflow. A cleaned selection of null (all blank) deletes the row. fanOut and
603
+ * askQuestions are preserved when the caller omits them (read from the existing
604
+ * row), set when a boolean, and cleared on an explicit null. Writes only the config_workflow_nodes table
605
+ * (legacy view + extra untouched). Model/effort validate against the effective
606
+ * catalog exactly like setStep (design §4.5 — the two write paths must not
607
+ * disagree); rows persisted before this hardening are validated only when
608
+ * next written.
609
+ * @param {string} projectDir
610
+ * @param {string} workflowId
611
+ * @param {string} nodeId
612
+ * @param {{model?:string,effort?:string,fanOut?:boolean,askQuestions?:boolean}} selection
613
+ * @returns {Promise<void>}
614
+ */
615
+ export async function setNodeModel(projectDir, workflowId, nodeId, selection = {}) {
616
+ const model = typeof selection.model === 'string' ? selection.model.trim() : '';
617
+ const effort = typeof selection.effort === 'string' ? selection.effort.trim() : '';
618
+ const models = await listModels(projectDir);
619
+ const entry = model ? models.find((m) => m.id === model) : null;
620
+ if (model && !entry) throw new Error(`unknown model "${model}"`);
621
+ if (effort) {
622
+ if (!EFFORTS.includes(effort)) throw new Error(`unknown effort "${effort}"`);
623
+ if (!entry) throw new Error('select a model before choosing an effort');
624
+ if (!entry.efforts.includes(effort)) {
625
+ throw new Error(`model "${model}" does not support effort "${effort}"`);
626
+ }
627
+ }
628
+
629
+ const key = projectKey(projectDir);
630
+ getDb();
631
+ const prev = prepare(
632
+ 'SELECT fan_out, ask_questions FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
633
+ ).get(key, workflowId, nodeId);
634
+ const prevFanOut = prev && prev.fan_out !== null && prev.fan_out !== undefined ? !!prev.fan_out : undefined;
635
+ const fanOut = inheritOr(selection.fanOut, prevFanOut);
636
+ const prevAsk = prev && prev.ask_questions !== null && prev.ask_questions !== undefined ? !!prev.ask_questions : undefined;
637
+ const askQuestions = inheritOr(selection.askQuestions, prevAsk);
638
+ const sel = cleanNodeSel({ model: selection.model, effort: selection.effort, fanOut, askQuestions });
639
+
640
+ tx(() => {
641
+ if (!sel) {
642
+ prepare(
643
+ 'DELETE FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
644
+ ).run(key, workflowId, nodeId);
645
+ return;
646
+ }
647
+ prepare(`
648
+ INSERT INTO config_workflow_nodes (project_key, workflow_id, node_id, model, effort, fan_out, ask_questions)
649
+ VALUES (?, ?, ?, ?, ?, ?, ?)
650
+ ON CONFLICT(project_key, workflow_id, node_id)
651
+ DO UPDATE SET model = excluded.model, effort = excluded.effort,
652
+ fan_out = excluded.fan_out, ask_questions = excluded.ask_questions
653
+ `).run(
654
+ key, workflowId, nodeId,
655
+ sel.model ?? null,
656
+ sel.effort ?? null,
657
+ sel.fanOut === undefined ? null : (sel.fanOut ? 1 : 0),
658
+ sel.askQuestions === undefined ? null : (sel.askQuestions ? 1 : 0),
659
+ );
660
+ });
661
+ }
662
+
663
+ /**
664
+ * Set the cycle count for one feedback loop of a workflow. Coerced to an integer
665
+ * >= 1 (a loop runs at least once). Writes only config_workflow_feedbacks.
666
+ * @param {string} projectDir
667
+ * @param {string} workflowId
668
+ * @param {string} fbId
669
+ * @param {number} maxCycles
670
+ * @returns {Promise<void>}
671
+ */
672
+ export async function setFeedbackCycles(projectDir, workflowId, fbId, maxCycles) {
673
+ const n = Math.max(1, Math.floor(Number(maxCycles) || 0) || 1);
674
+ const key = projectKey(projectDir);
675
+ tx(() => {
676
+ prepare(`
677
+ INSERT INTO config_workflow_feedbacks (project_key, workflow_id, fb_id, max_cycles)
678
+ VALUES (?, ?, ?, ?)
679
+ ON CONFLICT(project_key, workflow_id, fb_id) DO UPDATE SET max_cycles = excluded.max_cycles
680
+ `).run(key, workflowId, fbId, n);
681
+ });
682
+ }
683
+
684
+ /**
685
+ * Drop every per-project override for one workflow — the New-Pipeline accordion's
686
+ * "Reset to defaults" (newpipeline-ux-design.md §4.5). Deletes the workflow's
687
+ * config_workflow_nodes + config_workflow_feedbacks rows, so each node falls back
688
+ * to the workflow's own defaults and then the agent registry.
689
+ *
690
+ * For the built-in default workflow it ALSO clears the legacy per-role `steps`
691
+ * blob: that is where the Default workflow's overrides actually live, so a reset
692
+ * that skipped it would leave the page showing "all defaults" while the run still
693
+ * used the old models. customModels / activeWorkflowId / extra are untouched.
694
+ *
695
+ * @param {string} projectDir
696
+ * @param {string} workflowId
697
+ * @returns {Promise<void>}
698
+ */
699
+ export async function resetWorkflowConfig(projectDir, workflowId) {
700
+ const id = String(workflowId || '').trim();
701
+ if (!id) throw new Error('workflowId is required');
702
+ const key = projectKey(projectDir);
703
+ const clearLegacy = id === 'wf_default';
704
+ const cfg = clearLegacy ? readRaw(projectDir) : null;
705
+ getDb();
706
+ tx(() => {
707
+ prepare('DELETE FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ?').run(key, id);
708
+ prepare('DELETE FROM config_workflow_feedbacks WHERE project_key = ? AND workflow_id = ?').run(key, id);
709
+ if (clearLegacy) {
710
+ prepare(`
711
+ INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
712
+ VALUES (?, '{}', ?, NULL, '{}')
713
+ ON CONFLICT(project_key) DO UPDATE SET steps = '{}'
714
+ `).run(key, JSON.stringify(cfg.customModels || []));
715
+ }
716
+ });
717
+ }
718
+
719
+ /**
720
+ * Remember the last workflow selected in New Pipeline. Writes only
721
+ * project_config.active_workflow_id; steps/custom_models/extra are preserved.
722
+ * @param {string} projectDir
723
+ * @param {string} workflowId
724
+ * @returns {Promise<void>}
725
+ */
726
+ export async function setActiveWorkflow(projectDir, workflowId) {
727
+ const key = projectKey(projectDir);
728
+ const active = String(workflowId || '').trim();
729
+ tx(() => {
730
+ prepare(`
731
+ INSERT INTO project_config (project_key, steps, custom_models, active_workflow_id, extra)
732
+ VALUES (?, '{}', '[]', ?, '{}')
733
+ ON CONFLICT(project_key) DO UPDATE SET active_workflow_id = excluded.active_workflow_id
734
+ `).run(key, active);
735
+ });
736
+ }
737
+
738
+ /**
739
+ * Resolve just the run-config for one workflow into { nodes, feedbacks } maps
740
+ * (the inputs resolveWorkflow overlays on the template). Unconfigured => empties.
741
+ * @param {string} projectDir
742
+ * @param {string} workflowId
743
+ * @returns {Promise<{nodes:Record<string,object>,feedbacks:Record<string,{maxCycles:number}>}>}
744
+ */
745
+ export async function resolveRunConfig(projectDir, workflowId) {
746
+ const wf = readWorkflowsMap(projectKey(projectDir))[workflowId] || {};
747
+ return {
748
+ nodes: wf.nodes && typeof wf.nodes === 'object' ? wf.nodes : {},
749
+ feedbacks: wf.feedbacks && typeof wf.feedbacks === 'object' ? wf.feedbacks : {},
750
+ };
751
+ }
752
+
753
+ // ── global catalog removal (design §4.5) ──────────────────────────────────────
754
+ // Removing a GLOBAL entry can dangle refs in EVERY project, unlike
755
+ // removeCustomModel's single-project scope. Two carve-outs keep refs that stay
756
+ // resolvable: (1) removing a predefined SHADOW merely reverts to the built-in
757
+ // entry, so nothing dangles; (2) a project whose legacy customModels carries
758
+ // the same id keeps its refs — the id still resolves there (composeCatalog
759
+ // ranks the legacy entry back in once the global one is gone).
760
+
761
+ /** All project_config rows with parsed steps/customModels (raw, all projects). */
762
+ function allProjectConfigRows() {
763
+ getDb();
764
+ return prepare('SELECT project_key, steps, custom_models FROM project_config').all().map((r) => ({
765
+ projectKey: r.project_key,
766
+ steps: sanitizeSteps(parseJson(r.steps, {})),
767
+ customModels: sanitizeCustom(parseJson(r.custom_models, [])),
768
+ }));
769
+ }
770
+
771
+ /**
772
+ * Preview what removing a global catalog entry would clear, for the UI's
773
+ * confirmation dialog. `predefinedShadow: true` means the removal only reverts
774
+ * an override and clears nothing. Synchronous; never throws.
775
+ * @param {string} id
776
+ * @returns {{predefinedShadow: boolean,
777
+ * steps: Array<{projectKey:string, step:string}>,
778
+ * nodes: Array<{projectKey:string, workflowId:string, nodeId:string}>}}
779
+ */
780
+ export function globalModelRefs(id) {
781
+ const lc = (typeof id === 'string' ? id : '').trim().toLowerCase();
782
+ if (PREDEFINED_MODELS.some((m) => m.id.toLowerCase() === lc)) {
783
+ return { predefinedShadow: true, steps: [], nodes: [] };
784
+ }
785
+ return { predefinedShadow: false, ...refsForModelId(lc, allProjectConfigRows()) };
786
+ }
787
+
788
+ /** Cross-project step/node refs to one lowercased model id, minus projects
789
+ * whose legacy customModels carry the same id (those keep resolving). */
790
+ function refsForModelId(lc, rows) {
791
+ const keep = new Set(rows
792
+ .filter((r) => r.customModels.some((m) => m.id.toLowerCase() === lc))
793
+ .map((r) => r.projectKey));
794
+ const steps = [];
795
+ for (const r of rows) {
796
+ if (keep.has(r.projectKey)) continue;
797
+ for (const [step, v] of Object.entries(r.steps)) {
798
+ if (v?.model && v.model.toLowerCase() === lc) steps.push({ projectKey: r.projectKey, step });
799
+ }
800
+ }
801
+ const nodes = prepare(
802
+ 'SELECT project_key, workflow_id, node_id FROM config_workflow_nodes WHERE model = ? COLLATE NOCASE'
803
+ ).all(lc)
804
+ .filter((r) => !keep.has(r.project_key))
805
+ .map((r) => ({ projectKey: r.project_key, workflowId: r.workflow_id, nodeId: r.node_id }));
806
+ return { steps, nodes };
807
+ }
808
+
809
+ /**
810
+ * Uninstall guard input (design §9.4, block-with-list): the plugin's model ids
811
+ * that pipeline configuration still references AND that would stop resolving
812
+ * once the plugin is gone. Carve-outs — the id keeps resolving, so it does not
813
+ * block — mirror removeGlobalModelAndRefs: (1) a user GLOBAL entry shadows it;
814
+ * (2) a PREDEFINED id (removal reverts to the built-in); (3) another enabled
815
+ * plugin ships the same id; (4) per-project legacy customModels (inside
816
+ * refsForModelId). Synchronous; never throws.
817
+ * @param {string} pluginName
818
+ * @returns {Array<{id:string, steps:Array, nodes:Array}>}
819
+ */
820
+ export function referencedPluginModels(pluginName) {
821
+ const all = allPluginModels();
822
+ const mine = all.filter((m) => m.plugin === pluginName);
823
+ if (!mine.length) return [];
824
+ const globalIds = new Set(listGlobalModels().map((m) => m.id.toLowerCase()));
825
+ const predefinedIds = new Set(PREDEFINED_MODELS.map((m) => m.id.toLowerCase()));
826
+ const rows = allProjectConfigRows();
827
+ const out = [];
828
+ for (const m of mine) {
829
+ const lc = m.id.toLowerCase();
830
+ if (globalIds.has(lc) || predefinedIds.has(lc)) continue;
831
+ if (all.some((o) => o.plugin !== pluginName && o.id.toLowerCase() === lc)) continue;
832
+ const { steps, nodes } = refsForModelId(lc, rows);
833
+ if (steps.length || nodes.length) out.push({ id: m.id, steps, nodes });
834
+ }
835
+ return out;
836
+ }
837
+
838
+ /**
839
+ * Remove a global catalog entry AND every ref it would dangle (per-node rows
840
+ * and legacy step selections, across all projects, minus the carve-outs
841
+ * above). Ref purge and settings removal are not one transaction — a purge
842
+ * that lands without the removal (or vice versa on a crash) is harmless, since
843
+ * refs can be re-set and purging is idempotent.
844
+ * @param {string} id
845
+ * @returns {Promise<{clearedSteps:number, clearedNodes:number, predefinedShadow:boolean}>}
846
+ * @throws {Error} on an unknown id (from removeGlobalModel)
847
+ */
848
+ export async function removeGlobalModelAndRefs(id) {
849
+ const target = (typeof id === 'string' ? id : '').trim();
850
+ if (!listGlobalModels().some((m) => m.id.toLowerCase() === target.toLowerCase())) {
851
+ // Guard BEFORE the purge: an unknown id must throw without touching refs
852
+ // (they may belong to a legacy per-project model with the same string).
853
+ throw new Error(`unknown model id ${JSON.stringify(target)}`);
854
+ }
855
+ const refs = globalModelRefs(id);
856
+ let clearedSteps = 0;
857
+ let clearedNodes = 0;
858
+ if (!refs.predefinedShadow && (refs.steps.length || refs.nodes.length)) {
859
+ const lc = String(id).trim().toLowerCase();
860
+ const rows = allProjectConfigRows();
861
+ const stepKeysByProject = new Map(refs.steps.map((s) => [s.projectKey, true]));
862
+ tx(() => {
863
+ for (const r of rows) {
864
+ if (!stepKeysByProject.has(r.projectKey)) continue;
865
+ const filtered = {};
866
+ for (const [k, v] of Object.entries(r.steps)) {
867
+ if (v?.model && v.model.toLowerCase() === lc) { clearedSteps += 1; continue; }
868
+ filtered[k] = v;
869
+ }
870
+ prepare('UPDATE project_config SET steps = ? WHERE project_key = ?')
871
+ .run(JSON.stringify(filtered), r.projectKey);
872
+ }
873
+ for (const n of refs.nodes) {
874
+ clearedNodes += prepare(
875
+ 'DELETE FROM config_workflow_nodes WHERE project_key = ? AND workflow_id = ? AND node_id = ?'
876
+ ).run(n.projectKey, n.workflowId, n.nodeId).changes;
877
+ }
878
+ });
879
+ }
880
+ await removeGlobalModel(id); // throws on unknown id — AFTER the idempotent purge
881
+ return { clearedSteps, clearedNodes, predefinedShadow: refs.predefinedShadow };
882
+ }