@bitkyc08/opencodex 2.34.0 → 2.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{index-C4TMRloX.js → index-DNdRKXK9.js} +11 -11
- package/gui/dist/index.html +1 -1
- 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-antigravity-wire.ts +5 -0
- package/src/adapters/google.ts +15 -1
- 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/run-turn-queue.ts +36 -1
- 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 +84 -15
- package/src/cli/account-extended.ts +261 -28
- 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 +496 -0
- package/src/cli/claude-desktop.ts +31 -11
- 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/inspect.ts +230 -0
- package/src/cli/observe.ts +11 -3
- 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/transition-state.ts +12 -3
- package/src/compatibility/openai-responses.ts +9 -1
- package/src/generated/compatibility-version.json +95 -59
- 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/store.ts +14 -5
- package/src/providers/label.ts +34 -1
- package/src/responses/turn-termination.ts +107 -0
- package/src/server/management/logs-usage-routes.ts +0 -16
- package/src/server/management/route-registry.ts +311 -0
- package/src/server/proxy-liveness.ts +27 -4
- package/src/server/request-log.ts +29 -1
- package/src/server/responses/core.ts +80 -0
- package/src/service.ts +34 -0
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +88 -23
- package/src/usage/log.ts +44 -4
- package/src/usage/summary.ts +10 -0
|
@@ -40,6 +40,10 @@ import {
|
|
|
40
40
|
previousResponseScopeMismatch,
|
|
41
41
|
rememberResponseState,
|
|
42
42
|
} from "../../responses/state";
|
|
43
|
+
import {
|
|
44
|
+
bindTurnTerminationScope,
|
|
45
|
+
rememberDeliveredFinalAnswer,
|
|
46
|
+
} from "../../responses/turn-termination";
|
|
43
47
|
import {
|
|
44
48
|
isValidProviderContinuationOwner,
|
|
45
49
|
mergeProviderContinuationPayload,
|
|
@@ -115,6 +119,7 @@ import {
|
|
|
115
119
|
resolveAnthropicAccountForSession,
|
|
116
120
|
rotateAnthropicAccountOn429,
|
|
117
121
|
} from "../../oauth/anthropic-routing";
|
|
122
|
+
import { stampOAuthAccountLabel } from "../../providers/label";
|
|
118
123
|
import {
|
|
119
124
|
failoverAccountSnapshot,
|
|
120
125
|
GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST,
|
|
@@ -2380,6 +2385,10 @@ async function handleResponsesInner(
|
|
|
2380
2385
|
threadIdHeader: req.headers.get("thread-id"),
|
|
2381
2386
|
cursorConversationId: parsed._cursorConversationId,
|
|
2382
2387
|
});
|
|
2388
|
+
bindTurnTerminationScope(parsed, resolvedConversationId);
|
|
2389
|
+
const rememberKiroDeliveredFinalAnswer = (adapterName: string, response: unknown): void => {
|
|
2390
|
+
if (adapterName === "kiro") rememberDeliveredFinalAnswer(parsed, response);
|
|
2391
|
+
};
|
|
2383
2392
|
// _clientThreadId remains the routing/continuation identity supplied by Codex. Replay state uses
|
|
2384
2393
|
// a dedicated raw conversation namespace so mixed headers that carry the same identity still
|
|
2385
2394
|
// match, and a shared/synthetic session_id cannot coalesce distinct thread/Cursor conversations.
|
|
@@ -2841,6 +2850,10 @@ async function handleResponsesInner(
|
|
|
2841
2850
|
if (snapshot.projectId) rotatedProvider = { ...rotatedProvider, project: snapshot.projectId };
|
|
2842
2851
|
route.provider = rotatedProvider;
|
|
2843
2852
|
if (route.providerName === "kiro") parsed._kiroAuthContext = { ...(snapshot.kiro ?? {}) };
|
|
2853
|
+
// Re-stamp: a request that rotated accounts must be attributed to the account that actually
|
|
2854
|
+
// served it. All three rotation sites funnel through here, so this is the only re-stamp
|
|
2855
|
+
// needed -- and putting it anywhere else would let one of the three drift.
|
|
2856
|
+
stampOAuthAccountLabel(logCtx, route.providerName, route.provider, snapshot.accountId);
|
|
2844
2857
|
return true;
|
|
2845
2858
|
};
|
|
2846
2859
|
const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth"
|
|
@@ -2882,6 +2895,10 @@ async function handleResponsesInner(
|
|
|
2882
2895
|
};
|
|
2883
2896
|
if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
|
|
2884
2897
|
route.provider = { ...route.provider, apiKey: resolved.accessToken };
|
|
2898
|
+
// Attribution is independent of failover (#2699): stamped from the resolved snapshot
|
|
2899
|
+
// itself, not from inside the `isGenericFailoverProvider` branch below, so a future
|
|
2900
|
+
// narrowing of that predicate cannot silently switch attribution off.
|
|
2901
|
+
stampOAuthAccountLabel(logCtx, route.providerName, route.provider, resolved.accountId);
|
|
2885
2902
|
// Remember which account actually served this request so a 429 cools THAT one, not
|
|
2886
2903
|
// whichever account is active by the time the response comes back (#2568).
|
|
2887
2904
|
if (isGenericFailoverProvider(route.providerName, route.provider)) {
|
|
@@ -4440,6 +4457,7 @@ async function handleResponsesInner(
|
|
|
4440
4457
|
...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
|
|
4441
4458
|
onCompletedResponse: (response, providerState) => {
|
|
4442
4459
|
commitReasoningReplayServingRoute();
|
|
4460
|
+
rememberKiroDeliveredFinalAnswer(adapter.name, response);
|
|
4443
4461
|
rememberResponseState(
|
|
4444
4462
|
parsed._rawBody,
|
|
4445
4463
|
response,
|
|
@@ -4755,6 +4773,7 @@ async function handleResponsesInner(
|
|
|
4755
4773
|
},
|
|
4756
4774
|
onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
|
|
4757
4775
|
commitReasoningReplayServingRoute();
|
|
4776
|
+
rememberKiroDeliveredFinalAnswer(adapter.name, response);
|
|
4758
4777
|
if (!routedCompaction) {
|
|
4759
4778
|
rememberResponseState(
|
|
4760
4779
|
parsed._rawBody,
|
|
@@ -4824,6 +4843,7 @@ async function handleResponsesInner(
|
|
|
4824
4843
|
},
|
|
4825
4844
|
});
|
|
4826
4845
|
if (!routedCompaction) {
|
|
4846
|
+
rememberKiroDeliveredFinalAnswer(adapter.name, json);
|
|
4827
4847
|
rememberResponseState(
|
|
4828
4848
|
parsed._rawBody,
|
|
4829
4849
|
json,
|
|
@@ -4858,6 +4878,64 @@ async function handleResponsesInner(
|
|
|
4858
4878
|
// reuse is safe, and releaseBodyObservation is idempotent per build.
|
|
4859
4879
|
let initialRequest: AdapterRequest | undefined;
|
|
4860
4880
|
let inputTokenEstimate: number | undefined;
|
|
4881
|
+
// An adapter may know the turn needs no inference at all — Kiro's replayed history ending in a
|
|
4882
|
+
// delivered final answer. Answer it locally: no build (so no token estimate), no send (so
|
|
4883
|
+
// sendCount stays 0), and crucially no empty-completion guard, which treats an outputless
|
|
4884
|
+
// terminal as a failed turn and re-invokes the identical request. Routing this through the
|
|
4885
|
+
// ordinary event path would therefore reinstate the loop it exists to end.
|
|
4886
|
+
const localTerminal = activeAdapter.localTerminal?.(parsed);
|
|
4887
|
+
if (localTerminal) {
|
|
4888
|
+
logCtx.localTerminalReason = localTerminal.reason;
|
|
4889
|
+
// Mark the physical attempt too, not just the parent row. `finishRequestAttempt` finalizes the
|
|
4890
|
+
// attempt through the same estimated-provider path, so without this the row reads exact while
|
|
4891
|
+
// its own attempt still claims an estimate — the detailed accounting a maintainer actually
|
|
4892
|
+
// reads for a zero-send turn.
|
|
4893
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.locallyAnswered = true;
|
|
4894
|
+
cleanupUpstreamAbort();
|
|
4895
|
+
upstream.abort();
|
|
4896
|
+
const terminalEvents: AdapterEvent[] = [{
|
|
4897
|
+
type: "done",
|
|
4898
|
+
endTurn: true,
|
|
4899
|
+
usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 },
|
|
4900
|
+
}];
|
|
4901
|
+
if (parsed.stream) {
|
|
4902
|
+
const localSse = bridgeToResponsesSSE(
|
|
4903
|
+
(async function* () { yield* terminalEvents; })(),
|
|
4904
|
+
parsed._responseModelId ?? parsed.modelId,
|
|
4905
|
+
toolBridgeMaps.toolNsMap,
|
|
4906
|
+
toolBridgeMaps.freeformToolNames,
|
|
4907
|
+
toolBridgeMaps.toolSearchToolNames,
|
|
4908
|
+
undefined,
|
|
4909
|
+
2_000,
|
|
4910
|
+
{
|
|
4911
|
+
translatorBudget,
|
|
4912
|
+
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
4913
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
4914
|
+
},
|
|
4915
|
+
);
|
|
4916
|
+
// Same lifetime tracking as every other streaming return in this function: the turn
|
|
4917
|
+
// admission lease is released when the body finishes or the client disconnects. Returning
|
|
4918
|
+
// the raw stream would hold a lease for a turn that already has all of its output.
|
|
4919
|
+
const localTurnAc = new AbortController();
|
|
4920
|
+
return new Response(
|
|
4921
|
+
trackStreamLifetime(localSse, localTurnAc, undefined, options.turnAdmissionLease),
|
|
4922
|
+
{
|
|
4923
|
+
headers: {
|
|
4924
|
+
"Content-Type": "text/event-stream",
|
|
4925
|
+
"Cache-Control": "no-cache",
|
|
4926
|
+
"Connection": "keep-alive",
|
|
4927
|
+
"X-Accel-Buffering": "no",
|
|
4928
|
+
},
|
|
4929
|
+
},
|
|
4930
|
+
);
|
|
4931
|
+
}
|
|
4932
|
+
return new Response(
|
|
4933
|
+
JSON.stringify(buildResponseJSON(terminalEvents, parsed._responseModelId ?? parsed.modelId, {
|
|
4934
|
+
translatorBudget,
|
|
4935
|
+
})),
|
|
4936
|
+
{ headers: { "Content-Type": "application/json" } },
|
|
4937
|
+
);
|
|
4938
|
+
}
|
|
4861
4939
|
try {
|
|
4862
4940
|
initialRequest = await activeAdapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget });
|
|
4863
4941
|
refreshRoutedNamespaceToolAliases(initialRequest);
|
|
@@ -5706,6 +5784,7 @@ async function handleResponsesInner(
|
|
|
5706
5784
|
},
|
|
5707
5785
|
onCompletedResponse: (response: Record<string, unknown>, providerState?: OcxProviderContinuationState) => {
|
|
5708
5786
|
commitReasoningReplayServingRoute();
|
|
5787
|
+
rememberKiroDeliveredFinalAnswer(activeAdapter.name, response);
|
|
5709
5788
|
// Compaction turns must NOT enter the continuation cache: _rawBody still holds the full
|
|
5710
5789
|
// PRE-compaction history, and a later previous_response_id expansion would rehydrate the
|
|
5711
5790
|
// giant stale chain Codex just replaced.
|
|
@@ -5783,6 +5862,7 @@ async function handleResponsesInner(
|
|
|
5783
5862
|
});
|
|
5784
5863
|
// See the streaming branch: compaction turns skip the continuation cache.
|
|
5785
5864
|
if (!routedCompaction) {
|
|
5865
|
+
rememberKiroDeliveredFinalAnswer(activeAdapter.name, json);
|
|
5786
5866
|
rememberResponseState(
|
|
5787
5867
|
parsed._rawBody,
|
|
5788
5868
|
json,
|
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);
|
|
@@ -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 {
|
|
@@ -238,6 +239,7 @@ export async function abortStorageCleanupPolicyJobAsync(): Promise<void> {
|
|
|
238
239
|
}
|
|
239
240
|
}
|
|
240
241
|
|
|
242
|
+
/** Project a run result into the bounded management-API job outcome. */
|
|
241
243
|
function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome {
|
|
242
244
|
return {
|
|
243
245
|
ok: result.ok,
|
|
@@ -248,18 +250,26 @@ function outcomeFromResult(result: PolicyRunResult): PolicyJobOutcome {
|
|
|
248
250
|
...(result.freedBytes !== undefined ? { freedBytes: result.freedBytes } : {}),
|
|
249
251
|
...(result.removed !== undefined ? { removed: result.removed } : {}),
|
|
250
252
|
...(result.trashDir ? { trashDir: result.trashDir } : {}),
|
|
253
|
+
...(result.metadataPersistenceError
|
|
254
|
+
? { metadataPersistenceError: result.metadataPersistenceError }
|
|
255
|
+
: {}),
|
|
251
256
|
};
|
|
252
257
|
}
|
|
253
258
|
|
|
259
|
+
/** Publish one completed evaluation without losing successful cleanup effects. */
|
|
254
260
|
function applyFinished(result: PolicyRunResult): void {
|
|
255
261
|
// Prefer the latest persisted policy over `result.policy`. The worker (or
|
|
256
262
|
// in-process run) already merged run metadata into disk; a concurrent PUT
|
|
257
263
|
// may also have landed after that write. Re-reading avoids applying a stale
|
|
258
264
|
// start-of-job snapshot when the run skipped without saving.
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
265
|
+
// A best-effort fallback policy may predate concurrent edits; keep the current
|
|
266
|
+
// live config untouched when the durable metadata write did not land.
|
|
267
|
+
if (!result.metadataPersistenceError) {
|
|
268
|
+
try {
|
|
269
|
+
livePolicyApply?.(readStorageCleanupPolicyFromConfig());
|
|
270
|
+
} catch {
|
|
271
|
+
livePolicyApply?.(result.policy);
|
|
272
|
+
}
|
|
263
273
|
}
|
|
264
274
|
state = {
|
|
265
275
|
status: "idle",
|
package/src/storage/policy.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Privacy: logs never include host paths, digests of file contents, or secrets.
|
|
9
9
|
*/
|
|
10
10
|
import { resolveCodexHomeDir } from "../codex/home";
|
|
11
|
-
import { loadConfig, saveConfigPreservingClaudeCode } from "../config";
|
|
11
|
+
import { loadConfig, mutatePersistedConfig, saveConfigPreservingClaudeCode } from "../config";
|
|
12
12
|
import type { StorageCleanupPolicy } from "../types";
|
|
13
13
|
import {
|
|
14
14
|
computePreviewDigest,
|
|
@@ -38,6 +38,7 @@ export function setStorageCleanupPolicyLiveSink(
|
|
|
38
38
|
|
|
39
39
|
export type PolicySchedule = StorageCleanupPolicy["schedule"];
|
|
40
40
|
export type PolicyRunReason = "startup" | "schedule" | "manual";
|
|
41
|
+
export type PolicyMetadataPersistenceError = "missing" | "invalid" | "conflict" | "write_failed";
|
|
41
42
|
|
|
42
43
|
export type PolicySkipReason =
|
|
43
44
|
| "disabled"
|
|
@@ -54,6 +55,7 @@ export interface PolicyRunResult {
|
|
|
54
55
|
freedBytes?: number;
|
|
55
56
|
removed?: number;
|
|
56
57
|
trashDir?: string;
|
|
58
|
+
metadataPersistenceError?: PolicyMetadataPersistenceError;
|
|
57
59
|
policy: StorageCleanupPolicy;
|
|
58
60
|
}
|
|
59
61
|
|
|
@@ -399,6 +401,21 @@ export type PolicyRunMetadataPatch = {
|
|
|
399
401
|
lastRun?: StorageCleanupPolicy["lastRun"];
|
|
400
402
|
};
|
|
401
403
|
|
|
404
|
+
/** Apply only run-owned fields while preserving the supplied policy settings. */
|
|
405
|
+
function applyPolicyRunMetadata(
|
|
406
|
+
policy: StorageCleanupPolicy,
|
|
407
|
+
patch: PolicyRunMetadataPatch,
|
|
408
|
+
): StorageCleanupPolicy {
|
|
409
|
+
let next =
|
|
410
|
+
patch.nextRun === "defer_busy"
|
|
411
|
+
? deferBusy(policy, patch.now)
|
|
412
|
+
: advanceNextRun(policy, patch.now);
|
|
413
|
+
if (patch.lastRun) {
|
|
414
|
+
next = { ...next, lastRun: patch.lastRun };
|
|
415
|
+
}
|
|
416
|
+
return next;
|
|
417
|
+
}
|
|
418
|
+
|
|
402
419
|
/**
|
|
403
420
|
* Reload the latest persisted policy and write only run-owned metadata
|
|
404
421
|
* (`lastRun` / `nextRun`). Preserves concurrent edits to enabled, trigger,
|
|
@@ -410,17 +427,57 @@ export function commitPolicyRunMetadata(
|
|
|
410
427
|
patch: PolicyRunMetadataPatch,
|
|
411
428
|
): StorageCleanupPolicy {
|
|
412
429
|
const latest = normalizeStorageCleanupPolicy(load());
|
|
413
|
-
|
|
414
|
-
patch.nextRun === "defer_busy"
|
|
415
|
-
? deferBusy(latest, patch.now)
|
|
416
|
-
: advanceNextRun(latest, patch.now);
|
|
417
|
-
if (patch.lastRun) {
|
|
418
|
-
next = { ...next, lastRun: patch.lastRun };
|
|
419
|
-
}
|
|
430
|
+
const next = applyPolicyRunMetadata(latest, patch);
|
|
420
431
|
save(next);
|
|
421
432
|
return next;
|
|
422
433
|
}
|
|
423
434
|
|
|
435
|
+
type PolicyRunMetadataCommit = {
|
|
436
|
+
policy: StorageCleanupPolicy;
|
|
437
|
+
persistenceError?: PolicyMetadataPersistenceError;
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
/** Attach the durable metadata outcome without replacing cleanup status or metrics. */
|
|
441
|
+
function withMetadataCommit(
|
|
442
|
+
result: Omit<PolicyRunResult, "policy" | "metadataPersistenceError">,
|
|
443
|
+
committed: PolicyRunMetadataCommit,
|
|
444
|
+
): PolicyRunResult {
|
|
445
|
+
return {
|
|
446
|
+
...result,
|
|
447
|
+
policy: committed.policy,
|
|
448
|
+
...(committed.persistenceError ? { metadataPersistenceError: committed.persistenceError } : {}),
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Recompute run-owned metadata from the latest config inside the mutation lock. */
|
|
453
|
+
function commitPolicyRunMetadataToConfig(
|
|
454
|
+
patch: PolicyRunMetadataPatch,
|
|
455
|
+
fallbackPolicy: StorageCleanupPolicy,
|
|
456
|
+
): PolicyRunMetadataCommit {
|
|
457
|
+
const unavailable = (reason: PolicyMetadataPersistenceError): PolicyRunMetadataCommit => {
|
|
458
|
+
console.warn(`[storage-policy] metadata_persist_failed reason=${reason}`);
|
|
459
|
+
return {
|
|
460
|
+
policy: applyPolicyRunMetadata(fallbackPolicy, patch),
|
|
461
|
+
persistenceError: reason,
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
try {
|
|
465
|
+
const outcome = mutatePersistedConfig(config => {
|
|
466
|
+
const next = applyPolicyRunMetadata(
|
|
467
|
+
normalizeStorageCleanupPolicy(config.storageCleanupPolicy),
|
|
468
|
+
patch,
|
|
469
|
+
);
|
|
470
|
+
config.storageCleanupPolicy = next;
|
|
471
|
+
return { changed: true, value: next };
|
|
472
|
+
});
|
|
473
|
+
if (outcome.status === "unavailable") return unavailable(outcome.reason);
|
|
474
|
+
livePolicySink?.(outcome.value);
|
|
475
|
+
return { policy: outcome.value };
|
|
476
|
+
} catch {
|
|
477
|
+
return unavailable("write_failed");
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
424
481
|
function logPolicyEvent(message: string): void {
|
|
425
482
|
console.log(`[storage-policy] ${message}`);
|
|
426
483
|
}
|
|
@@ -434,8 +491,14 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult {
|
|
|
434
491
|
const load = deps.loadPolicy ?? readStorageCleanupPolicyFromConfig;
|
|
435
492
|
const save = deps.savePolicy ?? writeStorageCleanupPolicyToConfig;
|
|
436
493
|
const execute = deps.execute ?? executeArchivedCleanup;
|
|
437
|
-
|
|
438
494
|
const policy = normalizeStorageCleanupPolicy(load());
|
|
495
|
+
// Injected stores retain the existing load/save contract. The production path
|
|
496
|
+
// recomputes metadata from the latest persisted policy inside the config lock.
|
|
497
|
+
const commitMetadata = deps.loadPolicy !== undefined || deps.savePolicy !== undefined
|
|
498
|
+
? (patch: PolicyRunMetadataPatch): PolicyRunMetadataCommit => ({
|
|
499
|
+
policy: commitPolicyRunMetadata(load, save, patch),
|
|
500
|
+
})
|
|
501
|
+
: (patch: PolicyRunMetadataPatch) => commitPolicyRunMetadataToConfig(patch, policy);
|
|
439
502
|
|
|
440
503
|
if (typeof deps.holdAfterLoadMs === "number" && Number.isFinite(deps.holdAfterLoadMs) && deps.holdAfterLoadMs > 0) {
|
|
441
504
|
Bun.sleepSync(Math.floor(deps.holdAfterLoadMs));
|
|
@@ -451,15 +514,15 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult {
|
|
|
451
514
|
|
|
452
515
|
const selection = selectPolicyPreview(policy, deps.codexHome);
|
|
453
516
|
if (selection.archivedBytes <= policy.trigger.archivedBytesOver) {
|
|
454
|
-
const
|
|
517
|
+
const committed = commitMetadata({ now, nextRun: "advance" });
|
|
455
518
|
logPolicyEvent("skip under_threshold");
|
|
456
|
-
return { ok: true, skipped: "under_threshold",
|
|
519
|
+
return withMetadataCommit({ ok: true, skipped: "under_threshold" }, committed);
|
|
457
520
|
}
|
|
458
521
|
|
|
459
522
|
if (selection.count === 0) {
|
|
460
|
-
const
|
|
523
|
+
const committed = commitMetadata({ now, nextRun: "advance" });
|
|
461
524
|
logPolicyEvent("skip nothing_selected");
|
|
462
|
-
return { ok: true, skipped: "nothing_selected",
|
|
525
|
+
return withMetadataCommit({ ok: true, skipped: "nothing_selected" }, committed);
|
|
463
526
|
}
|
|
464
527
|
|
|
465
528
|
const result = execute({
|
|
@@ -473,25 +536,28 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult {
|
|
|
473
536
|
});
|
|
474
537
|
|
|
475
538
|
if (!result.ok && result.error === "codex_busy") {
|
|
476
|
-
const
|
|
539
|
+
const committed = commitMetadata({ now, nextRun: "defer_busy" });
|
|
477
540
|
logPolicyEvent("defer codex_busy");
|
|
478
|
-
return {
|
|
541
|
+
return withMetadataCommit({
|
|
542
|
+
ok: false,
|
|
543
|
+
deferred: "codex_busy",
|
|
544
|
+
error: "codex_busy",
|
|
545
|
+
}, committed);
|
|
479
546
|
}
|
|
480
547
|
|
|
481
548
|
if (!result.ok) {
|
|
482
549
|
// Non-busy failure: still advance schedule so we do not tight-loop.
|
|
483
|
-
const
|
|
550
|
+
const committed = commitMetadata({ now, nextRun: "advance" });
|
|
484
551
|
logPolicyEvent(`fail ${result.error ?? "cleanup_failed"}`);
|
|
485
|
-
return {
|
|
552
|
+
return withMetadataCommit({
|
|
486
553
|
ok: false,
|
|
487
554
|
error: result.error,
|
|
488
555
|
mode: result.mode,
|
|
489
556
|
...(result.trashDir ? { trashDir: result.trashDir } : {}),
|
|
490
|
-
|
|
491
|
-
};
|
|
557
|
+
}, committed);
|
|
492
558
|
}
|
|
493
559
|
|
|
494
|
-
const
|
|
560
|
+
const committed = commitMetadata({
|
|
495
561
|
now,
|
|
496
562
|
nextRun: "advance",
|
|
497
563
|
lastRun: {
|
|
@@ -503,14 +569,13 @@ export function runStorageCleanupPolicy(deps: PolicyRunDeps): PolicyRunResult {
|
|
|
503
569
|
logPolicyEvent(
|
|
504
570
|
`ok mode=${result.mode} removed=${result.count} freedBytes=${result.bytes}`,
|
|
505
571
|
);
|
|
506
|
-
return {
|
|
572
|
+
return withMetadataCommit({
|
|
507
573
|
ok: true,
|
|
508
574
|
mode: result.mode,
|
|
509
575
|
freedBytes: result.bytes,
|
|
510
576
|
removed: result.count,
|
|
511
577
|
...(result.trashDir ? { trashDir: result.trashDir } : {}),
|
|
512
|
-
|
|
513
|
-
};
|
|
578
|
+
}, committed);
|
|
514
579
|
}
|
|
515
580
|
|
|
516
581
|
/** Startup / schedule tick entry — swallows unexpected errors. */
|
package/src/usage/log.ts
CHANGED
|
@@ -8,12 +8,33 @@ import { sanitizeLogMetadataString } from "../lib/redact";
|
|
|
8
8
|
import { usageDisplayTotalTokens } from "./totals";
|
|
9
9
|
import type { AttemptTierOutcome, OcxUsage } from "../types";
|
|
10
10
|
import { normalizeRouteDecisionTrace, type RouteDecisionTraceV1 } from "../routing/trace";
|
|
11
|
-
import { CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
|
|
11
|
+
import { ACCOUNT_LOG_LABEL_RE, CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
|
|
12
12
|
|
|
13
13
|
export type UsageStatus = "reported" | "unreported" | "unsupported" | "estimated";
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* A persisted account label: a Codex pool account (`main`/`p<hex6>`) or a non-Codex OAuth
|
|
16
|
+
* provider account (`o<hex6>`, #2699).
|
|
17
|
+
*
|
|
18
|
+
* The old name `CodexUsageAccountLogLabel` is kept as an alias because it is exported and used
|
|
19
|
+
* across modules; the two predicates below are what callers should choose between.
|
|
20
|
+
*/
|
|
21
|
+
export type UsageAccountLogLabel = "main" | `p${string}` | `o${string}`;
|
|
22
|
+
export type CodexUsageAccountLogLabel = UsageAccountLogLabel;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Accepts EITHER label family. This is the predicate the persistence writers use, so widening
|
|
26
|
+
* it here is what stops six separate call sites from silently dropping an `o`-label -- including
|
|
27
|
+
* two in the live request path (`request-log.ts:972` and `:1187`).
|
|
28
|
+
*
|
|
29
|
+
* The name is unchanged deliberately: renaming it would touch every call site for no behavior,
|
|
30
|
+
* and the widened contract is what every one of those sites wanted.
|
|
31
|
+
*/
|
|
32
|
+
export function isCodexUsageAccountLogLabel(value: unknown): value is UsageAccountLogLabel {
|
|
33
|
+
return value === "main" || (typeof value === "string" && ACCOUNT_LOG_LABEL_RE.test(value));
|
|
34
|
+
}
|
|
15
35
|
|
|
16
|
-
|
|
36
|
+
/** Strictly a Codex pool label. Use when the Codex-only distinction actually matters. */
|
|
37
|
+
export function isCodexPoolAccountLogLabel(value: unknown): value is "main" | `p${string}` {
|
|
17
38
|
return value === "main" || (typeof value === "string" && CODEX_ACCOUNT_LOG_LABEL_RE.test(value));
|
|
18
39
|
}
|
|
19
40
|
|
|
@@ -51,6 +72,13 @@ export interface PersistedUsageAttempt {
|
|
|
51
72
|
sendCount: number;
|
|
52
73
|
recoveryKinds: AttemptRecoveryKind[];
|
|
53
74
|
usageStatus: UsageStatus;
|
|
75
|
+
/**
|
|
76
|
+
* True when the proxy answered this turn locally and issued no upstream request. It travels on
|
|
77
|
+
* the attempt itself rather than as a `finishRequestAttempt` argument because that function is
|
|
78
|
+
* called from six places, and a new parameter would silently default to the wrong answer at any
|
|
79
|
+
* one of them that was missed. Absent on ordinary attempts so old rows keep their exact shape.
|
|
80
|
+
*/
|
|
81
|
+
locallyAnswered?: boolean;
|
|
54
82
|
/** Stable non-PII identity for the Codex pool account that served this attempt. */
|
|
55
83
|
accountLogLabel?: CodexUsageAccountLogLabel;
|
|
56
84
|
inputTokenEstimate?: number;
|
|
@@ -181,8 +209,20 @@ function isEstimatedUsageProvider(providerOrAdapter: string): boolean {
|
|
|
181
209
|
|| providerOrAdapter === "cursor" || providerOrAdapter.startsWith("cursor-");
|
|
182
210
|
}
|
|
183
211
|
|
|
184
|
-
export function usageForFinalLog(
|
|
212
|
+
export function usageForFinalLog(
|
|
213
|
+
provider: string,
|
|
214
|
+
usage: OcxUsage | undefined,
|
|
215
|
+
/**
|
|
216
|
+
* True when the proxy answered this turn locally and issued no upstream request. Such a turn's
|
|
217
|
+
* zero counts are EXACT, so the provider-wide estimated marking must not apply: Kiro and Cursor
|
|
218
|
+
* are marked estimated because their adapters can only guess a real inference's usage, and a
|
|
219
|
+
* turn with no inference has nothing to guess. Without this, a no-send turn is indistinguishable
|
|
220
|
+
* from a real one whose usage frame never arrived.
|
|
221
|
+
*/
|
|
222
|
+
locallyAnswered = false,
|
|
223
|
+
): OcxUsage | undefined {
|
|
185
224
|
if (!usage) return undefined;
|
|
225
|
+
if (locallyAnswered) return usage;
|
|
186
226
|
if (usage.estimated || isEstimatedUsageProvider(provider)) return { ...usage, estimated: true };
|
|
187
227
|
return usage;
|
|
188
228
|
}
|
package/src/usage/summary.ts
CHANGED
|
@@ -684,6 +684,16 @@ function legacyCodexAccountLabel(provider: string): string | null {
|
|
|
684
684
|
return suffix ?? LEGACY_AMBIGUOUS_ACCOUNT_LABEL;
|
|
685
685
|
}
|
|
686
686
|
|
|
687
|
+
/**
|
|
688
|
+
* An explicitly stamped label of EITHER family is authoritative for any provider (#2699).
|
|
689
|
+
*
|
|
690
|
+
* No `o`-label branch is needed here: `isCodexUsageAccountLogLabel` now accepts both families,
|
|
691
|
+
* and adding a second predicate call would be a no-op guarded by a comment claiming otherwise.
|
|
692
|
+
*
|
|
693
|
+
* The legacy fallback stays openai-only on purpose. It infers an account from the PROVIDER
|
|
694
|
+
* string, and inferring for a non-Codex row would merge unrelated accounts under one label --
|
|
695
|
+
* so an unlabeled xai row is dropped from the account table rather than guessed at.
|
|
696
|
+
*/
|
|
687
697
|
function accountLabelForAttribution(provider: string, explicit: unknown): string | null {
|
|
688
698
|
if (isCodexUsageAccountLogLabel(explicit)) return explicit;
|
|
689
699
|
return legacyCodexAccountLabel(provider);
|