@yansigit/opencodex 2.36.1-dev.20260829.47 → 2.36.1-dev.20260829.48

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 (43) hide show
  1. package/gui/dist/assets/{ApiKeys-DAuYQsGJ.js → ApiKeys-LOOFiZfv.js} +1 -1
  2. package/gui/dist/assets/{Claude-bN_80RT8.js → Claude-BwKzpXe3.js} +1 -1
  3. package/gui/dist/assets/{CodexSet-D3dWX_ay.js → CodexSet-B5qi9KeE.js} +1 -1
  4. package/gui/dist/assets/{FileIntegrationPage-BGyzkPs2.js → FileIntegrationPage-B0sUef6W.js} +1 -1
  5. package/gui/dist/assets/{Grok-CuaKd1TT.js → Grok-bsb4n-f4.js} +1 -1
  6. package/gui/dist/assets/{Integrations-C7NGQoxR.js → Integrations-CSMwFDvM.js} +2 -2
  7. package/gui/dist/assets/{IntegrationsOverview-DjVorYO5.js → IntegrationsOverview-CTORdKJA.js} +1 -1
  8. package/gui/dist/assets/{Logs-B21a-9xe.js → Logs-BWYbfELf.js} +1 -1
  9. package/gui/dist/assets/{Models-CN7Q3kuK.js → Models-CexEtdT1.js} +1 -1
  10. package/gui/dist/assets/{NumberStepper-BWg90tEs.js → NumberStepper-BapSFQnW.js} +1 -1
  11. package/gui/dist/assets/{Providers-CP_i-7LS.js → Providers-BwUFeAgA.js} +1 -1
  12. package/gui/dist/assets/{RestoreDialog-3M0NqCiD.js → RestoreDialog-CD8piq90.js} +1 -1
  13. package/gui/dist/assets/{Startup-BVPFoP2s.js → Startup-BgX731C2.js} +1 -1
  14. package/gui/dist/assets/{Storage-BdsG9ypz.js → Storage-Bgq7HphP.js} +1 -1
  15. package/gui/dist/assets/{Subagents-CguR_bGQ.js → Subagents-ChxItbeF.js} +1 -1
  16. package/gui/dist/assets/{Usage-C0kKi9_H.js → Usage-BkylzP50.js} +1 -1
  17. package/gui/dist/assets/{data-surface-BCycbObp.js → data-surface-pCXChiBf.js} +1 -1
  18. package/gui/dist/assets/{index-CU1jE0st.js → index-CNopOid3.js} +2 -2
  19. package/gui/dist/assets/{model-display-D2elXkuE.js → model-display-CiUpg8T9.js} +1 -1
  20. package/gui/dist/assets/{provider-payload-DfkXWE7v.js → provider-payload-b2jlS-On.js} +1 -1
  21. package/gui/dist/index.html +1 -1
  22. package/package.json +1 -1
  23. package/src/cli/config-command.ts +7 -3
  24. package/src/cli/init.ts +2 -2
  25. package/src/cli/provider.ts +58 -74
  26. package/src/codex/convergence.ts +110 -15
  27. package/src/config.ts +209 -11
  28. package/src/generated/compatibility-version.json +24 -20
  29. package/src/oauth/index.ts +143 -82
  30. package/src/oauth/login-cli.ts +53 -11
  31. package/src/providers/alibaba-region-startup.ts +31 -11
  32. package/src/providers/api-keys.ts +58 -34
  33. package/src/providers/key-failover.ts +59 -46
  34. package/src/providers/model-rename-startup.ts +25 -7
  35. package/src/providers/openai-tier-startup.ts +38 -14
  36. package/src/server/index.ts +10 -0
  37. package/src/server/management/context.ts +10 -3
  38. package/src/server/management/logs-usage-routes.ts +19 -16
  39. package/src/server/management/model-routes.ts +284 -165
  40. package/src/server/management/oauth-account-routes.ts +93 -15
  41. package/src/server/management/provider-routes.ts +200 -87
  42. package/src/storage/policy-input.ts +166 -0
  43. package/src/storage/policy.ts +59 -211
@@ -7,7 +7,7 @@
7
7
  * A provider with a legacy bare `apiKey` is seeded into a one-entry pool on first touch.
8
8
  */
9
9
  import { createHash } from "node:crypto";
10
- import { saveConfigPreservingClaudeCode } from "../config";
10
+ import { mutatePersistedConfig } from "../config";
11
11
  import { isAzureIdentityProvider } from "../config/provider-validation";
12
12
  import type { OcxConfig, OcxProviderConfig } from "../types";
13
13
 
@@ -62,6 +62,23 @@ function activeEntryId(provider: OcxProviderConfig): string | null {
62
62
  return (pool.find(e => e.key === provider.apiKey) ?? pool[0]!).id;
63
63
  }
64
64
 
65
+ function mutateProvider<T>(
66
+ config: OcxConfig,
67
+ name: string,
68
+ mutate: (provider: OcxProviderConfig) => T | null,
69
+ ): T | null {
70
+ const outcome = mutatePersistedConfig(fresh => {
71
+ const provider = fresh.providers[name];
72
+ if (!provider || !isKeyAuthProvider(provider)) return { changed: false, value: null };
73
+ const before = JSON.stringify(provider);
74
+ const value = mutate(provider);
75
+ return { changed: value !== null && JSON.stringify(provider) !== before, value: value === null ? null : { provider, value } };
76
+ });
77
+ if (outcome.status === "unavailable" || outcome.value === null) return null;
78
+ config.providers[name] = structuredClone(outcome.value.provider);
79
+ return outcome.value.value;
80
+ }
81
+
65
82
  export function listProviderApiKeys(config: OcxConfig, name: string): { activeId: string | null; keys: ProviderApiKeyInfo[] } {
66
83
  const provider = config.providers[name];
67
84
  if (!provider || !isKeyAuthProvider(provider)) return { activeId: null, keys: [] };
@@ -86,56 +103,63 @@ export function addProviderApiKey(config: OcxConfig, name: string, key: string,
86
103
  if (typeof key !== "string" || !key.trim()) return { error: "key is required" };
87
104
  const trimmed = sanitizeApiKeyValue(key);
88
105
  if (!trimmed) return { error: "key must not include line breaks" };
89
- const pool = ensurePool(provider);
90
- const id = apiKeyPoolEntryId(trimmed);
91
- const existing = pool.find(e => e.id === id);
92
- if (existing) {
93
- if (label?.trim()) existing.label = label.trim();
94
- } else {
106
+ const saved = mutateProvider(config, name, fresh => {
107
+ const pool = ensurePool(fresh);
108
+ const existing = pool.find(entry => entry.key === trimmed);
109
+ if (existing) {
110
+ if (label?.trim()) existing.label = label.trim();
111
+ fresh.apiKey = trimmed;
112
+ return { id: existing.id };
113
+ }
114
+ const id = apiKeyPoolEntryId(trimmed);
115
+ if (pool.some(entry => entry.id === id)) return { error: "key id collision" };
95
116
  pool.push({ id, key: trimmed, ...(label?.trim() ? { label: label.trim() } : {}), addedAt: Date.now() });
96
- }
97
- provider.apiKey = trimmed;
98
- saveConfigPreservingClaudeCode(config);
99
- return { id };
117
+ fresh.apiKey = trimmed;
118
+ return { id };
119
+ });
120
+ return saved === null ? { error: "config is unavailable" } : saved;
100
121
  }
101
122
 
102
123
  /** Switch the ACTIVE key (mirrors into `provider.apiKey`). Persists config. */
103
124
  export function setActiveProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
104
125
  const provider = config.providers[name];
105
126
  if (!provider || !isKeyAuthProvider(provider)) return false;
106
- const entry = ensurePool(provider).find(e => e.id === id);
107
- if (!entry) return false;
108
- provider.apiKey = entry.key;
109
- saveConfigPreservingClaudeCode(config);
110
- return true;
127
+ return mutateProvider(config, name, fresh => {
128
+ const entry = ensurePool(fresh).find(candidate => candidate.id === id);
129
+ if (!entry) return null;
130
+ fresh.apiKey = entry.key;
131
+ return true;
132
+ }) === true;
111
133
  }
112
134
 
113
135
  /** Rename a key slot without changing its id, secret, or active routing state. */
114
136
  export function setProviderApiKeyLabel(config: OcxConfig, name: string, id: string, label: string | undefined): boolean {
115
137
  const provider = config.providers[name];
116
138
  if (!provider || !isKeyAuthProvider(provider)) return false;
117
- const entry = ensurePool(provider).find(e => e.id === id);
118
- if (!entry) return false;
119
- if (label) entry.label = label;
120
- else delete entry.label;
121
- saveConfigPreservingClaudeCode(config);
122
- return true;
139
+ return mutateProvider(config, name, fresh => {
140
+ const entry = ensurePool(fresh).find(candidate => candidate.id === id);
141
+ if (!entry) return null;
142
+ if (label) entry.label = label;
143
+ else delete entry.label;
144
+ return true;
145
+ }) === true;
123
146
  }
124
147
 
125
148
  /** Remove one key; removing the active one promotes the first remaining. Persists config. */
126
149
  export function removeProviderApiKey(config: OcxConfig, name: string, id: string): boolean {
127
150
  const provider = config.providers[name];
128
151
  if (!provider || !isKeyAuthProvider(provider)) return false;
129
- const pool = ensurePool(provider);
130
- const entry = pool.find(e => e.id === id);
131
- if (!entry) return false;
132
- provider.apiKeyPool = pool.filter(e => e.id !== id);
133
- if (provider.apiKey === entry.key) {
134
- const next = provider.apiKeyPool[0];
135
- if (next) provider.apiKey = next.key;
136
- else delete provider.apiKey;
137
- }
138
- if (provider.apiKeyPool.length === 0) delete provider.apiKeyPool;
139
- saveConfigPreservingClaudeCode(config);
140
- return true;
152
+ return mutateProvider(config, name, fresh => {
153
+ const pool = ensurePool(fresh);
154
+ const entry = pool.find(candidate => candidate.id === id);
155
+ if (!entry) return null;
156
+ fresh.apiKeyPool = pool.filter(candidate => candidate.id !== id);
157
+ if (fresh.apiKey === entry.key) {
158
+ const next = fresh.apiKeyPool[0];
159
+ if (next) fresh.apiKey = next.key;
160
+ else delete fresh.apiKey;
161
+ }
162
+ if (fresh.apiKeyPool.length === 0) delete fresh.apiKeyPool;
163
+ return true;
164
+ }) === true;
141
165
  }
@@ -8,8 +8,9 @@
8
8
  *
9
9
  * Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools.
10
10
  */
11
- import { saveConfigPreservingClaudeCode } from "../config";
11
+ import { mutatePersistedConfig } from "../config";
12
12
  import { isAzureIdentityProvider } from "../config/provider-validation";
13
+ import { routedProviderConfig } from "../router";
13
14
  import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy } from "../types";
14
15
  import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
15
16
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
@@ -159,50 +160,66 @@ export function rotateKeyOn429(
159
160
  if (isAzureIdentityProvider(provider)) return null;
160
161
  if (provider.authMode === "oauth" || provider.authMode === "forward") return null;
161
162
 
162
- const pool = provider.apiKeyPool;
163
- if (!pool || pool.length < 2) return null;
164
-
165
- // Cool the key that ACTUALLY failed. Under concurrent 429s another request may already have
166
- // rotated provider.apiKey — cooling the live key would punish an innocent replacement and can
167
- // exhaust a 2-key pool from a single bad key. CAS semantics: callers pass the key they used.
168
163
  const failedKey = attemptedKey ?? provider.apiKey;
169
- const currentEntry = pool.find(e => e.key === failedKey);
170
- if (currentEntry) {
171
- const cooldownMs = parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS;
172
- keyCooldowns.set(cooldownKey(providerName, currentEntry.id), {
173
- cooldownUntil: now + cooldownMs,
174
- });
175
- sweepExpiredOnWrite(now);
176
- }
164
+ type Rotation =
165
+ | { provider: OcxProviderConfig; failedId?: string; candidateId?: string }
166
+ | { exhaustedCount: number };
167
+ const outcome = mutatePersistedConfig<Rotation | null>(fresh => {
168
+ const freshProvider = fresh.providers[providerName];
169
+ if (!freshProvider || isAzureIdentityProvider(freshProvider)
170
+ || freshProvider.authMode === "oauth" || freshProvider.authMode === "forward") {
171
+ return { changed: false, value: null };
172
+ }
173
+ const pool = freshProvider.apiKeyPool;
174
+ if (!pool || pool.length < 2) return { changed: false, value: null };
177
175
 
178
- // Lost the race: someone already rotated away from the failed key. If the live key is healthy,
179
- // retry with it as-is instead of rotating a second time.
180
- if (attemptedKey !== undefined && provider.apiKey !== attemptedKey) {
181
- const liveEntry = pool.find(e => e.key === provider.apiKey);
182
- if (liveEntry && !isKeyInCooldown(providerName, liveEntry.id, now)) {
183
- return { ...provider };
176
+ // Cool the key that actually failed. The callback is rerun after rebasing,
177
+ // so both the id and the active-key comparison come from the commit preimage.
178
+ const failedEntry = pool.find(entry => entry.key === failedKey);
179
+ if (failedEntry) {
180
+ const cooldownMs = parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS;
181
+ keyCooldowns.set(cooldownKey(providerName, failedEntry.id), { cooldownUntil: now + cooldownMs });
182
+ sweepExpiredOnWrite(now);
184
183
  }
185
- }
186
184
 
187
- // Pick the next key that is NOT in cooldown
188
- const currentIndex = currentEntry ? pool.indexOf(currentEntry) : -1;
189
- for (let i = 1; i < pool.length; i++) {
190
- const candidate = pool[(currentIndex + i) % pool.length]!;
191
- if (!isKeyInCooldown(providerName, candidate.id, now)) {
192
- // Swap active key
193
- provider.apiKey = candidate.key;
194
- saveConfigPreservingClaudeCode(config);
195
- console.warn(
196
- // Log ids only — labels are user-supplied free text and could carry secret material.
197
- `[key-failover] ${providerName}: 429 on key ${currentEntry?.id ?? "?"}; rotating to key ${candidate.id}`,
198
- );
199
- return { ...provider };
185
+ if (freshProvider.apiKey !== failedKey) {
186
+ const activeEntry = pool.find(entry => entry.key === freshProvider.apiKey);
187
+ if (activeEntry && !isKeyInCooldown(providerName, activeEntry.id, now)) {
188
+ return {
189
+ changed: false,
190
+ value: { provider: structuredClone(freshProvider), failedId: failedEntry?.id, candidateId: undefined },
191
+ };
192
+ }
200
193
  }
194
+
195
+ const currentIndex = failedEntry ? pool.indexOf(failedEntry) : -1;
196
+ const candidateCount = failedEntry ? pool.length - 1 : pool.length;
197
+ for (let offset = 1; offset <= candidateCount; offset += 1) {
198
+ const candidate = pool[(currentIndex + offset) % pool.length]!;
199
+ if (isKeyInCooldown(providerName, candidate.id, now)) continue;
200
+ freshProvider.apiKey = candidate.key;
201
+ return {
202
+ changed: true,
203
+ value: { provider: structuredClone(freshProvider), failedId: failedEntry?.id, candidateId: candidate.id },
204
+ };
205
+ }
206
+ return { changed: false, value: { exhaustedCount: pool.length } };
207
+ });
208
+ if (outcome.status === "unavailable" || outcome.value === null) return null;
209
+ if ("exhaustedCount" in outcome.value) {
210
+ console.warn(`[key-failover] ${providerName}: all ${outcome.value.exhaustedCount} keys in cooldown; returning 429 to client`);
211
+ return null;
201
212
  }
202
213
 
203
- // All keys in cooldown
204
- console.warn(`[key-failover] ${providerName}: all ${pool.length} keys in cooldown; returning 429 to client`);
205
- return null;
214
+ const committed = structuredClone(outcome.value.provider);
215
+ config.providers[providerName] = committed;
216
+ if (outcome.value.candidateId) {
217
+ console.warn(
218
+ // Log ids only — labels are user-supplied free text and could carry secret material.
219
+ `[key-failover] ${providerName}: 429 on key ${outcome.value.failedId ?? "?"}; rotating to key ${outcome.value.candidateId}`,
220
+ );
221
+ }
222
+ return structuredClone(committed);
206
223
  }
207
224
 
208
225
  export function sweepExpiredApiKeyCooldowns(now = Date.now()): number {
@@ -225,13 +242,9 @@ interface RotateProviderTransportOptions {
225
242
  /**
226
243
  * Rotate a failed key and re-apply provider-specific transport metadata to the replacement.
227
244
  *
228
- * `routedProvider` is the request's active provider (the `routedProviderConfig` output the
229
- * route was built with). The result inherits it and swaps ONLY the API key: the persisted
230
- * config that `rotateKeyOn429` snapshots predates registry backfill, so building the retry
231
- * provider from that snapshot would silently drop every field the registry merged in at
232
- * routing time (scalar flags like `promptCacheKey`/`parallelToolCalls`, merged model
233
- * metadata such as `noTemperatureModels`, a pinned baseUrl). Mirrors the OAuth-401 replay
234
- * path in src/server/responses/core.ts, which spreads `route.provider` for the same reason.
245
+ * Put the authoritative committed row over request-only fields and registry backfills, then
246
+ * route it again so concurrent provider edits take effect without losing either kind of runtime
247
+ * metadata.
235
248
  */
236
249
  export function rotateProviderTransportOn429(
237
250
  config: OcxConfig,
@@ -249,7 +262,7 @@ export function rotateProviderTransportOn429(
249
262
  return rotated
250
263
  ? resolveProviderTransport(
251
264
  providerName,
252
- { ...routedProvider, apiKey: rotated.apiKey },
265
+ routedProviderConfig(providerName, { ...routedProvider, ...rotated }),
253
266
  options.promptCacheKey,
254
267
  )
255
268
  : null;
@@ -1,10 +1,16 @@
1
- import { saveConfig } from "../config";
1
+ import { mutatePersistedConfig } from "../config";
2
2
  import { projectModelRenames } from "./model-rename-migration";
3
3
  import type { OcxConfig } from "../types";
4
4
 
5
5
  export interface ModelRenameStartupDeps {
6
6
  project: typeof projectModelRenames;
7
- save: (config: OcxConfig) => void;
7
+ save?: (config: OcxConfig) => void;
8
+ }
9
+
10
+ function adoptConfig(target: OcxConfig, source: OcxConfig): void {
11
+ if (target === source) return;
12
+ for (const key of Object.keys(target)) delete (target as unknown as Record<string, unknown>)[key];
13
+ Object.assign(target, structuredClone(source));
8
14
  }
9
15
 
10
16
  /**
@@ -18,11 +24,23 @@ export interface ModelRenameStartupDeps {
18
24
  */
19
25
  export function runModelRenameStartupMigration(
20
26
  config: OcxConfig,
21
- deps: ModelRenameStartupDeps = { project: projectModelRenames, save: saveConfig },
27
+ deps: ModelRenameStartupDeps = { project: projectModelRenames },
22
28
  ): OcxConfig {
23
- const projection = deps.project(config);
24
- for (const warning of projection.warnings) console.warn(`[model-rename-migration] ${warning}`);
29
+ const projection = deps.project(structuredClone(config));
25
30
  if (!projection.changed) return projection.config;
26
- deps.save(projection.config);
27
- return projection.config;
31
+ if (deps.save) {
32
+ deps.save(projection.config);
33
+ adoptConfig(config, projection.config);
34
+ for (const warning of projection.warnings) console.warn(`[model-rename-migration] ${warning}`);
35
+ return config;
36
+ }
37
+ const outcome = mutatePersistedConfig(fresh => {
38
+ const next = deps.project(fresh);
39
+ if (next.changed) adoptConfig(fresh, next.config);
40
+ return { changed: next.changed, value: next };
41
+ });
42
+ if (outcome.status === "unavailable") return config;
43
+ adoptConfig(config, outcome.value.config);
44
+ for (const warning of outcome.value.warnings) console.warn(`[model-rename-migration] ${warning}`);
45
+ return config;
28
46
  }
@@ -2,8 +2,8 @@ import {
2
2
  backupConfigBeforeOpenAiTierMigration,
3
3
  OpenAiTierBackupCollisionError,
4
4
  OpenAiTierRollbackPreserveError,
5
+ mutatePersistedConfig,
5
6
  preserveOpenAiTierRollbackSnapshot,
6
- saveConfig,
7
7
  } from "../config";
8
8
  import type { OcxConfig } from "../types";
9
9
  import { projectOpenAiTierMigration } from "./openai-tiers";
@@ -11,7 +11,7 @@ import { projectOpenAiTierMigration } from "./openai-tiers";
11
11
  export interface OpenAiTierStartupDeps {
12
12
  project: typeof projectOpenAiTierMigration;
13
13
  backup: () => void;
14
- save: (config: OcxConfig) => void;
14
+ save?: (config: OcxConfig) => void;
15
15
  preserveRollback?: (error: OpenAiTierBackupCollisionError) => void;
16
16
  }
17
17
 
@@ -34,23 +34,47 @@ function defaultPreserveRollback(error: OpenAiTierBackupCollisionError): void {
34
34
  const DEFAULT_DEPS: OpenAiTierStartupDeps = {
35
35
  project: projectOpenAiTierMigration,
36
36
  backup: backupConfigBeforeOpenAiTierMigration,
37
- save: saveConfig,
38
37
  };
39
38
 
40
39
  export function runOpenAiTierStartupMigration(
41
40
  config: OcxConfig,
42
41
  deps: OpenAiTierStartupDeps = DEFAULT_DEPS,
43
42
  ): OcxConfig {
44
- const projection = deps.project(config);
45
- if (!projection.changed) return projection.config;
46
- try {
47
- deps.backup();
48
- } catch (error) {
49
- if (!(error instanceof OpenAiTierBackupCollisionError)) throw error;
50
- (deps.preserveRollback ?? defaultPreserveRollback)(error);
51
- deps.backup();
43
+ const backup = (): void => {
44
+ try {
45
+ deps.backup();
46
+ } catch (error) {
47
+ if (!(error instanceof OpenAiTierBackupCollisionError)) throw error;
48
+ (deps.preserveRollback ?? defaultPreserveRollback)(error);
49
+ deps.backup();
50
+ }
51
+ };
52
+ if (deps.save) {
53
+ const projection = deps.project(config);
54
+ if (!projection.changed) return projection.config;
55
+ backup();
56
+ deps.save(projection.config);
57
+ for (const warning of projection.warnings) console.warn(`[openai-provider-migration] ${warning}`);
58
+ return projection.config;
59
+ }
60
+
61
+ let previousSnapshot: string | undefined;
62
+ const outcome = mutatePersistedConfig(fresh => {
63
+ const snapshot = JSON.stringify(fresh);
64
+ const next = deps.project(fresh);
65
+ // mutatePersistedConfig confirms a candidate by invoking us again with the same
66
+ // snapshot after rebasing. Back up only that confirmed preimage.
67
+ if (next.changed && snapshot === previousSnapshot) backup();
68
+ previousSnapshot = snapshot;
69
+ if (next.changed) {
70
+ for (const key of Object.keys(fresh)) delete (fresh as unknown as Record<string, unknown>)[key];
71
+ Object.assign(fresh, next.config);
72
+ }
73
+ return { changed: next.changed, value: next };
74
+ });
75
+ if (outcome.status === "unavailable") return config;
76
+ if (outcome.status === "committed") {
77
+ for (const warning of outcome.value.warnings) console.warn(`[openai-provider-migration] ${warning}`);
52
78
  }
53
- deps.save(projection.config);
54
- for (const warning of projection.warnings) console.warn(`[openai-provider-migration] ${warning}`);
55
- return projection.config;
79
+ return outcome.value.config;
56
80
  }
@@ -38,6 +38,11 @@ import {
38
38
  setLiveStateStoreConfig,
39
39
  } from "../lib/state-store-registrations";
40
40
  import { startUserCostOverlayReconciler } from "../usage/user-cost-overlay-reconciler";
41
+ import {
42
+ getStorageCleanupPolicyJobState,
43
+ getStorageCleanupPolicyTestStreamResponse,
44
+ requestStorageCleanupPolicyRun,
45
+ } from "../storage/policy-job";
41
46
  import {
42
47
  configureAppOwnedMemoryBudget,
43
48
  enforceAppOwnedMemoryBudget,
@@ -537,6 +542,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
537
542
  const managementApi: ManagementApiDeps = {
538
543
  saveConfigPreservingClaudeCode,
539
544
  mutatePersistedConfig,
545
+ storageCleanupPolicyJob: {
546
+ getState: getStorageCleanupPolicyJobState,
547
+ getTestStream: getStorageCleanupPolicyTestStreamResponse,
548
+ requestRun: requestStorageCleanupPolicyRun,
549
+ },
540
550
  ...deps.managementApi,
541
551
  };
542
552
  const localAttestationSecret = deps.localAttestationSecret ?? createLocalAttestationSecret();
@@ -1,4 +1,5 @@
1
1
  import type { OcxConfig } from "../../types";
2
+ import type { PersistedConfigMutation, PersistedConfigMutationOutcome } from "../../config";
2
3
  import type { NativeProfileApiDeps } from "../../codex/native-profile-api";
3
4
  import type { CodexLogGuardProtectionDeps } from "../../codex/log-guard/protection";
4
5
  import type { CodexLogGuardMaintenanceDeps } from "../../codex/log-guard/maintenance";
@@ -40,6 +41,12 @@ export interface ManagementApiDeps {
40
41
  saveConfigPreservingClaudeCode?: (config: OcxConfig) => void;
41
42
  /** Config-mutation seam for routes that commit through `mutatePersistedConfig`. */
42
43
  mutatePersistedConfig?: typeof import("../../config").mutatePersistedConfig;
44
+ /** Storage-policy job seam keeps management routes off persistence-bearing worker modules. */
45
+ storageCleanupPolicyJob?: {
46
+ getState: typeof import("../../storage/policy-job").getStorageCleanupPolicyJobState;
47
+ getTestStream: typeof import("../../storage/policy-job").getStorageCleanupPolicyTestStreamResponse;
48
+ requestRun: typeof import("../../storage/policy-job").requestStorageCleanupPolicyRun;
49
+ };
43
50
  /** Test-only fetch injection for Replit gateway install probes. */
44
51
  probeFetch?: typeof globalThis.fetch;
45
52
  /**
@@ -142,11 +149,11 @@ export function saveManagementConfig(deps: ManagementApiDeps, config: OcxConfig)
142
149
  /** The only locked, field-scoped on-disk mutation boundary available to management routes. */
143
150
  export function mutateManagementConfig<T>(
144
151
  deps: ManagementApiDeps,
145
- mutate: Parameters<NonNullable<ManagementApiDeps["mutatePersistedConfig"]>>[0],
146
- ): ReturnType<NonNullable<ManagementApiDeps["mutatePersistedConfig"]>> {
152
+ mutate: (config: OcxConfig) => PersistedConfigMutation<T>,
153
+ ): PersistedConfigMutationOutcome<T> {
147
154
  if (!deps.mutatePersistedConfig) throw new MissingManagementPersistenceError();
148
155
  try {
149
- return deps.mutatePersistedConfig(mutate) as ReturnType<NonNullable<ManagementApiDeps["mutatePersistedConfig"]>>;
156
+ return deps.mutatePersistedConfig(mutate);
150
157
  } catch (error) {
151
158
  throw new ManagementPersistenceError(error);
152
159
  }
@@ -39,13 +39,7 @@ import { getRestoreTrashTestStreamResponse, runRestoreTrashEntryJob } from "../.
39
39
  import {
40
40
  normalizeStorageCleanupPolicy,
41
41
  parseStorageCleanupPolicyInput,
42
- writeStorageCleanupPolicyToConfig,
43
- } from "../../storage/policy";
44
- import {
45
- getStorageCleanupPolicyJobState,
46
- getStorageCleanupPolicyTestStreamResponse,
47
- requestStorageCleanupPolicyRun,
48
- } from "../../storage/policy-job";
42
+ } from "../../storage/policy-input";
49
43
  import {
50
44
  currentUsageLogRevision,
51
45
  readUsageSnapshotForManagement,
@@ -77,7 +71,7 @@ import { applySystemEnvToggle } from "../system-env";
77
71
 
78
72
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
79
73
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
80
- import type { ManagementContext } from "./context";
74
+ import { MissingManagementPersistenceError, mutateManagementConfig, type ManagementContext } from "./context";
81
75
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
82
76
  import {
83
77
  discardUsageSummaryCacheEntry,
@@ -131,6 +125,7 @@ function snapshotWindow(entries: PersistedUsageEntry[]): { start: number | null;
131
125
 
132
126
  export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Response | null> {
133
127
  const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx;
128
+ const storagePolicyJobState = () => deps.storageCleanupPolicyJob?.getState() ?? { status: "idle" as const };
134
129
 
135
130
  if (url.pathname === "/api/logs" && req.method === "GET") {
136
131
  const all = getRequestLogEntries();
@@ -555,7 +550,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
555
550
  }
556
551
 
557
552
  if (url.pathname === "/api/storage/cleanup-policy/test-stream" && req.method === "GET") {
558
- const stream = getStorageCleanupPolicyTestStreamResponse();
553
+ const stream = deps.storageCleanupPolicyJob?.getTestStream();
559
554
  if (stream) return stream;
560
555
  // Production: hook is off. Return an explicit JSON 404 — do not fall through to the GUI.
561
556
  return jsonResponse({ error: "not_found" }, 404);
@@ -565,7 +560,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
565
560
  const policy = normalizeStorageCleanupPolicy(config.storageCleanupPolicy);
566
561
  return jsonResponse({
567
562
  ...policy,
568
- job: getStorageCleanupPolicyJobState(),
563
+ job: storagePolicyJobState(),
569
564
  });
570
565
  }
571
566
 
@@ -575,17 +570,25 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
575
570
  const previous = normalizeStorageCleanupPolicy(config.storageCleanupPolicy);
576
571
  const parsed = parseStorageCleanupPolicyInput(raw, previous);
577
572
  if (!parsed.ok) return jsonResponse({ error: parsed.error }, 400);
578
- // Never enable implicitly: if client omitted enabled, keep previous (default false).
579
573
  const body = raw as Record<string, unknown>;
580
- if (body.enabled === undefined) parsed.policy.enabled = previous.enabled;
581
- const saved = writeStorageCleanupPolicyToConfig(parsed.policy);
582
- config.storageCleanupPolicy = saved;
583
- return jsonResponse({ ok: true, policy: saved, job: getStorageCleanupPolicyJobState() });
574
+ const persisted = mutateManagementConfig(deps, disk => {
575
+ const latest = normalizeStorageCleanupPolicy(disk.storageCleanupPolicy);
576
+ const rebased = parseStorageCleanupPolicyInput(raw, latest);
577
+ if (!rebased.ok) throw new Error(rebased.error);
578
+ // Never enable implicitly: if client omitted enabled, keep the latest persisted value.
579
+ if (body.enabled === undefined) rebased.policy.enabled = latest.enabled;
580
+ disk.storageCleanupPolicy = rebased.policy;
581
+ return { changed: true, value: structuredClone(rebased.policy) };
582
+ });
583
+ if (persisted.status === "unavailable") return jsonResponse({ error: "management persistence unavailable" }, 500);
584
+ config.storageCleanupPolicy = persisted.value;
585
+ return jsonResponse({ ok: true, policy: persisted.value, job: storagePolicyJobState() });
584
586
  }
585
587
 
586
588
  if (url.pathname === "/api/storage/cleanup-policy/run" && req.method === "POST") {
587
589
  try {
588
- const accepted = requestStorageCleanupPolicyRun({ reason: "manual", force: true });
590
+ if (!deps.storageCleanupPolicyJob) throw new MissingManagementPersistenceError();
591
+ const accepted = deps.storageCleanupPolicyJob.requestRun({ reason: "manual", force: true });
589
592
  if (!accepted.accepted) {
590
593
  return jsonResponse({
591
594
  ok: false,