@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
@@ -43,6 +43,10 @@ import {
43
43
  previousResponseScopeMismatch,
44
44
  rememberResponseState,
45
45
  } from "../../responses/state";
46
+ import {
47
+ bindTurnTerminationScope,
48
+ rememberDeliveredFinalAnswer,
49
+ } from "../../responses/turn-termination";
46
50
  import {
47
51
  isValidProviderContinuationOwner,
48
52
  mergeProviderContinuationPayload,
@@ -122,6 +126,7 @@ import {
122
126
  resolveAnthropicAccountForSession,
123
127
  rotateAnthropicAccountOn429,
124
128
  } from "../../oauth/anthropic-routing";
129
+ import { stampOAuthAccountLabel } from "../../providers/label";
125
130
  import {
126
131
  antigravitySessionKeyFromParts,
127
132
  bindAntigravitySessionAffinity,
@@ -2508,6 +2513,10 @@ async function handleResponsesInner(
2508
2513
  threadIdHeader: req.headers.get("thread-id"),
2509
2514
  cursorConversationId: parsed._cursorConversationId,
2510
2515
  });
2516
+ bindTurnTerminationScope(parsed, resolvedConversationId);
2517
+ const rememberKiroDeliveredFinalAnswer = (adapterName: string, response: unknown): void => {
2518
+ if (adapterName === "kiro") rememberDeliveredFinalAnswer(parsed, response);
2519
+ };
2511
2520
  // _clientThreadId remains the routing/continuation identity supplied by Codex. Replay state uses
2512
2521
  // a dedicated raw conversation namespace so mixed headers that carry the same identity still
2513
2522
  // match, and a shared/synthetic session_id cannot coalesce distinct thread/Cursor conversations.
@@ -3040,9 +3049,12 @@ async function handleResponsesInner(
3040
3049
  accountId: snapshot.accountId,
3041
3050
  generation: snapshot.generation,
3042
3051
  };
3043
- logCtx.accountLogLabel = snapshot.accountId ?? genericFailoverAccountId;
3044
3052
  bindAntigravitySessionAffinity(antigravitySessionKey, snapshot.accountId ?? genericFailoverAccountId);
3045
3053
  }
3054
+ // Re-stamp: a request that rotated accounts must be attributed to the account that actually
3055
+ // served it. All three rotation sites funnel through here, so this is the only re-stamp
3056
+ // needed -- and putting it anywhere else would let one of the three drift.
3057
+ stampOAuthAccountLabel(logCtx, route.providerName, route.provider, snapshot.accountId);
3046
3058
  return true;
3047
3059
  };
3048
3060
  const oauthSessionKeyParts = {
@@ -3151,9 +3163,10 @@ async function handleResponsesInner(
3151
3163
  };
3152
3164
  if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
3153
3165
  route.provider = { ...route.provider, apiKey: resolved.accessToken };
3154
- if (resolved.accountId) {
3155
- logCtx.accountLogLabel = resolved.accountId;
3156
- }
3166
+ // Attribution is independent of failover (#2699): stamped from the resolved snapshot
3167
+ // itself, not from inside the `isGenericFailoverProvider` branch below, so a future
3168
+ // narrowing of that predicate cannot silently switch attribution off.
3169
+ stampOAuthAccountLabel(logCtx, route.providerName, route.provider, resolved.accountId);
3157
3170
  // Remember which account actually served this request so a 429 cools THAT one, not
3158
3171
  // whichever account is active by the time the response comes back (#2568).
3159
3172
  if (isGenericFailoverProvider(route.providerName, route.provider)) {
@@ -4810,6 +4823,7 @@ async function handleResponsesInner(
4810
4823
  ...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
4811
4824
  onCompletedResponse: (response, providerState) => {
4812
4825
  commitReasoningReplayServingRoute();
4826
+ rememberKiroDeliveredFinalAnswer(adapter.name, response);
4813
4827
  rememberResponseState(
4814
4828
  parsed._rawBody,
4815
4829
  response,
@@ -5135,6 +5149,7 @@ async function handleResponsesInner(
5135
5149
  },
5136
5150
  onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
5137
5151
  commitReasoningReplayServingRoute();
5152
+ rememberKiroDeliveredFinalAnswer(adapter.name, response);
5138
5153
  if (!routedCompaction) {
5139
5154
  rememberResponseState(
5140
5155
  parsed._rawBody,
@@ -5204,6 +5219,7 @@ async function handleResponsesInner(
5204
5219
  },
5205
5220
  });
5206
5221
  if (!routedCompaction) {
5222
+ rememberKiroDeliveredFinalAnswer(adapter.name, json);
5207
5223
  rememberResponseState(
5208
5224
  parsed._rawBody,
5209
5225
  json,
@@ -5238,6 +5254,64 @@ async function handleResponsesInner(
5238
5254
  // reuse is safe, and releaseBodyObservation is idempotent per build.
5239
5255
  let initialRequest: AdapterRequest | undefined;
5240
5256
  let inputTokenEstimate: number | undefined;
5257
+ // An adapter may know the turn needs no inference at all — Kiro's replayed history ending in a
5258
+ // delivered final answer. Answer it locally: no build (so no token estimate), no send (so
5259
+ // sendCount stays 0), and crucially no empty-completion guard, which treats an outputless
5260
+ // terminal as a failed turn and re-invokes the identical request. Routing this through the
5261
+ // ordinary event path would therefore reinstate the loop it exists to end.
5262
+ const localTerminal = activeAdapter.localTerminal?.(parsed);
5263
+ if (localTerminal) {
5264
+ logCtx.localTerminalReason = localTerminal.reason;
5265
+ // Mark the physical attempt too, not just the parent row. `finishRequestAttempt` finalizes the
5266
+ // attempt through the same estimated-provider path, so without this the row reads exact while
5267
+ // its own attempt still claims an estimate — the detailed accounting a maintainer actually
5268
+ // reads for a zero-send turn.
5269
+ if (logCtx.activeAttempt) logCtx.activeAttempt.locallyAnswered = true;
5270
+ cleanupUpstreamAbort();
5271
+ upstream.abort();
5272
+ const terminalEvents: AdapterEvent[] = [{
5273
+ type: "done",
5274
+ endTurn: true,
5275
+ usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
5276
+ }];
5277
+ if (parsed.stream) {
5278
+ const localSse = bridgeToResponsesSSE(
5279
+ (async function* () { yield* terminalEvents; })(),
5280
+ parsed._responseModelId ?? parsed.modelId,
5281
+ toolBridgeMaps.toolNsMap,
5282
+ toolBridgeMaps.freeformToolNames,
5283
+ toolBridgeMaps.toolSearchToolNames,
5284
+ undefined,
5285
+ 2_000,
5286
+ {
5287
+ translatorBudget,
5288
+ ...(options.forceEmptyResponseId ? { responseId: "" } : {}),
5289
+ ...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
5290
+ },
5291
+ );
5292
+ // Same lifetime tracking as every other streaming return in this function: the turn
5293
+ // admission lease is released when the body finishes or the client disconnects. Returning
5294
+ // the raw stream would hold a lease for a turn that already has all of its output.
5295
+ const localTurnAc = new AbortController();
5296
+ return new Response(
5297
+ trackStreamLifetime(localSse, localTurnAc, undefined, options.turnAdmissionLease),
5298
+ {
5299
+ headers: {
5300
+ "Content-Type": "text/event-stream",
5301
+ "Cache-Control": "no-cache",
5302
+ "Connection": "keep-alive",
5303
+ "X-Accel-Buffering": "no",
5304
+ },
5305
+ },
5306
+ );
5307
+ }
5308
+ return new Response(
5309
+ JSON.stringify(buildResponseJSON(terminalEvents, parsed._responseModelId ?? parsed.modelId, {
5310
+ translatorBudget,
5311
+ })),
5312
+ { headers: { "Content-Type": "application/json" } },
5313
+ );
5314
+ }
5241
5315
  try {
5242
5316
  assertGoogleOptionsRoute(activeAdapter, route.provider);
5243
5317
  initialRequest = await activeAdapter.buildRequest(parsed, {
@@ -6434,6 +6508,7 @@ async function handleResponsesInner(
6434
6508
  },
6435
6509
  onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
6436
6510
  commitReasoningReplayServingRoute();
6511
+ rememberKiroDeliveredFinalAnswer(activeAdapter.name, response);
6437
6512
  // Compaction turns must NOT enter the continuation cache: _rawBody still holds the full
6438
6513
  // PRE-compaction history, and a later previous_response_id expansion would rehydrate the
6439
6514
  // giant stale chain Codex just replaced.
@@ -6511,6 +6586,7 @@ async function handleResponsesInner(
6511
6586
  });
6512
6587
  // See the streaming branch: compaction turns skip the continuation cache.
6513
6588
  if (!routedCompaction) {
6589
+ rememberKiroDeliveredFinalAnswer(activeAdapter.name, json);
6514
6590
  rememberResponseState(
6515
6591
  parsed._rawBody,
6516
6592
  json,
@@ -89,7 +89,7 @@ function finishFailedPolicyAttempt(logCtx: RequestLogContext, status: number): v
89
89
  const attempt = logCtx.activeAttempt;
90
90
  if (attempt) {
91
91
  const startedAt = logCtx.activeAttemptStartedAt ?? Date.now();
92
- finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage, logCtx.upstreamError);
92
+ finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage);
93
93
  }
94
94
  delete logCtx.activeAttempt;
95
95
  delete logCtx.activeAttemptStartedAt;
package/src/service.ts CHANGED
@@ -19,6 +19,7 @@ import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV, durableBunRuntime } from
19
19
  import type { BunRuntimeSource } from "./lib/bun-runtime";
20
20
  import { isProcessAlive, stopProxy } from "./lib/process-control";
21
21
  import { serviceApiTokenFilePath } from "./lib/service-secrets";
22
+ import { tokenCollidesWithAdmin } from "./lib/admin-secrets";
22
23
  import { PROXY_ENV_KEYS } from "./lib/proxy-env";
23
24
  import { randomUUID } from "node:crypto";
24
25
  import {
@@ -366,8 +367,38 @@ export function serviceRetryCommand(
366
367
  return diag.installed && !diag.conflict ? "ocx service repair" : "ocx service install";
367
368
  }
368
369
 
370
+ /**
371
+ * Refuse a management (admin) token as the data-plane secret.
372
+ *
373
+ * The service exports the contents of the service token file as
374
+ * `OPENCODEX_API_AUTH_TOKEN` before starting the proxy. When that value is the admin
375
+ * token, the server treats the management credential as a data-plane admission secret
376
+ * and fails the ENTIRE management plane closed at boot, so every `/api/*` request
377
+ * returns 503 — even on a loopback install that never needed a data-plane secret.
378
+ * Exporting the admin token in the CLI cannot recover it, because the fence is decided
379
+ * server-side at startup (#2696).
380
+ *
381
+ * Nothing in this codebase puts an admin token in that env var; it arrives from the
382
+ * installing shell. This function is the chokepoint that should refuse it rather than
383
+ * writing a file that produces a broken service. Comparison is the same helper doctor
384
+ * uses: minted `ocx_admin_…` prefix, or byte-equal to configuredAdminToken (env or file).
385
+ */
386
+ export function assertNotAdminToken(token: string, env: NodeJS.ProcessEnv = process.env): void {
387
+ if (!tokenCollidesWithAdmin(token, env)) return;
388
+ throw new Error(
389
+ "OPENCODEX_API_AUTH_TOKEN holds a management (admin) token. The service exports it "
390
+ + "as the data-plane secret, which fences the whole management API closed and makes "
391
+ + "every ocx management command fail with 503. Unset OPENCODEX_API_AUTH_TOKEN, or set "
392
+ + "it to a distinct data-plane key, then rerun the install.",
393
+ );
394
+ }
395
+
369
396
  export function assertServiceAuthEnvironment(): void {
370
397
  const config = loadConfig();
398
+ // Check the collision before the loopback short-circuit: a loopback install writes
399
+ // the token file too, so returning early here is what let the broken state through.
400
+ const present = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
401
+ if (present) assertNotAdminToken(present);
371
402
  if (isLoopbackHostname(config.hostname)) return;
372
403
  if (process.env.OPENCODEX_API_AUTH_TOKEN?.trim()) return;
373
404
  // Reached from `service repair` as well as `install`, so name a command that can
@@ -383,6 +414,9 @@ export function assertServiceAuthEnvironment(): void {
383
414
  function writeServiceApiTokenFile(): string | null {
384
415
  const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
385
416
  if (!token) return null;
417
+ // Last line of defence: every install/repair path funnels through here, so a
418
+ // collision cannot reach disk regardless of which caller ran (#2696).
419
+ assertNotAdminToken(token);
386
420
  const path = serviceApiTokenFilePath();
387
421
  const dir = getConfigDir();
388
422
  recordOwnedConfigPath(dir, path);
@@ -0,0 +1,166 @@
1
+ import type { StorageCleanupPolicy } from "../types";
2
+
3
+ export const DEFAULT_ARCHIVED_BYTES_OVER = 5 * 1024 ** 3;
4
+ export const DEFAULT_REMOVE_OLDEST_PERCENT = 25;
5
+
6
+ export type PolicySchedule = StorageCleanupPolicy["schedule"];
7
+
8
+ /** Canonical defaults — enabled is always false. */
9
+ export function defaultStorageCleanupPolicy(): StorageCleanupPolicy {
10
+ return {
11
+ enabled: false,
12
+ trigger: { archivedBytesOver: DEFAULT_ARCHIVED_BYTES_OVER },
13
+ target: { removeOldestPercent: DEFAULT_REMOVE_OLDEST_PERCENT },
14
+ schedule: "manual",
15
+ mode: "quarantine",
16
+ };
17
+ }
18
+
19
+ function isFiniteNonNegInt(n: unknown): n is number {
20
+ return typeof n === "number" && Number.isFinite(n) && n >= 0 && Math.floor(n) === n;
21
+ }
22
+
23
+ function isFinitePositiveInt(n: unknown): n is number {
24
+ return typeof n === "number" && Number.isFinite(n) && n > 0 && Math.floor(n) === n;
25
+ }
26
+
27
+ /** True when target has exactly one of reduceToBytes / removeOldestPercent. */
28
+ export function isValidPolicyTarget(target: StorageCleanupPolicy["target"]): boolean {
29
+ if (!target || typeof target !== "object") return false;
30
+ const reduce = (target as { reduceToBytes?: unknown }).reduceToBytes;
31
+ const percent = (target as { removeOldestPercent?: unknown }).removeOldestPercent;
32
+ const hasReduce = reduce !== undefined;
33
+ const hasPercent = percent !== undefined;
34
+ if (hasReduce === hasPercent) return false;
35
+ if (hasReduce) return isFiniteNonNegInt(reduce);
36
+ return typeof percent === "number" && Number.isFinite(percent) && percent > 0 && percent <= 100;
37
+ }
38
+
39
+ /** Normalize persisted or partial policy input without ever enabling implicitly. */
40
+ export function normalizeStorageCleanupPolicy(raw: unknown): StorageCleanupPolicy {
41
+ const base = defaultStorageCleanupPolicy();
42
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return base;
43
+ const o = raw as Record<string, unknown>;
44
+ let enabled = o.enabled === true;
45
+
46
+ let archivedBytesOver = base.trigger.archivedBytesOver;
47
+ if (o.trigger && typeof o.trigger === "object" && !Array.isArray(o.trigger)) {
48
+ const value = (o.trigger as { archivedBytesOver?: unknown }).archivedBytesOver;
49
+ if (isFiniteNonNegInt(value)) archivedBytesOver = value;
50
+ }
51
+
52
+ let target: StorageCleanupPolicy["target"] = base.target;
53
+ if (Object.hasOwn(o, "target")) {
54
+ if (o.target && typeof o.target === "object" && !Array.isArray(o.target)) {
55
+ const candidate = o.target as StorageCleanupPolicy["target"];
56
+ if (isValidPolicyTarget(candidate)) {
57
+ const reduce = (candidate as { reduceToBytes?: number }).reduceToBytes;
58
+ const percent = (candidate as { removeOldestPercent?: number }).removeOldestPercent;
59
+ target = reduce !== undefined
60
+ ? { reduceToBytes: reduce }
61
+ : { removeOldestPercent: Math.min(100, Math.max(1, Math.floor(percent!))) };
62
+ } else {
63
+ enabled = false;
64
+ }
65
+ } else {
66
+ enabled = false;
67
+ }
68
+ }
69
+
70
+ const schedule: PolicySchedule =
71
+ o.schedule === "startup" || o.schedule === "daily" || o.schedule === "weekly" || o.schedule === "manual"
72
+ ? o.schedule
73
+ : base.schedule;
74
+ const mode = o.mode === "permanent" ? "permanent" : "quarantine";
75
+
76
+ let lastRun: StorageCleanupPolicy["lastRun"];
77
+ if (o.lastRun && typeof o.lastRun === "object" && !Array.isArray(o.lastRun)) {
78
+ const row = o.lastRun as Record<string, unknown>;
79
+ if (isFinitePositiveInt(row.at) && isFiniteNonNegInt(row.freedBytes) && isFiniteNonNegInt(row.removed)) {
80
+ lastRun = { at: row.at, freedBytes: row.freedBytes, removed: row.removed };
81
+ }
82
+ }
83
+
84
+ const nextRun = o.nextRun === undefined || o.nextRun === null
85
+ ? undefined
86
+ : isFinitePositiveInt(o.nextRun) ? o.nextRun : undefined;
87
+
88
+ return {
89
+ enabled,
90
+ trigger: { archivedBytesOver },
91
+ target,
92
+ schedule,
93
+ mode,
94
+ ...(lastRun ? { lastRun } : {}),
95
+ ...(nextRun !== undefined ? { nextRun } : {}),
96
+ };
97
+ }
98
+
99
+ /** Validate a management PUT body and merge it over the latest policy. */
100
+ export function parseStorageCleanupPolicyInput(
101
+ raw: unknown,
102
+ previous?: StorageCleanupPolicy,
103
+ ): { ok: true; policy: StorageCleanupPolicy } | { ok: false; error: string } {
104
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
105
+ return { ok: false, error: "body must be a JSON object" };
106
+ }
107
+ const o = raw as Record<string, unknown>;
108
+ const prev = previous ?? defaultStorageCleanupPolicy();
109
+
110
+ if (o.enabled !== undefined && typeof o.enabled !== "boolean") {
111
+ return { ok: false, error: "enabled must be a boolean" };
112
+ }
113
+ if (o.mode !== undefined && o.mode !== "quarantine" && o.mode !== "permanent") {
114
+ return { ok: false, error: "mode must be quarantine or permanent" };
115
+ }
116
+ if (o.schedule !== undefined
117
+ && o.schedule !== "startup"
118
+ && o.schedule !== "daily"
119
+ && o.schedule !== "weekly"
120
+ && o.schedule !== "manual") {
121
+ return { ok: false, error: "schedule must be startup, daily, weekly, or manual" };
122
+ }
123
+
124
+ const merged: Record<string, unknown> = {
125
+ ...prev,
126
+ ...o,
127
+ trigger: o.trigger !== undefined ? o.trigger : prev.trigger,
128
+ target: o.target !== undefined ? o.target : prev.target,
129
+ lastRun: o.lastRun !== undefined ? o.lastRun : prev.lastRun,
130
+ nextRun: o.nextRun !== undefined ? o.nextRun : prev.nextRun,
131
+ };
132
+
133
+ if (o.trigger !== undefined) {
134
+ if (!o.trigger || typeof o.trigger !== "object" || Array.isArray(o.trigger)) {
135
+ return { ok: false, error: "trigger must be an object" };
136
+ }
137
+ const bytes = (o.trigger as { archivedBytesOver?: unknown }).archivedBytesOver;
138
+ if (!isFiniteNonNegInt(bytes)) {
139
+ return { ok: false, error: "trigger.archivedBytesOver must be a non-negative integer" };
140
+ }
141
+ }
142
+ if (o.target !== undefined && !isValidPolicyTarget(o.target as StorageCleanupPolicy["target"])) {
143
+ return {
144
+ ok: false,
145
+ error: "target must set exactly one of reduceToBytes (non-negative int) or removeOldestPercent (1-100)",
146
+ };
147
+ }
148
+
149
+ const policy = normalizeStorageCleanupPolicy(merged);
150
+ if ((policy.schedule === "daily" || policy.schedule === "weekly")
151
+ && o.nextRun === undefined
152
+ && (policy.nextRun === undefined || (o.schedule !== undefined && o.schedule !== prev.schedule))) {
153
+ policy.nextRun = computeNextRun(policy.schedule, Date.now());
154
+ }
155
+ if ((policy.schedule === "manual" || policy.schedule === "startup") && o.nextRun === undefined) {
156
+ delete policy.nextRun;
157
+ }
158
+ return { ok: true, policy };
159
+ }
160
+
161
+ /** Wall-clock next run for daily/weekly. Startup/manual → undefined. */
162
+ export function computeNextRun(schedule: PolicySchedule, now: number): number | undefined {
163
+ if (schedule === "daily") return now + 24 * 60 * 60 * 1000;
164
+ if (schedule === "weekly") return now + 7 * 24 * 60 * 60 * 1000;
165
+ return undefined;
166
+ }
@@ -38,6 +38,7 @@ export interface PolicyJobOutcome {
38
38
  freedBytes?: number;
39
39
  removed?: number;
40
40
  trashDir?: string;
41
+ metadataPersistenceError?: PolicyRunResult["metadataPersistenceError"];
41
42
  }
42
43
 
43
44
  export interface PolicyJobState {
@@ -246,6 +247,7 @@ export async function abortStorageCleanupPolicyJobAsync(): Promise<void> {
246
247
  }
247
248
  }
248
249
 
250
+ /** Project a run result into the bounded management-API job outcome. */
249
251
  function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome {
250
252
  return {
251
253
  ok: result.ok,
@@ -256,18 +258,26 @@ function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome {
256
258
  ...(result.freedBytes !== undefined ? { freedBytes: result.freedBytes } : {}),
257
259
  ...(result.removed !== undefined ? { removed: result.removed } : {}),
258
260
  ...(result.trashDir ? { trashDir: result.trashDir } : {}),
261
+ ...(result.metadataPersistenceError
262
+ ? { metadataPersistenceError: result.metadataPersistenceError }
263
+ : {}),
259
264
  };
260
265
  }
261
266
 
267
+ /** Publish one completed evaluation without losing successful cleanup effects. */
262
268
  function applyFinished(result: PolicyRunResult): void {
263
269
  // Prefer the latest persisted policy over `result.policy`. The worker (or
264
270
  // in-process run) already merged run metadata into disk; a concurrent PUT
265
271
  // may also have landed after that write. Re-reading avoids applying a stale
266
272
  // start-of-job snapshot when the run skipped without saving.
267
- try {
268
- livePolicyApply?.(readStorageCleanupPolicyFromConfig());
269
- } catch {
270
- livePolicyApply?.(result.policy);
273
+ // A best-effort fallback policy may predate concurrent edits; keep the current
274
+ // live config untouched when the durable metadata write did not land.
275
+ if (!result.metadataPersistenceError) {
276
+ try {
277
+ livePolicyApply?.(readStorageCleanupPolicyFromConfig());
278
+ } catch {
279
+ livePolicyApply?.(result.policy);
280
+ }
271
281
  }
272
282
  state = {
273
283
  status: "idle",