@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.
- package/gui/dist/assets/{ApiKeys-DAuYQsGJ.js → ApiKeys-BLPOvK4z.js} +1 -1
- package/gui/dist/assets/{Claude-bN_80RT8.js → Claude-BbHv3EU7.js} +1 -1
- package/gui/dist/assets/{CodexSet-D3dWX_ay.js → CodexSet-hgu5hES-.js} +1 -1
- package/gui/dist/assets/{FileIntegrationPage-BGyzkPs2.js → FileIntegrationPage-RCmDvvsx.js} +1 -1
- package/gui/dist/assets/{Grok-CuaKd1TT.js → Grok-DH-ggRqA.js} +1 -1
- package/gui/dist/assets/{Integrations-C7NGQoxR.js → Integrations-D6m5E2-T.js} +2 -2
- package/gui/dist/assets/{IntegrationsOverview-DjVorYO5.js → IntegrationsOverview-C1rbnXV7.js} +1 -1
- package/gui/dist/assets/{Logs-B21a-9xe.js → Logs-Bqb0vYRk.js} +1 -1
- package/gui/dist/assets/{Models-CN7Q3kuK.js → Models-gMnzHf0c.js} +1 -1
- package/gui/dist/assets/{NumberStepper-BWg90tEs.js → NumberStepper-awaoZNlj.js} +1 -1
- package/gui/dist/assets/{Providers-CP_i-7LS.js → Providers-Bo6wuQMa.js} +1 -1
- package/gui/dist/assets/{RestoreDialog-3M0NqCiD.js → RestoreDialog-DX7SGWIk.js} +1 -1
- package/gui/dist/assets/{Startup-BVPFoP2s.js → Startup-B1htiLU9.js} +1 -1
- package/gui/dist/assets/{Storage-BdsG9ypz.js → Storage-B3MfosmS.js} +1 -1
- package/gui/dist/assets/{Subagents-CguR_bGQ.js → Subagents-L6Q2V9PZ.js} +1 -1
- package/gui/dist/assets/{Usage-C0kKi9_H.js → Usage-xbZnkDoj.js} +1 -1
- package/gui/dist/assets/{codex-stale-banner-D4gvSWnO.js → codex-stale-banner-W4FhikN7.js} +1 -1
- package/gui/dist/assets/{data-surface-BtO3lwam.js → data-surface-B8PX8rjm.js} +1 -1
- package/gui/dist/assets/{data-surface-BCycbObp.js → data-surface-BbBMp0Wj.js} +1 -1
- package/gui/dist/assets/{index-CU1jE0st.js → index-JEC_6CUW.js} +3 -3
- package/gui/dist/assets/{model-display-D2elXkuE.js → model-display-BvLWy9D5.js} +1 -1
- package/gui/dist/assets/{provider-payload-DfkXWE7v.js → provider-payload-BRfSsonS.js} +1 -1
- package/gui/dist/assets/{section-tabs-BP7gEPK6.js → section-tabs-B8sLMiz6.js} +1 -1
- package/gui/dist/assets/shared-DI18-uF7.js +69 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/base.ts +26 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +34 -41
- package/src/adapters/cursor/envelope-echo.ts +128 -0
- package/src/adapters/cursor/request-builder.ts +19 -12
- package/src/adapters/cursor/tool-definitions.ts +2 -1
- package/src/adapters/cursor/tool-result-normalize.ts +23 -31
- package/src/adapters/cursor.ts +21 -2
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +71 -2
- package/src/adapters/google.ts +19 -4
- package/src/adapters/kiro-constants.ts +12 -0
- package/src/adapters/kiro.ts +128 -11
- package/src/adapters/openai-chat.ts +16 -2
- package/src/adapters/openai-responses.ts +15 -2
- package/src/adapters/tool-catalog-nudge.ts +2 -1
- package/src/adapters/xai-web-search.ts +10 -14
- package/src/claude/outbound.ts +14 -3
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +93 -16
- package/src/cli/account-extended.ts +262 -36
- package/src/cli/account-main.ts +12 -12
- package/src/cli/account.ts +40 -10
- package/src/cli/agent.ts +8 -1
- package/src/cli/capabilities-command.ts +94 -0
- package/src/cli/capabilities.ts +535 -0
- package/src/cli/claude-desktop.ts +31 -11
- package/src/cli/config-command.ts +7 -3
- package/src/cli/dispatch.ts +195 -27
- package/src/cli/doctor.ts +100 -1
- package/src/cli/help.ts +11 -2
- package/src/cli/index.ts +19 -3
- package/src/cli/init.ts +2 -2
- package/src/cli/inspect.ts +230 -0
- package/src/cli/observe.ts +11 -3
- package/src/cli/provider.ts +58 -74
- package/src/cli/registry.ts +34 -2
- package/src/cli/runtime-api.ts +51 -7
- package/src/cli/status.ts +16 -0
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/usage-report.ts +52 -2
- package/src/cli/version-skew.ts +46 -0
- package/src/codex/account-label.ts +21 -0
- package/src/codex/catalog/provider-fetch.ts +4 -0
- package/src/codex/convergence.ts +110 -15
- package/src/codex/transition-state.ts +12 -3
- package/src/compatibility/openai-responses.ts +9 -1
- package/src/config.ts +209 -11
- package/src/generated/compatibility-version.json +115 -75
- package/src/integrations/ownership-policy.ts +24 -5
- package/src/integrations/ownership.ts +36 -2
- package/src/integrations/state.ts +40 -7
- package/src/integrations/writer.ts +21 -3
- package/src/lib/admin-secrets.ts +24 -0
- package/src/lib/errors.ts +25 -1
- package/src/lib/service-secrets.ts +15 -0
- package/src/oauth/index.ts +143 -82
- package/src/oauth/login-cli.ts +53 -11
- package/src/oauth/store.ts +16 -6
- package/src/providers/alibaba-region-startup.ts +31 -11
- package/src/providers/api-keys.ts +58 -34
- package/src/providers/key-failover.ts +59 -46
- package/src/providers/label.ts +34 -1
- package/src/providers/model-rename-startup.ts +25 -7
- package/src/providers/openai-tier-startup.ts +38 -14
- package/src/responses/turn-termination.ts +107 -0
- package/src/server/index.ts +10 -0
- package/src/server/management/context.ts +10 -3
- package/src/server/management/logs-usage-routes.ts +24 -43
- package/src/server/management/model-routes.ts +284 -165
- package/src/server/management/oauth-account-routes.ts +93 -15
- package/src/server/management/provider-routes.ts +200 -87
- package/src/server/management/route-registry.ts +317 -0
- package/src/server/proxy-liveness.ts +27 -4
- package/src/server/request-log.ts +37 -17
- package/src/server/responses/core.ts +80 -4
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/service.ts +34 -0
- package/src/storage/policy-input.ts +166 -0
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +115 -221
- package/src/usage/log.ts +32 -6
- package/src/usage/summary.ts +22 -34
- 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
|
-
|
|
3155
|
-
|
|
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
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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",
|