@worca/ui 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/styles.css CHANGED
@@ -3436,29 +3436,27 @@ sl-details.learnings-panel::part(content) {
3436
3436
  align-items: center;
3437
3437
  }
3438
3438
 
3439
- .pr-details-section {
3440
- margin-top: 8px;
3441
- }
3442
-
3443
- .pr-details-table {
3444
- border-collapse: collapse;
3439
+ .pr-info-strip {
3440
+ display: flex;
3441
+ flex-wrap: wrap;
3442
+ gap: 4px 20px;
3445
3443
  font-size: 13px;
3446
- width: 100%;
3447
- }
3448
-
3449
- .pr-details-table td {
3450
- padding: 3px 8px 3px 0;
3451
- vertical-align: middle;
3444
+ color: var(--muted);
3445
+ margin-top: 8px;
3446
+ padding-top: 8px;
3447
+ border-top: 1px solid var(--border-subtle);
3448
+ align-items: center;
3452
3449
  }
3453
3450
 
3454
- .pr-details-table td.meta-label {
3451
+ .pr-info-item {
3452
+ display: inline-flex;
3453
+ align-items: center;
3454
+ gap: 4px;
3455
3455
  white-space: nowrap;
3456
- width: 1%;
3457
- padding-right: 10px;
3458
3456
  }
3459
3457
 
3460
- .pr-commit-cell {
3461
- display: flex;
3458
+ .pr-info-strip .run-pr-link {
3459
+ display: inline-flex;
3462
3460
  align-items: center;
3463
3461
  gap: 4px;
3464
3462
  }
@@ -3472,13 +3470,8 @@ sl-details.learnings-panel::part(content) {
3472
3470
  border: 1px solid var(--border-subtle);
3473
3471
  }
3474
3472
 
3475
- .pr-branch-flow {
3476
- font-family: monospace;
3477
- font-size: 12px;
3478
- }
3479
-
3480
3473
  .pr-title-badge {
3481
- margin-left: 6px;
3474
+ /* spacing handled by parent .pipeline-stage-header gap */
3482
3475
  }
3483
3476
 
3484
3477
  .classification-strip {
@@ -4657,3 +4650,167 @@ sl-tooltip.bead-tooltip::part(body) {
4657
4650
  justify-content: flex-end;
4658
4651
  width: 100%;
4659
4652
  }
4653
+
4654
+ /* ─── Models tab — model cards + env rows ──────────────────────────── */
4655
+ .models-cards {
4656
+ grid-template-columns: repeat(auto-fill, minmax(440px, 1fr));
4657
+ }
4658
+
4659
+ .model-card {
4660
+ display: flex;
4661
+ flex-direction: column;
4662
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
4663
+ }
4664
+
4665
+ .model-card.is-dirty {
4666
+ border-color: var(--status-running, #3b82f6);
4667
+ box-shadow: 0 0 0 1px var(--status-running, #3b82f6);
4668
+ }
4669
+
4670
+ .model-delete-btn {
4671
+ /* Aligned left in the footer action row via the flex layout (the status
4672
+ span between it and Discard/Save has flex:1 and grows). */
4673
+ }
4674
+
4675
+ .model-id-input::part(input) {
4676
+ font-family: var(--sl-font-mono);
4677
+ font-size: 12px;
4678
+ }
4679
+
4680
+ .settings-label-row {
4681
+ display: flex;
4682
+ align-items: baseline;
4683
+ justify-content: space-between;
4684
+ gap: 8px;
4685
+ }
4686
+
4687
+ .settings-muted-small {
4688
+ font-size: 11px;
4689
+ color: var(--muted);
4690
+ font-weight: 500;
4691
+ text-transform: uppercase;
4692
+ letter-spacing: 0.03em;
4693
+ }
4694
+
4695
+ .model-env-table {
4696
+ display: flex;
4697
+ flex-direction: column;
4698
+ gap: 6px;
4699
+ }
4700
+
4701
+ .model-env-row {
4702
+ display: grid;
4703
+ grid-template-columns: minmax(0, 240px) minmax(0, 1fr) 18px 30px;
4704
+ gap: 6px;
4705
+ align-items: center;
4706
+ }
4707
+
4708
+ .model-env-key::part(input) {
4709
+ font-family: var(--sl-font-mono);
4710
+ font-size: 12px;
4711
+ }
4712
+
4713
+ .model-env-key.is-invalid::part(base) {
4714
+ border-color: var(--status-failed, #ef4444);
4715
+ }
4716
+
4717
+ .model-env-value::part(input) {
4718
+ font-size: 12px;
4719
+ }
4720
+
4721
+ .model-env-warn {
4722
+ display: inline-flex;
4723
+ align-items: center;
4724
+ justify-content: center;
4725
+ color: var(--status-failed, #ef4444);
4726
+ font-size: 14px;
4727
+ line-height: 1;
4728
+ cursor: help;
4729
+ }
4730
+
4731
+ .model-env-warn-spacer {
4732
+ display: inline-block;
4733
+ width: 18px;
4734
+ }
4735
+
4736
+ .model-env-remove {
4737
+ color: var(--muted);
4738
+ --sl-spacing-medium: 0;
4739
+ }
4740
+
4741
+ .model-env-add-btn {
4742
+ align-self: flex-start;
4743
+ margin-top: 4px;
4744
+ }
4745
+
4746
+ .model-card-actions {
4747
+ display: flex;
4748
+ align-items: center;
4749
+ gap: 8px;
4750
+ margin-top: 14px;
4751
+ padding-top: 12px;
4752
+ border-top: 1px solid var(--border-subtle);
4753
+ }
4754
+
4755
+ .model-card-status {
4756
+ flex: 1;
4757
+ font-size: 11px;
4758
+ color: var(--muted);
4759
+ font-style: italic;
4760
+ text-align: center;
4761
+ }
4762
+
4763
+ .settings-tab-description {
4764
+ font-size: 12px;
4765
+ color: var(--muted);
4766
+ margin: 0 0 16px 0;
4767
+ line-height: 1.5;
4768
+ }
4769
+
4770
+ .settings-tab-description code {
4771
+ background: var(--bg-tertiary);
4772
+ padding: 1px 5px;
4773
+ border-radius: 3px;
4774
+ font-family: var(--sl-font-mono);
4775
+ font-size: 11px;
4776
+ color: var(--fg);
4777
+ }
4778
+
4779
+ .models-add-row {
4780
+ margin-top: 24px;
4781
+ padding-top: 16px;
4782
+ border-top: 1px solid var(--border-subtle);
4783
+ }
4784
+
4785
+ .models-add-controls {
4786
+ display: flex;
4787
+ gap: 8px;
4788
+ align-items: flex-end;
4789
+ }
4790
+
4791
+ .models-add-controls sl-input {
4792
+ flex: 1;
4793
+ }
4794
+
4795
+ /* ─── Confirmation dialog — "cannot be undone" warning row ────────── */
4796
+ .confirm-warning {
4797
+ display: flex;
4798
+ align-items: flex-start;
4799
+ gap: 8px;
4800
+ margin: 0.75rem 0 0;
4801
+ padding: 8px 10px;
4802
+ background: rgba(245, 158, 11, 0.08);
4803
+ border-left: 3px solid var(--status-paused, #f59e0b);
4804
+ border-radius: 4px;
4805
+ color: var(--fg);
4806
+ font-size: 13px;
4807
+ font-weight: 500;
4808
+ line-height: 1.4;
4809
+ }
4810
+
4811
+ .confirm-warning > span:first-child {
4812
+ color: var(--status-paused, #f59e0b);
4813
+ font-size: 15px;
4814
+ line-height: 1.2;
4815
+ flex: 0 0 auto;
4816
+ }
@@ -14,6 +14,8 @@ export const STAGE_ORDER = [
14
14
  'learn',
15
15
  ];
16
16
 
17
+ export const STAGE_VALUES = new Set(STAGE_ORDER);
18
+
17
19
  /** Stage order with orchestrator prepended (for log display). */
18
20
  export const STAGE_ORDER_WITH_ORCHESTRATOR = ['orchestrator', ...STAGE_ORDER];
19
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@worca/ui",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Pipeline monitoring UI for worca-cc",
5
5
  "license": "MIT",
6
6
  "author": "Sinisha Djukic",
@@ -25,6 +25,7 @@
25
25
  "bin/worca-ui.js",
26
26
  "server/**/*.js",
27
27
  "server/schemas/keys.json",
28
+ "server/reserved-env-keys.json",
28
29
  "!server/**/*.test.js",
29
30
  "!server/test/**",
30
31
  "!server/**/test/**",
@@ -0,0 +1,43 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { dirname, join, resolve } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ const execFileAsync = promisify(execFile);
7
+
8
+ /**
9
+ * Ensure the bd daemon is running for the project at worcaDir.
10
+ * Best-effort — all errors swallowed.
11
+ *
12
+ * Probes `bd daemon status` first. The `daemon.stopped` sentinel only blocks
13
+ * auto-start; if the daemon is already running (e.g. started manually outside
14
+ * worca), we report it as up regardless of the sentinel.
15
+ */
16
+ export async function ensureBdDaemon(worcaDir) {
17
+ const beadsDir = resolve(join(worcaDir, '..', '.beads'));
18
+ if (!existsSync(beadsDir)) return false;
19
+
20
+ const workspaceDir = dirname(beadsDir);
21
+ const opts = {
22
+ encoding: 'utf8',
23
+ timeout: 5000,
24
+ env: { ...process.env, BEADS_DIR: beadsDir },
25
+ cwd: workspaceDir,
26
+ };
27
+
28
+ try {
29
+ await execFileAsync('bd', ['daemon', 'status'], opts);
30
+ return true;
31
+ } catch {
32
+ // not running — sentinel may block auto-start below
33
+ }
34
+
35
+ if (existsSync(join(beadsDir, 'daemon.stopped'))) return false;
36
+
37
+ try {
38
+ await execFileAsync('bd', ['daemon', 'start'], opts);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
@@ -49,7 +49,10 @@ export function dbExists(beadsDb) {
49
49
  export async function listIssues(beadsDb) {
50
50
  try {
51
51
  const issues = await runBd(['list', '--limit', '0'], beadsDb);
52
- return enrichWithDeps(issues, beadsDb);
52
+ // Must await here — without it, an enrichWithDeps rejection (e.g. bd show
53
+ // SIGTERM under daemon contention) escapes the try/catch and propagates
54
+ // to the WS handler as an unhandled rejection, crashing Node.
55
+ return await enrichWithDeps(issues, beadsDb);
53
56
  } catch {
54
57
  return [];
55
58
  }
@@ -61,7 +64,7 @@ export async function listIssuesByLabel(beadsDb, label) {
61
64
  ['list', '--label-any', label, '--all', '--limit', '0'],
62
65
  beadsDb,
63
66
  );
64
- return enrichWithDeps(issues, beadsDb);
67
+ return await enrichWithDeps(issues, beadsDb);
65
68
  } catch {
66
69
  return [];
67
70
  }
@@ -92,37 +95,48 @@ export async function listUnlinkedIssues(beadsDb) {
92
95
  /**
93
96
  * Returns { runId: { total, done } } for every run:<id> label in the beads db.
94
97
  *
95
- * `total` comes from the cheap `bd label list-all` count. `done` requires
96
- * looking at issue status, so we query `bd list --label-any run:<id>` per
97
- * run and count statuses === "closed". N+1 queries, but N is bounded by
98
- * the number of pipeline runs and this endpoint is called on app load /
99
- * project switch only, not on every render.
98
+ * Single-pass: bd label list-all (totals) + bd list --all (ids) +
99
+ * bd show <all-ids> (labels + statuses), then group by run labels in JS.
100
+ * Always 3 bd calls regardless of run-label count.
101
+ *
102
+ * Called by the beads watcher on every db change (counts are included in the
103
+ * broadcast payload) and by the list-beads-counts endpoint for initial load
104
+ * and project switch.
100
105
  */
101
106
  export async function countIssuesByRunLabel(beadsDb) {
102
107
  try {
103
108
  const rows = await runBd(['label', 'list-all'], beadsDb);
104
109
  const counts = {};
105
110
  const runLabels = rows.filter((r) => r.label.startsWith('run:'));
111
+ if (runLabels.length === 0) return counts;
106
112
  for (const row of runLabels) {
107
113
  counts[row.label.replace('run:', '')] = { total: row.count, done: 0 };
108
114
  }
109
- // Count closed issues per label in parallel.
110
- await Promise.all(
111
- runLabels.map(async (row) => {
112
- const runId = row.label.replace('run:', '');
113
- try {
114
- const issues = await runBd(
115
- ['list', '--label-any', row.label, '--all', '--limit', '0'],
116
- beadsDb,
117
- );
118
- counts[runId].done = issues.filter(
119
- (i) => i.status === 'closed',
120
- ).length;
121
- } catch {
122
- /* leave done at 0 on per-run failure */
115
+ try {
116
+ const issues = await runBd(['list', '--all', '--limit', '0'], beadsDb);
117
+ if (issues.length === 0) return counts;
118
+ const detailed = await runBd(
119
+ ['show', ...issues.map((i) => i.id)],
120
+ beadsDb,
121
+ );
122
+ for (const issue of detailed) {
123
+ if (issue.status !== 'closed') continue;
124
+ for (const label of issue.labels || []) {
125
+ if (label.startsWith('run:')) {
126
+ const runId = label.replace('run:', '');
127
+ if (counts[runId]) counts[runId].done++;
128
+ }
123
129
  }
124
- }),
125
- );
130
+ }
131
+ } catch (err) {
132
+ // Leave done=0 for all runs on list/show failure. Logged so a stale
133
+ // "0/N" badge can be traced back to bd subprocess timeout (typically
134
+ // daemon contention) rather than mistaken for "no closed issues."
135
+ // The next watcher tick recomputes and corrects.
136
+ console.warn(
137
+ `[countIssuesByRunLabel] bd list/show failed; counts.done left at 0: ${err?.message || err}`,
138
+ );
139
+ }
126
140
  return counts;
127
141
  } catch {
128
142
  return {};
@@ -0,0 +1,189 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { Router } from 'express';
4
+ import { atomicWriteSync } from './atomic-write.js';
5
+ import { localPathFor } from './settings-merge.js';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const denylist = require('./reserved-env-keys.json');
9
+ const RESERVED_KEYS = new Set(denylist.keys);
10
+ const RESERVED_PREFIXES = denylist.prefixes;
11
+
12
+ function isReservedKey(key) {
13
+ if (RESERVED_KEYS.has(key)) return true;
14
+ return RESERVED_PREFIXES.some((p) => key.startsWith(p));
15
+ }
16
+
17
+ function readJsonOr(path, fallback) {
18
+ if (!existsSync(path)) return fallback;
19
+ try {
20
+ return JSON.parse(readFileSync(path, 'utf8'));
21
+ } catch {
22
+ return fallback;
23
+ }
24
+ }
25
+
26
+ export function createModelEnvRouter({ settingsPath: staticPath } = {}) {
27
+ const router = Router({ mergeParams: true });
28
+
29
+ function resolveSettingsPath(req) {
30
+ return req.project?.settingsPath || staticPath;
31
+ }
32
+
33
+ router.put('/', (req, res) => {
34
+ const { model, id, env } = req.body || {};
35
+
36
+ if (!model || typeof model !== 'string') {
37
+ return res
38
+ .status(400)
39
+ .json({ ok: false, error: 'model name is required' });
40
+ }
41
+ if (id != null && typeof id !== 'string') {
42
+ return res.status(400).json({ ok: false, error: 'id must be a string' });
43
+ }
44
+ if (env != null && (typeof env !== 'object' || Array.isArray(env))) {
45
+ return res
46
+ .status(400)
47
+ .json({ ok: false, error: 'env must be an object' });
48
+ }
49
+
50
+ const envIn = env || {};
51
+ for (const [key, value] of Object.entries(envIn)) {
52
+ if (typeof key !== 'string' || key === '') {
53
+ return res
54
+ .status(400)
55
+ .json({ ok: false, error: 'env keys must be non-empty strings' });
56
+ }
57
+ if (isReservedKey(key)) {
58
+ return res.status(400).json({
59
+ ok: false,
60
+ key,
61
+ error: `Key "${key}" is reserved and cannot be used as a model env var`,
62
+ });
63
+ }
64
+ if (typeof value !== 'string') {
65
+ return res.status(400).json({
66
+ ok: false,
67
+ key,
68
+ error: `value for "${key}" must be a string`,
69
+ });
70
+ }
71
+ }
72
+
73
+ const settingsPath = resolveSettingsPath(req);
74
+ if (!settingsPath) {
75
+ return res
76
+ .status(501)
77
+ .json({ ok: false, error: 'settingsPath not configured' });
78
+ }
79
+
80
+ // Storage split (deliberate after the W-051 simplification):
81
+ // settings.json — public model entry: string id or { id }, NEVER env
82
+ // settings.local.json — { env } only, NEVER id
83
+ //
84
+ // Writing env to local while leaving env behind in settings.json would
85
+ // let deep-merge resurrect deleted keys (a key removed in the UI but
86
+ // still present in settings.json would reappear on next load). So PUT
87
+ // actively strips env from the settings.json entry whenever it writes
88
+ // env to local, and conversely never lets id leak into local.
89
+ const localPath = localPathFor(settingsPath);
90
+ const local = readJsonOr(localPath, {});
91
+ if (!local.worca) local.worca = {};
92
+ if (!local.worca.models) local.worca.models = {};
93
+
94
+ if (Object.keys(envIn).length === 0) {
95
+ delete local.worca.models[model];
96
+ } else {
97
+ local.worca.models[model] = { env: { ...envIn } };
98
+ }
99
+ atomicWriteSync(localPath, `${JSON.stringify(local, null, 2)}\n`);
100
+
101
+ // settings.json: keep/update id, drop env entirely. If the model
102
+ // doesn't exist there and no id was supplied, skip the file. If id is
103
+ // explicitly an empty string, treat it as "no id" and drop the entry.
104
+ const base = readJsonOr(settingsPath, {});
105
+ if (!base.worca) base.worca = {};
106
+ if (!base.worca.models) base.worca.models = {};
107
+
108
+ const baseEntry = base.worca.models[model];
109
+ let resolvedId = id;
110
+ if (resolvedId == null) {
111
+ if (typeof baseEntry === 'string') resolvedId = baseEntry;
112
+ else if (baseEntry && typeof baseEntry === 'object')
113
+ resolvedId = baseEntry.id;
114
+ }
115
+
116
+ let baseChanged = false;
117
+ if (resolvedId) {
118
+ // Prefer string form when there's no other metadata — keeps JSON minimal.
119
+ const nextBaseEntry = resolvedId;
120
+ if (JSON.stringify(baseEntry) !== JSON.stringify(nextBaseEntry)) {
121
+ base.worca.models[model] = nextBaseEntry;
122
+ baseChanged = true;
123
+ }
124
+ } else if (baseEntry !== undefined) {
125
+ delete base.worca.models[model];
126
+ baseChanged = true;
127
+ }
128
+ if (baseChanged) {
129
+ atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
130
+ }
131
+
132
+ res.json({ ok: true, model, id: resolvedId || null, env: { ...envIn } });
133
+ });
134
+
135
+ router.delete('/', (req, res) => {
136
+ const model =
137
+ req.body?.model ||
138
+ (typeof req.query?.model === 'string' ? req.query.model : null);
139
+
140
+ if (!model) {
141
+ return res
142
+ .status(400)
143
+ .json({ ok: false, error: 'model name is required' });
144
+ }
145
+
146
+ const settingsPath = resolveSettingsPath(req);
147
+ if (!settingsPath) {
148
+ return res
149
+ .status(501)
150
+ .json({ ok: false, error: 'settingsPath not configured' });
151
+ }
152
+
153
+ // Remove from BOTH files so deep-merge can't resurrect the entry. The
154
+ // settings POST endpoint deep-merges and cannot remove a key, so we
155
+ // operate on disk directly. This is intentional — "Delete model" in
156
+ // the UI means the model goes away, full stop.
157
+ let removedFromBase = false;
158
+ let removedFromLocal = false;
159
+
160
+ if (existsSync(settingsPath)) {
161
+ const base = readJsonOr(settingsPath, {});
162
+ if (base?.worca?.models && model in base.worca.models) {
163
+ delete base.worca.models[model];
164
+ atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
165
+ removedFromBase = true;
166
+ }
167
+ }
168
+
169
+ const localPath = localPathFor(settingsPath);
170
+ if (existsSync(localPath)) {
171
+ const local = readJsonOr(localPath, {});
172
+ if (local?.worca?.models && model in local.worca.models) {
173
+ delete local.worca.models[model];
174
+ atomicWriteSync(localPath, `${JSON.stringify(local, null, 2)}\n`);
175
+ removedFromLocal = true;
176
+ }
177
+ }
178
+
179
+ res.json({
180
+ ok: true,
181
+ model,
182
+ removed: removedFromBase || removedFromLocal,
183
+ fromBase: removedFromBase,
184
+ fromLocal: removedFromLocal,
185
+ });
186
+ });
187
+
188
+ return router;
189
+ }
@@ -0,0 +1,13 @@
1
+ const DEFAULT_MODELS = ['opus', 'sonnet', 'haiku'];
2
+
3
+ export function deriveValidModels(worcaObj) {
4
+ const configuredModels =
5
+ worcaObj?.models &&
6
+ typeof worcaObj.models === 'object' &&
7
+ !Array.isArray(worcaObj.models)
8
+ ? Object.keys(worcaObj.models)
9
+ : [];
10
+ return [...new Set([...DEFAULT_MODELS, ...configuredModels])];
11
+ }
12
+
13
+ export { DEFAULT_MODELS };
@@ -1,9 +1,9 @@
1
1
  import { join } from 'node:path';
2
2
  import { Router } from 'express';
3
+ import { deriveValidModels } from './model-validation.js';
3
4
  import { readGlobalSettings, writeGlobalSettings } from './settings-reader.js';
4
5
 
5
6
  const VALID_CLEANUP_POLICIES = ['never', 'on-success', 'manual-only'];
6
- const VALID_MODELS = ['opus', 'sonnet', 'haiku'];
7
7
  const MIN_DISK_BYTES = 500_000_000;
8
8
  const MAX_DISK_BYTES = 50_000_000_000;
9
9
 
@@ -73,9 +73,10 @@ export function validateGlobalSettingsPayload(body) {
73
73
  ) {
74
74
  details.push('worca.circuit_breaker must be an object');
75
75
  } else if (w.circuit_breaker.classifier_model !== undefined) {
76
- if (!VALID_MODELS.includes(w.circuit_breaker.classifier_model)) {
76
+ const validModels = deriveValidModels(w);
77
+ if (!validModels.includes(w.circuit_breaker.classifier_model)) {
77
78
  details.push(
78
- `classifier_model must be one of: ${VALID_MODELS.join(', ')}`,
79
+ `classifier_model must be one of: ${validModels.join(', ')}`,
79
80
  );
80
81
  }
81
82
  }
@@ -27,6 +27,7 @@ import { dispatchExternal } from './dispatch-external.js';
27
27
  import { ensureWebhookForUi } from './ensure-webhook.js';
28
28
  import { extractAndStripGlobalKeys } from './global-keys.js';
29
29
  import { LaunchLock } from './launch-lock.js';
30
+ import { createModelEnvRouter } from './model-env-routes.js';
30
31
  import { readPreferences } from './preferences.js';
31
32
  import { ProcessManager } from './process-manager.js';
32
33
  import { countRunningPipelinesAcrossProjects } from './process-registry.js';
@@ -430,6 +431,9 @@ export function createProjectScopedRoutes({
430
431
  res.json({ ok: true, files });
431
432
  });
432
433
 
434
+ // --- Model env endpoints (writes wholesale to settings.local.json) ---
435
+ router.use('/settings/model-env', createModelEnvRouter());
436
+
433
437
  // --- Project-scoped settings endpoints ---
434
438
 
435
439
  // GET /api/projects/:projectId/settings
@@ -578,6 +582,7 @@ export function createProjectScopedRoutes({
578
582
  // DELETE /api/projects/:projectId/settings/:section
579
583
  const SECTION_KEYS = {
580
584
  agents: { worca: ['agents'] },
585
+ models: { worca: ['models'] },
581
586
  pipeline: { worca: ['stages', 'loops', 'plan_path_template', 'defaults'] },
582
587
  governance: { worca: ['governance'], top: ['permissions'] },
583
588
  pricing: { worca: ['pricing'] },
@@ -0,0 +1,19 @@
1
+ {
2
+ "keys": [
3
+ "PATH",
4
+ "CLAUDECODE",
5
+ "WORCA_AGENT",
6
+ "WORCA_PROJECT_ROOT",
7
+ "WORCA_RUN_ID",
8
+ "WORCA_RUN_DIR",
9
+ "WORCA_PLAN_FILE",
10
+ "WORCA_EVENTS_PATH",
11
+ "WORCA_TARGET_BRANCH",
12
+ "WORCA_COVERAGE",
13
+ "WORCA_SKIP_BEADS",
14
+ "WORCA_CLAUDE_BIN"
15
+ ],
16
+ "prefixes": [
17
+ "WORCA_"
18
+ ]
19
+ }