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

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 (112) hide show
  1. package/gui/dist/assets/{ApiKeys-DAuYQsGJ.js → ApiKeys-BLPOvK4z.js} +1 -1
  2. package/gui/dist/assets/{Claude-bN_80RT8.js → Claude-BbHv3EU7.js} +1 -1
  3. package/gui/dist/assets/{CodexSet-D3dWX_ay.js → CodexSet-hgu5hES-.js} +1 -1
  4. package/gui/dist/assets/{FileIntegrationPage-BGyzkPs2.js → FileIntegrationPage-RCmDvvsx.js} +1 -1
  5. package/gui/dist/assets/{Grok-CuaKd1TT.js → Grok-DH-ggRqA.js} +1 -1
  6. package/gui/dist/assets/{Integrations-C7NGQoxR.js → Integrations-D6m5E2-T.js} +2 -2
  7. package/gui/dist/assets/{IntegrationsOverview-DjVorYO5.js → IntegrationsOverview-C1rbnXV7.js} +1 -1
  8. package/gui/dist/assets/{Logs-B21a-9xe.js → Logs-Bqb0vYRk.js} +1 -1
  9. package/gui/dist/assets/{Models-CN7Q3kuK.js → Models-gMnzHf0c.js} +1 -1
  10. package/gui/dist/assets/{NumberStepper-BWg90tEs.js → NumberStepper-awaoZNlj.js} +1 -1
  11. package/gui/dist/assets/{Providers-CP_i-7LS.js → Providers-Bo6wuQMa.js} +1 -1
  12. package/gui/dist/assets/{RestoreDialog-3M0NqCiD.js → RestoreDialog-DX7SGWIk.js} +1 -1
  13. package/gui/dist/assets/{Startup-BVPFoP2s.js → Startup-B1htiLU9.js} +1 -1
  14. package/gui/dist/assets/{Storage-BdsG9ypz.js → Storage-B3MfosmS.js} +1 -1
  15. package/gui/dist/assets/{Subagents-CguR_bGQ.js → Subagents-L6Q2V9PZ.js} +1 -1
  16. package/gui/dist/assets/{Usage-C0kKi9_H.js → Usage-xbZnkDoj.js} +1 -1
  17. package/gui/dist/assets/{codex-stale-banner-D4gvSWnO.js → codex-stale-banner-W4FhikN7.js} +1 -1
  18. package/gui/dist/assets/{data-surface-BtO3lwam.js → data-surface-B8PX8rjm.js} +1 -1
  19. package/gui/dist/assets/{data-surface-BCycbObp.js → data-surface-BbBMp0Wj.js} +1 -1
  20. package/gui/dist/assets/{index-CU1jE0st.js → index-JEC_6CUW.js} +3 -3
  21. package/gui/dist/assets/{model-display-D2elXkuE.js → model-display-BvLWy9D5.js} +1 -1
  22. package/gui/dist/assets/{provider-payload-DfkXWE7v.js → provider-payload-BRfSsonS.js} +1 -1
  23. package/gui/dist/assets/{section-tabs-BP7gEPK6.js → section-tabs-B8sLMiz6.js} +1 -1
  24. package/gui/dist/assets/shared-DI18-uF7.js +69 -0
  25. package/gui/dist/index.html +2 -2
  26. package/package.json +3 -1
  27. package/src/adapters/base.ts +26 -0
  28. package/src/adapters/cursor/catalog.ts +541 -0
  29. package/src/adapters/cursor/cursor-errors.ts +15 -0
  30. package/src/adapters/cursor/discovery.ts +34 -41
  31. package/src/adapters/cursor/envelope-echo.ts +128 -0
  32. package/src/adapters/cursor/request-builder.ts +19 -12
  33. package/src/adapters/cursor/tool-definitions.ts +2 -1
  34. package/src/adapters/cursor/tool-result-normalize.ts +23 -31
  35. package/src/adapters/cursor.ts +21 -2
  36. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  37. package/src/adapters/google-antigravity-replay.ts +71 -2
  38. package/src/adapters/google.ts +19 -4
  39. package/src/adapters/kiro-constants.ts +12 -0
  40. package/src/adapters/kiro.ts +128 -11
  41. package/src/adapters/openai-chat.ts +16 -2
  42. package/src/adapters/openai-responses.ts +15 -2
  43. package/src/adapters/tool-catalog-nudge.ts +2 -1
  44. package/src/adapters/xai-web-search.ts +10 -14
  45. package/src/claude/outbound.ts +14 -3
  46. package/src/cli/access.ts +46 -3
  47. package/src/cli/account-api.ts +93 -16
  48. package/src/cli/account-extended.ts +262 -36
  49. package/src/cli/account-main.ts +12 -12
  50. package/src/cli/account.ts +40 -10
  51. package/src/cli/agent.ts +8 -1
  52. package/src/cli/capabilities-command.ts +94 -0
  53. package/src/cli/capabilities.ts +535 -0
  54. package/src/cli/claude-desktop.ts +31 -11
  55. package/src/cli/config-command.ts +7 -3
  56. package/src/cli/dispatch.ts +195 -27
  57. package/src/cli/doctor.ts +100 -1
  58. package/src/cli/help.ts +11 -2
  59. package/src/cli/index.ts +19 -3
  60. package/src/cli/init.ts +2 -2
  61. package/src/cli/inspect.ts +230 -0
  62. package/src/cli/observe.ts +11 -3
  63. package/src/cli/provider.ts +58 -74
  64. package/src/cli/registry.ts +34 -2
  65. package/src/cli/runtime-api.ts +51 -7
  66. package/src/cli/status.ts +16 -0
  67. package/src/cli/storage.ts +234 -0
  68. package/src/cli/system-command.ts +16 -0
  69. package/src/cli/usage-report.ts +52 -2
  70. package/src/cli/version-skew.ts +46 -0
  71. package/src/codex/account-label.ts +21 -0
  72. package/src/codex/catalog/provider-fetch.ts +4 -0
  73. package/src/codex/convergence.ts +110 -15
  74. package/src/codex/transition-state.ts +12 -3
  75. package/src/compatibility/openai-responses.ts +9 -1
  76. package/src/config.ts +209 -11
  77. package/src/generated/compatibility-version.json +115 -75
  78. package/src/integrations/ownership-policy.ts +24 -5
  79. package/src/integrations/ownership.ts +36 -2
  80. package/src/integrations/state.ts +40 -7
  81. package/src/integrations/writer.ts +21 -3
  82. package/src/lib/admin-secrets.ts +24 -0
  83. package/src/lib/errors.ts +25 -1
  84. package/src/lib/service-secrets.ts +15 -0
  85. package/src/oauth/index.ts +143 -82
  86. package/src/oauth/login-cli.ts +53 -11
  87. package/src/oauth/store.ts +16 -6
  88. package/src/providers/alibaba-region-startup.ts +31 -11
  89. package/src/providers/api-keys.ts +58 -34
  90. package/src/providers/key-failover.ts +59 -46
  91. package/src/providers/label.ts +34 -1
  92. package/src/providers/model-rename-startup.ts +25 -7
  93. package/src/providers/openai-tier-startup.ts +38 -14
  94. package/src/responses/turn-termination.ts +107 -0
  95. package/src/server/index.ts +10 -0
  96. package/src/server/management/context.ts +10 -3
  97. package/src/server/management/logs-usage-routes.ts +24 -43
  98. package/src/server/management/model-routes.ts +284 -165
  99. package/src/server/management/oauth-account-routes.ts +93 -15
  100. package/src/server/management/provider-routes.ts +200 -87
  101. package/src/server/management/route-registry.ts +317 -0
  102. package/src/server/proxy-liveness.ts +27 -4
  103. package/src/server/request-log.ts +37 -17
  104. package/src/server/responses/core.ts +80 -4
  105. package/src/server/responses/policy-fallback.ts +1 -1
  106. package/src/service.ts +34 -0
  107. package/src/storage/policy-input.ts +166 -0
  108. package/src/storage/policy-job.ts +14 -4
  109. package/src/storage/policy.ts +115 -221
  110. package/src/usage/log.ts +32 -6
  111. package/src/usage/summary.ts +22 -34
  112. package/gui/dist/assets/shared-BdYGV-yJ.js +0 -69
@@ -0,0 +1,107 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { OcxAssistantMessage, OcxMessage, OcxParsedRequest } from "../types";
3
+
4
+ const DELIVERED_FINAL_ANSWER_TTL_MS = 60 * 60 * 1_000;
5
+ const DELIVERED_FINAL_ANSWER_MAX_ENTRIES = 1_024;
6
+
7
+ interface DeliveredFinalAnswerRecord {
8
+ fingerprint: string;
9
+ createdAt: number;
10
+ }
11
+
12
+ const scopesByRequest = new WeakMap<OcxParsedRequest, string>();
13
+ const deliveredFinalAnswers = new Map<string, DeliveredFinalAnswerRecord>();
14
+
15
+ function pruneDeliveredFinalAnswers(at = Date.now()): void {
16
+ for (const [scope, record] of deliveredFinalAnswers) {
17
+ if (at - record.createdAt > DELIVERED_FINAL_ANSWER_TTL_MS) deliveredFinalAnswers.delete(scope);
18
+ }
19
+ while (deliveredFinalAnswers.size > DELIVERED_FINAL_ANSWER_MAX_ENTRIES) {
20
+ const oldest = deliveredFinalAnswers.keys().next().value;
21
+ if (oldest === undefined) break;
22
+ deliveredFinalAnswers.delete(oldest);
23
+ }
24
+ }
25
+
26
+ function textFingerprint(text: string): string {
27
+ return createHash("sha256").update(text, "utf8").digest("hex");
28
+ }
29
+
30
+ function assistantText(message: OcxAssistantMessage): string | undefined {
31
+ if (message.content.some(part => part.type === "toolCall")) return undefined;
32
+ const text = message.content
33
+ .filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
34
+ .map(part => part.text)
35
+ .join("");
36
+ return text.trim().length > 0 ? text : undefined;
37
+ }
38
+
39
+ function deliveredFinalAnswerText(response: unknown): string | undefined {
40
+ if (!response || typeof response !== "object" || Array.isArray(response)) return undefined;
41
+ const output = (response as { output?: unknown }).output;
42
+ if (!Array.isArray(output)) return undefined;
43
+ for (let i = output.length - 1; i >= 0; i--) {
44
+ const item = output[i];
45
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
46
+ const message = item as { type?: unknown; role?: unknown; phase?: unknown; content?: unknown };
47
+ if (message.type !== "message" || message.role !== "assistant" || message.phase !== "final_answer") continue;
48
+ if (!Array.isArray(message.content)) return undefined;
49
+ const text = message.content
50
+ .filter(part => !!part && typeof part === "object" && !Array.isArray(part)
51
+ && (part as { type?: unknown }).type === "output_text"
52
+ && typeof (part as { text?: unknown }).text === "string")
53
+ .map(part => (part as { text: string }).text)
54
+ .join("");
55
+ return text.trim().length > 0 ? text : undefined;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /** Bind the normalized per-conversation digest without adding proxy-private fields to the wire body. */
61
+ export function bindTurnTerminationScope(parsed: OcxParsedRequest, scope: string | undefined): void {
62
+ // Only the normalized log-conversation digest may key this process-wide map. Refusing any raw
63
+ // fallback prevents a future caller from retaining a client header or account identifier here.
64
+ if (!scope || !/^[0-9a-f]{32}$/.test(scope)) return;
65
+ scopesByRequest.set(parsed, scope);
66
+ }
67
+
68
+ /** Remember only a final-answer message the proxy actually emitted for this exact conversation. */
69
+ export function rememberDeliveredFinalAnswer(parsed: OcxParsedRequest, response: unknown): void {
70
+ const scope = scopesByRequest.get(parsed);
71
+ if (!scope) return;
72
+ const text = deliveredFinalAnswerText(response);
73
+ if (!text) return;
74
+ const at = Date.now();
75
+ pruneDeliveredFinalAnswers(at);
76
+ // Refresh insertion order so cap eviction removes the least recently delivered conversation.
77
+ deliveredFinalAnswers.delete(scope);
78
+ deliveredFinalAnswers.set(scope, { fingerprint: textFingerprint(text), createdAt: at });
79
+ pruneDeliveredFinalAnswers(at);
80
+ }
81
+
82
+ /**
83
+ * Match only when the delivered assistant answer is still the trailing content-bearing message.
84
+ * A later user/tool-result message is new work and must reach the provider, even though every
85
+ * legitimate next turn necessarily contains such a message somewhere in its history.
86
+ */
87
+ export function hasRecordedTrailingDeliveredFinalAnswer(
88
+ parsed: OcxParsedRequest,
89
+ messages: readonly OcxMessage[],
90
+ ): boolean {
91
+ const scope = scopesByRequest.get(parsed);
92
+ if (!scope) return false;
93
+ pruneDeliveredFinalAnswers();
94
+ const record = deliveredFinalAnswers.get(scope);
95
+ if (!record) return false;
96
+ for (let i = messages.length - 1; i >= 0; i--) {
97
+ const message = messages[i];
98
+ if (message.role !== "assistant") return false;
99
+ const text = assistantText(message as OcxAssistantMessage);
100
+ if (text === undefined) {
101
+ if ((message as OcxAssistantMessage).content.some(part => part.type === "toolCall")) return false;
102
+ continue;
103
+ }
104
+ return textFingerprint(text) === record.fingerprint;
105
+ }
106
+ return false;
107
+ }
@@ -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
  }
@@ -20,7 +20,7 @@ import {
20
20
  submitManualLoginCode,
21
21
  upsertOAuthProvider,
22
22
  } from "../../oauth";
23
- import { getAccountSet, removeCredential } from "../../oauth/store";
23
+ import { removeCredential } from "../../oauth/store";
24
24
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
25
25
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
26
26
  import { deriveProviderPresets } from "../../providers/derive";
@@ -31,21 +31,13 @@ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
31
31
  import { clearThreadAccountMap } from "../../codex/routing";
32
32
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
33
33
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
34
- import { resolveCodexHomeDir } from "../../codex/home";
35
- import { scanStorage } from "../../storage/scanner";
36
34
  import { executeArchivedCleanup, listTrashEntries, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode, type RestoreErrorCode } from "../../storage/cleanup";
37
35
  import { runArchivedCleanupJob } from "../../storage/cleanup-job";
38
36
  import { getRestoreTrashTestStreamResponse, runRestoreTrashEntryJob } from "../../storage/restore-job";
39
37
  import {
40
38
  normalizeStorageCleanupPolicy,
41
39
  parseStorageCleanupPolicyInput,
42
- writeStorageCleanupPolicyToConfig,
43
- } from "../../storage/policy";
44
- import {
45
- getStorageCleanupPolicyJobState,
46
- getStorageCleanupPolicyTestStreamResponse,
47
- requestStorageCleanupPolicyRun,
48
- } from "../../storage/policy-job";
40
+ } from "../../storage/policy-input";
49
41
  import {
50
42
  currentUsageLogRevision,
51
43
  readUsageSnapshotForManagement,
@@ -77,7 +69,7 @@ import { applySystemEnvToggle } from "../system-env";
77
69
 
78
70
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
79
71
  import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
80
- import type { ManagementContext } from "./context";
72
+ import { MissingManagementPersistenceError, mutateManagementConfig, type ManagementContext } from "./context";
81
73
  import { readManagementJsonBody, rethrowManagementBodyTooLarge } from "./body";
82
74
  import {
83
75
  discardUsageSummaryCacheEntry,
@@ -131,6 +123,7 @@ function snapshotWindow(entries: PersistedUsageEntry[]): { start: number | null;
131
123
 
132
124
  export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Response | null> {
133
125
  const { req, url, config, deps, syncClaudeAgentDefsBestEffort } = ctx;
126
+ const storagePolicyJobState = () => deps.storageCleanupPolicyJob?.getState() ?? { status: "idle" as const };
134
127
 
135
128
  if (url.pathname === "/api/logs" && req.method === "GET") {
136
129
  const all = getRequestLogEntries();
@@ -203,16 +196,10 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
203
196
  const filter = {
204
197
  provider: url.searchParams.get("provider"),
205
198
  model: url.searchParams.get("model"),
206
- account: url.searchParams.get("account"),
207
199
  };
208
- const fallbackAccounts: Record<string, string> = {};
209
- for (const p of listOAuthProviders()) {
210
- const set = getAccountSet(p);
211
- if (set && set.accounts.length === 1 && set.activeAccountId) fallbackAccounts[p] = set.activeAccountId;
212
- }
213
200
  const project = <T extends UsageSummary>(summary: T, entries?: PersistedUsageEntry[]) =>
214
- projectUsageSummary(summary, filter, entries, fallbackAccounts);
215
- const filterRequested = Boolean(filter.provider ?? filter.model ?? filter.account);
201
+ projectUsageSummary(summary, filter, entries);
202
+ const filterRequested = Boolean(filter.provider ?? filter.model);
216
203
  const now = Date.now();
217
204
  try {
218
205
  const cacheKey = `${range}:${surface}`;
@@ -247,7 +234,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
247
234
  const revisionReadAt = Date.now();
248
235
  const window = snapshotWindow(snapshot.entries);
249
236
  const summary = {
250
- ...summarizeUsage(snapshot.entries, range, now, surface, fallbackAccounts),
237
+ ...summarizeUsage(snapshot.entries, range, now, surface),
251
238
  historyTruncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated,
252
239
  truncatedPrefixBytes: snapshot.truncatedPrefixBytes,
253
240
  entriesTruncated: snapshot.entriesTruncated,
@@ -275,7 +262,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
275
262
  for (const nextRange of ranges) {
276
263
  for (const nextSurface of surfaces) {
277
264
  const nextSummary = nextRange === range && nextSurface === surface ? summary : {
278
- ...summarizeUsage(snapshot.entries, nextRange, now, nextSurface, fallbackAccounts),
265
+ ...summarizeUsage(snapshot.entries, nextRange, now, nextSurface),
279
266
  historyTruncated: summary.historyTruncated,
280
267
  truncatedPrefixBytes: summary.truncatedPrefixBytes,
281
268
  entriesTruncated: summary.entriesTruncated,
@@ -348,20 +335,6 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
348
335
  }
349
336
  }
350
337
 
351
- if (url.pathname === "/api/storage" && req.method === "GET") {
352
- try {
353
- return jsonResponse(scanStorage());
354
- } catch {
355
- return jsonResponse({
356
- codexHome: resolveCodexHomeDir(),
357
- generatedAt: Date.now(),
358
- total: { bytes: 0, fileCount: 0 },
359
- buckets: [],
360
- error: "scan_failed",
361
- });
362
- }
363
- }
364
-
365
338
  if (url.pathname === "/api/storage/cleanup/preview" && req.method === "POST") {
366
339
  let body: { percent?: unknown };
367
340
  try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400); }
@@ -555,7 +528,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
555
528
  }
556
529
 
557
530
  if (url.pathname === "/api/storage/cleanup-policy/test-stream" && req.method === "GET") {
558
- const stream = getStorageCleanupPolicyTestStreamResponse();
531
+ const stream = deps.storageCleanupPolicyJob?.getTestStream();
559
532
  if (stream) return stream;
560
533
  // Production: hook is off. Return an explicit JSON 404 — do not fall through to the GUI.
561
534
  return jsonResponse({ error: "not_found" }, 404);
@@ -565,7 +538,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
565
538
  const policy = normalizeStorageCleanupPolicy(config.storageCleanupPolicy);
566
539
  return jsonResponse({
567
540
  ...policy,
568
- job: getStorageCleanupPolicyJobState(),
541
+ job: storagePolicyJobState(),
569
542
  });
570
543
  }
571
544
 
@@ -575,17 +548,25 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
575
548
  const previous = normalizeStorageCleanupPolicy(config.storageCleanupPolicy);
576
549
  const parsed = parseStorageCleanupPolicyInput(raw, previous);
577
550
  if (!parsed.ok) return jsonResponse({ error: parsed.error }, 400);
578
- // Never enable implicitly: if client omitted enabled, keep previous (default false).
579
551
  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() });
552
+ const persisted = mutateManagementConfig(deps, disk => {
553
+ const latest = normalizeStorageCleanupPolicy(disk.storageCleanupPolicy);
554
+ const rebased = parseStorageCleanupPolicyInput(raw, latest);
555
+ if (!rebased.ok) throw new Error(rebased.error);
556
+ // Never enable implicitly: if client omitted enabled, keep the latest persisted value.
557
+ if (body.enabled === undefined) rebased.policy.enabled = latest.enabled;
558
+ disk.storageCleanupPolicy = rebased.policy;
559
+ return { changed: true, value: structuredClone(rebased.policy) };
560
+ });
561
+ if (persisted.status === "unavailable") return jsonResponse({ error: "management persistence unavailable" }, 500);
562
+ config.storageCleanupPolicy = persisted.value;
563
+ return jsonResponse({ ok: true, policy: persisted.value, job: storagePolicyJobState() });
584
564
  }
585
565
 
586
566
  if (url.pathname === "/api/storage/cleanup-policy/run" && req.method === "POST") {
587
567
  try {
588
- const accepted = requestStorageCleanupPolicyRun({ reason: "manual", force: true });
568
+ if (!deps.storageCleanupPolicyJob) throw new MissingManagementPersistenceError();
569
+ const accepted = deps.storageCleanupPolicyJob.requestRun({ reason: "manual", force: true });
589
570
  if (!accepted.accepted) {
590
571
  return jsonResponse({
591
572
  ok: false,