@bitkyc08/opencodex 2.11.1 → 2.12.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/README.md +1 -1
- package/bin/ocx.mjs +4 -0
- package/gui/dist/assets/index-BNVYzdn0.css +1 -0
- package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +94 -5
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google.ts +24 -7
- package/src/adapters/openai-chat.ts +233 -305
- package/src/adapters/openai-responses.ts +62 -0
- package/src/adapters/tool-catalog-nudge.ts +2 -11
- package/src/claude/outbound.ts +42 -14
- package/src/cli/claude.ts +61 -9
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +8 -1
- package/src/cli/config-command.ts +4 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/help.ts +15 -0
- package/src/cli/index.ts +27 -11
- package/src/cli/lab.ts +367 -0
- package/src/cli/provider.ts +9 -1
- package/src/clients/config-export.ts +100 -3
- package/src/codex/app-server-processes.ts +11 -8
- package/src/codex/auth-api.ts +92 -28
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/catalog/metadata.ts +7 -3
- package/src/codex/catalog/provider-fetch.ts +191 -47
- package/src/codex/catalog.ts +2 -2
- package/src/codex/history-job.ts +83 -18
- package/src/codex/history-migration-guardian.ts +9 -16
- package/src/codex/history-provider.ts +178 -1
- package/src/codex/history-transition.ts +5 -5
- package/src/codex/history-worker.ts +22 -2
- package/src/codex/native-profile-store.ts +1 -1
- package/src/codex/plan.ts +15 -0
- package/src/codex/quota.ts +6 -6
- package/src/codex/routing.ts +3 -3
- package/src/codex/subagent-model-fallback.ts +246 -22
- package/src/codex/user-identity.ts +63 -12
- package/src/config.ts +265 -32
- package/src/images/loop.ts +3 -1
- package/src/images/plan.ts +23 -13
- package/src/integrations/omp-yaml-source.ts +225 -0
- package/src/integrations/ownership.ts +5 -5
- package/src/integrations/state.ts +45 -6
- package/src/integrations/writer.ts +38 -3
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +24 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +160 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +57 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +191 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +228 -0
- package/src/lab/events/validate.ts +780 -0
- package/src/lab/index.ts +36 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +241 -0
- package/src/lab/ledger/store.ts +263 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +311 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +116 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +344 -0
- package/src/lab/observe/from-live.ts +103 -0
- package/src/lab/paths.ts +65 -0
- package/src/lab/projection/rebuild.ts +477 -0
- package/src/lab/projection/schema.ts +122 -0
- package/src/lab/projection/verdicts.ts +438 -0
- package/src/lab/projection/verification.ts +332 -0
- package/src/lab/query/catalog.ts +72 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/index.ts +34 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +81 -0
- package/src/lab/subject/route-subject.ts +73 -0
- package/src/lib/bounded-body.ts +104 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +45 -0
- package/src/lib/pinned-http.ts +36 -7
- package/src/oauth/index.ts +20 -1
- package/src/oauth/local-token-detect.ts +11 -2
- package/src/oauth/login-cli.ts +38 -4
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/codex-capacity.ts +7 -4
- package/src/providers/command-code-efforts.ts +5 -2
- package/src/providers/derive.ts +105 -0
- package/src/providers/label.ts +1 -1
- package/src/providers/openai-tiers.ts +59 -3
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/quota.ts +67 -21
- package/src/providers/registry.ts +118 -5
- package/src/router.ts +15 -1
- package/src/routing/analytics.ts +8 -7
- package/src/routing/evaluator.ts +51 -5
- package/src/routing/profile.ts +10 -1
- package/src/routing/trace.ts +42 -6
- package/src/server/auth-cors.ts +15 -0
- package/src/server/background-lifecycle.ts +169 -0
- package/src/server/chat-completions.ts +18 -3
- package/src/server/claude-messages.ts +1 -0
- package/src/server/images.ts +78 -11
- package/src/server/index.ts +72 -18
- package/src/server/lifecycle.ts +6 -0
- package/src/server/live.ts +5 -1
- package/src/server/management/agent-settings-routes.ts +41 -24
- package/src/server/management/body.ts +6 -0
- package/src/server/management/config-routes.ts +51 -8
- package/src/server/management/lab-routes.ts +388 -0
- package/src/server/management/logs-usage-routes.ts +19 -1
- package/src/server/management/model-rows.ts +25 -11
- package/src/server/management/native-integration-routes.ts +3 -3
- package/src/server/management/provider-routes.ts +5 -0
- package/src/server/management/shared.ts +8 -1
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management/vision-sidecar-options.ts +125 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/request-decompress.ts +106 -5
- package/src/server/responses/collaboration.ts +4 -1
- package/src/server/responses/core.ts +117 -31
- package/src/server/responses-json-events.ts +50 -12
- package/src/server/search.ts +23 -7
- package/src/server/ws-bridge.ts +3 -1
- package/src/service.ts +40 -20
- package/src/types.ts +74 -0
- package/src/update/job.ts +10 -2
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +9 -9
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/describe.ts +2 -2
- package/src/vision/eligibility.ts +229 -0
- package/src/vision/index.ts +20 -2
- package/src/web-search/executor.ts +2 -2
- package/src/web-search/index.ts +4 -2
- package/src/web-search/loop.ts +3 -1
- package/src/web-search/parse.ts +72 -3
- package/gui/dist/assets/index-BA1dgo4q.css +0 -1
- package/gui/dist/assets/index-DDZpgzKk.js +0 -70
package/src/codex/history-job.ts
CHANGED
|
@@ -25,7 +25,7 @@ import type {
|
|
|
25
25
|
HistoryWorkerResult,
|
|
26
26
|
} from "./history-worker";
|
|
27
27
|
import { historyBackupPathFor } from "./history-provider";
|
|
28
|
-
import type { CodexHistoryFailureReason } from "./history-provider";
|
|
28
|
+
import type { CodexHistoryFailureReason, CodexHistoryVerifiedNoopProof } from "./history-provider";
|
|
29
29
|
import { getCodexHome } from "./paths";
|
|
30
30
|
|
|
31
31
|
/** Where Codex keeps its resume history, and the manifest that shadows it. */
|
|
@@ -59,6 +59,47 @@ export function resolveCodexHistoryJobTarget(): {
|
|
|
59
59
|
|
|
60
60
|
/** How long a history unit may run before the parent stops waiting on it. */
|
|
61
61
|
const WORKER_TIMEOUT_MS = 30_000;
|
|
62
|
+
/** A terminated Worker must close before its caller can cross the file boundary. */
|
|
63
|
+
const WORKER_CLOSE_TIMEOUT_MS = 5_000;
|
|
64
|
+
|
|
65
|
+
function historyWorkerOsJoinSettleMs(platform = process.platform): number {
|
|
66
|
+
if (platform === "win32") return 1_500;
|
|
67
|
+
if (platform === "darwin" || platform === "linux") return 250;
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function terminateAndJoinHistoryWorker(
|
|
72
|
+
worker: Worker,
|
|
73
|
+
closed: Promise<void>,
|
|
74
|
+
): Promise<boolean> {
|
|
75
|
+
let timedOut = false;
|
|
76
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
77
|
+
const closeTimeout = new Promise<void>(resolve => {
|
|
78
|
+
timeout = setTimeout(() => {
|
|
79
|
+
timedOut = true;
|
|
80
|
+
resolve();
|
|
81
|
+
}, WORKER_CLOSE_TIMEOUT_MS);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
worker.terminate();
|
|
86
|
+
} catch {
|
|
87
|
+
// A Worker that already closed still resolves through the close listener.
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
await Promise.race([closed, closeTimeout]);
|
|
92
|
+
} finally {
|
|
93
|
+
if (timeout) clearTimeout(timeout);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const settleMs = historyWorkerOsJoinSettleMs();
|
|
97
|
+
if (settleMs > 0) {
|
|
98
|
+
await Bun.sleep(0);
|
|
99
|
+
await Bun.sleep(settleMs);
|
|
100
|
+
}
|
|
101
|
+
return !timedOut;
|
|
102
|
+
}
|
|
62
103
|
|
|
63
104
|
export interface CodexHistoryJobRequest {
|
|
64
105
|
readonly canonicalCodexHome: string;
|
|
@@ -69,7 +110,7 @@ export interface CodexHistoryJobRequest {
|
|
|
69
110
|
}
|
|
70
111
|
|
|
71
112
|
export type CodexHistoryJobOutcome =
|
|
72
|
-
| { readonly kind: "converged"; readonly rows: number; readonly files: number }
|
|
113
|
+
| { readonly kind: "converged"; readonly rows: number; readonly files: number; readonly proof?: CodexHistoryVerifiedNoopProof }
|
|
73
114
|
| { readonly kind: "skipped" }
|
|
74
115
|
| { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
|
|
75
116
|
| { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout";
|
|
@@ -92,8 +133,9 @@ export function isPlausibleWorkerResultForTests(
|
|
|
92
133
|
message: Record<string, unknown>,
|
|
93
134
|
requestId: string,
|
|
94
135
|
jobId: string,
|
|
136
|
+
target?: Pick<CodexHistoryJobRequest, "canonicalStateDbPath" | "canonicalBackupPath" | "operation">,
|
|
95
137
|
): boolean {
|
|
96
|
-
return isPlausibleWorkerResult(message, requestId, jobId);
|
|
138
|
+
return isPlausibleWorkerResult(message, requestId, jobId, target);
|
|
97
139
|
}
|
|
98
140
|
|
|
99
141
|
/**
|
|
@@ -108,13 +150,30 @@ function isPlausibleWorkerResult(
|
|
|
108
150
|
message: Record<string, unknown>,
|
|
109
151
|
requestId: string,
|
|
110
152
|
jobId: string,
|
|
153
|
+
target?: Pick<CodexHistoryJobRequest, "canonicalStateDbPath" | "canonicalBackupPath" | "operation">,
|
|
111
154
|
): boolean {
|
|
112
155
|
if (message.requestId !== requestId || message.jobId !== jobId) return false;
|
|
113
156
|
switch (message.type) {
|
|
114
157
|
case "done":
|
|
115
|
-
|
|
158
|
+
if (!((message.outcome === "converged" || message.outcome === "skipped")
|
|
116
159
|
&& typeof message.rows === "number"
|
|
117
|
-
&& typeof message.files === "number";
|
|
160
|
+
&& typeof message.files === "number")) return false;
|
|
161
|
+
if (message.proof === undefined) return true;
|
|
162
|
+
if (!target || !message.proof || typeof message.proof !== "object" || Array.isArray(message.proof)) return false;
|
|
163
|
+
{
|
|
164
|
+
const proof = message.proof as Record<string, unknown>;
|
|
165
|
+
return target.operation === "migrate-openai"
|
|
166
|
+
&& message.outcome === "converged"
|
|
167
|
+
&& message.rows === 0
|
|
168
|
+
&& message.files === 0
|
|
169
|
+
&& proof.kind === "verified-noop"
|
|
170
|
+
&& proof.pendingRows === 0
|
|
171
|
+
&& proof.backupEntries === 0
|
|
172
|
+
&& proof.canonicalStateDbPath === target.canonicalStateDbPath
|
|
173
|
+
&& proof.stateDbPresent === true
|
|
174
|
+
&& proof.canonicalBackupPath === target.canonicalBackupPath
|
|
175
|
+
&& typeof proof.backupPresent === "boolean";
|
|
176
|
+
}
|
|
118
177
|
case "blocked":
|
|
119
178
|
return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path"
|
|
120
179
|
|| message.reason === "desired_disabled" || message.reason === "desired_enabled";
|
|
@@ -227,7 +286,7 @@ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutco
|
|
|
227
286
|
}
|
|
228
287
|
return result.outcome === "skipped"
|
|
229
288
|
? { kind: "skipped" }
|
|
230
|
-
: { kind: "converged", rows: result.rows, files: result.files };
|
|
289
|
+
: { kind: "converged", rows: result.rows, files: result.files, ...(result.proof ? { proof: result.proof } : {}) };
|
|
231
290
|
}
|
|
232
291
|
|
|
233
292
|
/**
|
|
@@ -263,20 +322,23 @@ export async function runCodexHistoryJob(
|
|
|
263
322
|
|
|
264
323
|
return new Promise<CodexHistoryJobOutcome>(resolve => {
|
|
265
324
|
let settled = false;
|
|
325
|
+
let resolveWorkerClosed!: () => void;
|
|
326
|
+
const workerClosed = new Promise<void>(closed => {
|
|
327
|
+
resolveWorkerClosed = closed;
|
|
328
|
+
});
|
|
266
329
|
const finish = (outcome: CodexHistoryJobOutcome) => {
|
|
267
330
|
if (settled) return;
|
|
268
331
|
settled = true;
|
|
269
332
|
clearTimeout(timer);
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
done();
|
|
333
|
+
// Bun 1.3.14 returns void from terminate(); resolving on that return lets
|
|
334
|
+
// the Worker outlive this job and, under `bun test --isolate`, its file.
|
|
335
|
+
void terminateAndJoinHistoryWorker(worker, workerClosed).then(joined => {
|
|
336
|
+
resolve(joined
|
|
337
|
+
? outcome
|
|
338
|
+
: { kind: "failed", reason: "worker-died", message: "history_worker_close_timeout" });
|
|
339
|
+
}, () => {
|
|
340
|
+
resolve({ kind: "failed", reason: "worker-died", message: "history_worker_join_failed" });
|
|
341
|
+
});
|
|
280
342
|
};
|
|
281
343
|
|
|
282
344
|
const timer = setTimeout(() => {
|
|
@@ -303,7 +365,7 @@ export async function runCodexHistoryJob(
|
|
|
303
365
|
died("history_worker_unknown_message_type");
|
|
304
366
|
return;
|
|
305
367
|
}
|
|
306
|
-
if (!isPlausibleWorkerResult(message, requestId, jobId)) {
|
|
368
|
+
if (!isPlausibleWorkerResult(message, requestId, jobId, request)) {
|
|
307
369
|
// A recognized type with a missing payload read as `converged` with
|
|
308
370
|
// undefined fields once — success for work that may not have happened.
|
|
309
371
|
died("history_worker_malformed_payload");
|
|
@@ -319,7 +381,10 @@ export async function runCodexHistoryJob(
|
|
|
319
381
|
* and the Worker always closes after posting its result.
|
|
320
382
|
*/
|
|
321
383
|
worker.addEventListener("messageerror", () => died("history_worker_unserializable_message"));
|
|
322
|
-
worker.addEventListener("close", () =>
|
|
384
|
+
worker.addEventListener("close", () => {
|
|
385
|
+
resolveWorkerClosed();
|
|
386
|
+
died("history_worker_closed_early");
|
|
387
|
+
}, { once: true });
|
|
323
388
|
|
|
324
389
|
worker.onerror = (event: ErrorEvent) => {
|
|
325
390
|
finish({
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { migrateHistoryToOpenai } from "./history-provider";
|
|
2
2
|
import { resolveCodexHistoryJobTarget, runCodexHistoryJob } from "./history-job";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -23,7 +23,6 @@ export interface HistoryMigrationGuardianHandle {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export interface HistoryMigrationGuardianDeps {
|
|
26
|
-
countFn?: typeof countPendingOpencodexHistory;
|
|
27
26
|
migrateFn?: () => ReturnType<typeof migrateHistoryToOpenai>
|
|
28
27
|
| Promise<ReturnType<typeof migrateHistoryToOpenai>>;
|
|
29
28
|
log?: Pick<Console, "log">;
|
|
@@ -43,7 +42,6 @@ function defaultSchedule(fn: () => void, ms: number): { cancel(): void } {
|
|
|
43
42
|
}
|
|
44
43
|
|
|
45
44
|
export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps = {}): HistoryMigrationGuardianHandle {
|
|
46
|
-
const countFn = deps.countFn ?? countPendingOpencodexHistory;
|
|
47
45
|
// The default migration goes through the history job, so the guardian's timer
|
|
48
46
|
// thread never performs the transition itself. A background repair that races
|
|
49
47
|
// an apply or a restore is exactly what H exists to order.
|
|
@@ -53,7 +51,7 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps
|
|
|
53
51
|
operation: "migrate-openai",
|
|
54
52
|
});
|
|
55
53
|
return outcome.kind === "converged"
|
|
56
|
-
? { rows: outcome.rows, files: outcome.files }
|
|
54
|
+
? { rows: outcome.rows, files: outcome.files, verifiedNoop: outcome.proof?.kind === "verified-noop" }
|
|
57
55
|
: { rows: 0, files: 0, failed: true as const };
|
|
58
56
|
});
|
|
59
57
|
const log = deps.log ?? console;
|
|
@@ -73,23 +71,18 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps
|
|
|
73
71
|
if (stopped) return;
|
|
74
72
|
ticks++;
|
|
75
73
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
stopped = true; // nothing left to migrate — normal steady state, no log noise
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
// Locked probe or pending work: attempt one migration pass.
|
|
74
|
+
// The worker re-derives state under H on every pass; no pre-lock probe is
|
|
75
|
+
// allowed to stop or suppress this attempt.
|
|
82
76
|
const result = await migrateFn();
|
|
83
77
|
if (!result.failed) {
|
|
84
78
|
const moved = result.rows + ((result as { ejectedRows?: number }).ejectedRows ?? 0);
|
|
85
79
|
if (moved > 0) {
|
|
86
80
|
log.log(`🩹 history-migration: ${moved} legacy opencodex thread(s) migrated back to openai.`);
|
|
87
81
|
}
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (moved > 0 || (!after.failed && after.pendingRows === 0 && after.backupEntries === 0)) {
|
|
82
|
+
// Zero mutations are authoritative only when the worker verified the
|
|
83
|
+
// exact DB/manifest state while H was held.
|
|
84
|
+
const verifiedNoop = (result as { verifiedNoop?: boolean }).verifiedNoop === true;
|
|
85
|
+
if (moved > 0 || verifiedNoop) {
|
|
93
86
|
stopped = true;
|
|
94
87
|
return;
|
|
95
88
|
}
|
|
@@ -99,7 +92,7 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps
|
|
|
99
92
|
}
|
|
100
93
|
if (ticks >= maxTicks) {
|
|
101
94
|
stopped = true;
|
|
102
|
-
log.log("⚠️ history-migration:
|
|
95
|
+
log.log("⚠️ history-migration: Could not verify that legacy threads were migrated; the history database may be busy, unavailable, or not yet ready. Run 'ocx sync' (or check 'ocx doctor').");
|
|
103
96
|
return;
|
|
104
97
|
}
|
|
105
98
|
schedule();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { zstdDecompressSync } from "node:zlib";
|
|
5
5
|
import { Database } from "bun:sqlite";
|
|
@@ -200,6 +200,49 @@ interface BackupManifest {
|
|
|
200
200
|
entries: Record<string, BackupEntry>;
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
export interface CodexHistoryVerifiedNoopProof {
|
|
204
|
+
readonly kind: "verified-noop";
|
|
205
|
+
readonly pendingRows: 0;
|
|
206
|
+
readonly backupEntries: 0;
|
|
207
|
+
readonly canonicalStateDbPath: string;
|
|
208
|
+
readonly stateDbPresent: true;
|
|
209
|
+
readonly canonicalBackupPath: string;
|
|
210
|
+
readonly backupPresent: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type CodexHistoryNoopSnapshot =
|
|
214
|
+
| CodexHistoryVerifiedNoopProof
|
|
215
|
+
| {
|
|
216
|
+
readonly kind: "work-pending";
|
|
217
|
+
readonly pendingRows: number;
|
|
218
|
+
readonly backupEntries: number;
|
|
219
|
+
readonly canonicalStateDbPath: string;
|
|
220
|
+
readonly stateDbPresent: boolean;
|
|
221
|
+
readonly canonicalBackupPath: string;
|
|
222
|
+
readonly backupPresent: boolean;
|
|
223
|
+
}
|
|
224
|
+
| {
|
|
225
|
+
readonly kind: "unknown";
|
|
226
|
+
readonly pendingRows: null;
|
|
227
|
+
readonly backupEntries: null;
|
|
228
|
+
readonly canonicalStateDbPath: string;
|
|
229
|
+
readonly stateDbPresent: boolean;
|
|
230
|
+
readonly canonicalBackupPath: string;
|
|
231
|
+
readonly backupPresent: boolean;
|
|
232
|
+
readonly reason: "backup-path" | "database-absent" | "manifest-read" | "manifest-schema" | "manifest-foreign" | "database-query" | "snapshot-race";
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
type StrictBackupInspection =
|
|
236
|
+
| { readonly kind: "known"; readonly present: boolean; readonly entries: number; readonly fingerprint: string }
|
|
237
|
+
| { readonly kind: "unknown"; readonly present: boolean; readonly reason: "manifest-read" | "manifest-schema" | "manifest-foreign" };
|
|
238
|
+
|
|
239
|
+
let afterNoopPendingCountForTests: (() => void) | undefined;
|
|
240
|
+
|
|
241
|
+
/** Test seam: runs after the pending count and before stability validation. */
|
|
242
|
+
export function setAfterNoopPendingCountForTests(hook: (() => void) | undefined): void {
|
|
243
|
+
afterNoopPendingCountForTests = hook;
|
|
244
|
+
}
|
|
245
|
+
|
|
203
246
|
interface NativeRestoreTarget {
|
|
204
247
|
modelProvider: string;
|
|
205
248
|
source: string;
|
|
@@ -212,6 +255,65 @@ function samePath(a: string, b: string): boolean {
|
|
|
212
255
|
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
213
256
|
}
|
|
214
257
|
|
|
258
|
+
function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupInspection {
|
|
259
|
+
if (!existsSync(path)) return { kind: "known", present: false, entries: 0, fingerprint: "absent" };
|
|
260
|
+
let parsed: unknown;
|
|
261
|
+
let raw: string;
|
|
262
|
+
try {
|
|
263
|
+
raw = readFileSync(path, "utf8");
|
|
264
|
+
parsed = JSON.parse(raw);
|
|
265
|
+
} catch {
|
|
266
|
+
return { kind: "unknown", present: true, reason: "manifest-read" };
|
|
267
|
+
}
|
|
268
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
269
|
+
return { kind: "unknown", present: true, reason: "manifest-schema" };
|
|
270
|
+
}
|
|
271
|
+
const manifest = parsed as Partial<BackupManifest>;
|
|
272
|
+
if (manifest.version !== 1 || typeof manifest.stateDbPath !== "string") {
|
|
273
|
+
return { kind: "unknown", present: true, reason: "manifest-schema" };
|
|
274
|
+
}
|
|
275
|
+
if (!samePath(manifest.stateDbPath, stateDbPath)) {
|
|
276
|
+
return { kind: "unknown", present: true, reason: "manifest-foreign" };
|
|
277
|
+
}
|
|
278
|
+
if (!manifest.entries || typeof manifest.entries !== "object" || Array.isArray(manifest.entries)) {
|
|
279
|
+
return { kind: "unknown", present: true, reason: "manifest-schema" };
|
|
280
|
+
}
|
|
281
|
+
for (const [id, value] of Object.entries(manifest.entries)) {
|
|
282
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
283
|
+
return { kind: "unknown", present: true, reason: "manifest-schema" };
|
|
284
|
+
}
|
|
285
|
+
const entry = value as Partial<BackupEntry>;
|
|
286
|
+
if (entry.id !== id
|
|
287
|
+
|| typeof entry.rolloutPath !== "string"
|
|
288
|
+
|| typeof entry.modelProvider !== "string"
|
|
289
|
+
|| typeof entry.source !== "string"
|
|
290
|
+
|| typeof entry.hasUserEvent !== "number") {
|
|
291
|
+
return { kind: "unknown", present: true, reason: "manifest-schema" };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
kind: "known",
|
|
296
|
+
present: true,
|
|
297
|
+
entries: Object.keys(manifest.entries).length,
|
|
298
|
+
fingerprint: createHash("sha256").update(raw).digest("hex"),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function historyFileIdentity(path: string): string | null {
|
|
303
|
+
try {
|
|
304
|
+
const stat = statSync(path);
|
|
305
|
+
return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
|
|
306
|
+
} catch {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function readHistoryDataVersion(db: Database): number | null {
|
|
312
|
+
const row = db.query<{ data_version: number }, []>("PRAGMA data_version").get();
|
|
313
|
+
const value = row?.data_version;
|
|
314
|
+
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
215
317
|
function readBackup(path: string, stateDbPath?: string): BackupManifest {
|
|
216
318
|
if (!existsSync(path)) return { version: 1, stateDbPath, entries: {} };
|
|
217
319
|
try {
|
|
@@ -756,6 +858,81 @@ export function migrateHistoryToOpenai(
|
|
|
756
858
|
return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
|
|
757
859
|
}
|
|
758
860
|
|
|
861
|
+
/**
|
|
862
|
+
* Captures no-op evidence while the caller holds the history serialization
|
|
863
|
+
* lock H. This function does not acquire H itself. Unknown or foreign backup
|
|
864
|
+
* state is never collapsed into an empty manifest.
|
|
865
|
+
*/
|
|
866
|
+
export function snapshotCodexHistoryNoop(
|
|
867
|
+
stateDbPath: string,
|
|
868
|
+
backupPath: string,
|
|
869
|
+
): CodexHistoryNoopSnapshot {
|
|
870
|
+
const canonicalStateDbPath = resolve(stateDbPath);
|
|
871
|
+
const canonicalBackupPath = resolve(backupPath);
|
|
872
|
+
const stateDbPresent = existsSync(stateDbPath);
|
|
873
|
+
const backupPresent = existsSync(backupPath);
|
|
874
|
+
const base = { canonicalStateDbPath, stateDbPresent, canonicalBackupPath, backupPresent };
|
|
875
|
+
if (!samePath(backupPath, historyBackupPathFor(stateDbPath))) {
|
|
876
|
+
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "backup-path" };
|
|
877
|
+
}
|
|
878
|
+
const backup = inspectBackupForNoop(backupPath, stateDbPath);
|
|
879
|
+
if (backup.kind === "unknown") {
|
|
880
|
+
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: backup.reason };
|
|
881
|
+
}
|
|
882
|
+
if (!stateDbPresent) {
|
|
883
|
+
return backup.entries > 0
|
|
884
|
+
? { kind: "work-pending", pendingRows: 0, backupEntries: backup.entries, ...base }
|
|
885
|
+
: { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-absent" };
|
|
886
|
+
}
|
|
887
|
+
const stateDbIdentity = historyFileIdentity(stateDbPath);
|
|
888
|
+
if (stateDbIdentity === null) {
|
|
889
|
+
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "snapshot-race" };
|
|
890
|
+
}
|
|
891
|
+
let monitor: Database | undefined;
|
|
892
|
+
try {
|
|
893
|
+
monitor = new Database(stateDbPath, { readonly: true });
|
|
894
|
+
monitor.exec("PRAGMA busy_timeout = 100");
|
|
895
|
+
const dataVersionBefore = readHistoryDataVersion(monitor);
|
|
896
|
+
if (dataVersionBefore === null) {
|
|
897
|
+
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
|
|
898
|
+
}
|
|
899
|
+
const pending = countPendingOpencodexHistory(stateDbPath, backupPath);
|
|
900
|
+
if (pending.failed) {
|
|
901
|
+
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
|
|
902
|
+
}
|
|
903
|
+
afterNoopPendingCountForTests?.();
|
|
904
|
+
const backupAfter = inspectBackupForNoop(backupPath, stateDbPath);
|
|
905
|
+
if (backupAfter.kind === "unknown") {
|
|
906
|
+
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: backupAfter.reason };
|
|
907
|
+
}
|
|
908
|
+
const dataVersionAfter = readHistoryDataVersion(monitor);
|
|
909
|
+
if (dataVersionAfter === null) {
|
|
910
|
+
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
|
|
911
|
+
}
|
|
912
|
+
if (dataVersionAfter !== dataVersionBefore
|
|
913
|
+
|| pending.backupEntries !== backup.entries
|
|
914
|
+
|| backupAfter.entries !== backup.entries
|
|
915
|
+
|| backupAfter.present !== backup.present
|
|
916
|
+
|| backupAfter.fingerprint !== backup.fingerprint
|
|
917
|
+
|| historyFileIdentity(stateDbPath) !== stateDbIdentity
|
|
918
|
+
|| existsSync(stateDbPath) !== stateDbPresent
|
|
919
|
+
|| existsSync(backupPath) !== backupPresent) {
|
|
920
|
+
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "snapshot-race" };
|
|
921
|
+
}
|
|
922
|
+
return pending.pendingRows === 0 && backup.entries === 0
|
|
923
|
+
? { kind: "verified-noop", pendingRows: 0, backupEntries: 0, ...base, stateDbPresent: true }
|
|
924
|
+
: { kind: "work-pending", pendingRows: pending.pendingRows, backupEntries: backup.entries, ...base };
|
|
925
|
+
} catch {
|
|
926
|
+
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
|
|
927
|
+
} finally {
|
|
928
|
+
try {
|
|
929
|
+
monitor?.close();
|
|
930
|
+
} catch {
|
|
931
|
+
// Read-only monitor cleanup cannot make an uncertain snapshot authoritative.
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
759
936
|
export interface PendingHistoryCount {
|
|
760
937
|
/** Threads still tagged opencodex that the eject path WOULD move (mirrors its WHERE). */
|
|
761
938
|
pendingRows: number;
|
|
@@ -33,11 +33,11 @@ function classify(outcome: CodexHistoryJobOutcome, txId: string | null): CodexHi
|
|
|
33
33
|
attempts: 1,
|
|
34
34
|
nextRetryAt: null,
|
|
35
35
|
txId,
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
pendingRows: null,
|
|
40
|
-
backupEntries: null,
|
|
36
|
+
// A zero is durable only when the worker proved the exact DB/manifest
|
|
37
|
+
// state while H was held. Without proof, keep the counts unknown. Keep
|
|
38
|
+
// `??`: `||` would discard a verified zero.
|
|
39
|
+
pendingRows: outcome.proof?.pendingRows ?? null,
|
|
40
|
+
backupEntries: outcome.proof?.backupEntries ?? null,
|
|
41
41
|
};
|
|
42
42
|
case "skipped":
|
|
43
43
|
// The user opting out is a completed decision, not a failure — converged
|
|
@@ -31,7 +31,11 @@ import {
|
|
|
31
31
|
writeLegacyOpenaiHistoryRecovery,
|
|
32
32
|
type HistoryWriteTarget,
|
|
33
33
|
} from "./internal/history-writer";
|
|
34
|
-
import
|
|
34
|
+
import {
|
|
35
|
+
snapshotCodexHistoryNoop,
|
|
36
|
+
type CodexHistoryFailureReason,
|
|
37
|
+
type CodexHistoryVerifiedNoopProof,
|
|
38
|
+
} from "./history-provider";
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
41
|
* The durable operation, mirrored into the request for diagnostics only.
|
|
@@ -65,7 +69,8 @@ export interface HistoryWorkerRunMessage {
|
|
|
65
69
|
export type HistoryWorkerResult =
|
|
66
70
|
| { readonly type: "done"; readonly requestId: string; readonly jobId: string;
|
|
67
71
|
readonly outcome: "converged" | "skipped";
|
|
68
|
-
readonly rows: number; readonly files: number
|
|
72
|
+
readonly rows: number; readonly files: number;
|
|
73
|
+
readonly proof?: CodexHistoryVerifiedNoopProof }
|
|
69
74
|
| { readonly type: "blocked"; readonly requestId: string; readonly jobId: string;
|
|
70
75
|
readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
|
|
71
76
|
| { readonly type: "error"; readonly requestId: string; readonly jobId: string;
|
|
@@ -133,6 +138,10 @@ export function runHistoryUnitUnderLock(
|
|
|
133
138
|
if (operation === "recover-legacy-openai") {
|
|
134
139
|
return writeLegacyOpenaiHistoryRecovery(permit, target);
|
|
135
140
|
}
|
|
141
|
+
if (operation === "migrate-openai") {
|
|
142
|
+
const proof = snapshotCodexHistoryNoop(message.canonicalStateDbPath, message.canonicalBackupPath);
|
|
143
|
+
if (proof.kind === "verified-noop") return { verifiedNoop: proof } as const;
|
|
144
|
+
}
|
|
136
145
|
// apply-opencodex routes history to opencodex; migrate/restore return it to
|
|
137
146
|
// native. The provider is derived from the operation, never from a caller.
|
|
138
147
|
const provider = operation === "apply-opencodex" ? "opencodex" : "openai";
|
|
@@ -152,6 +161,17 @@ export function runHistoryUnitUnderLock(
|
|
|
152
161
|
reason: message.expectedDesiredEnabled ? "desired_disabled" : "desired_enabled",
|
|
153
162
|
};
|
|
154
163
|
}
|
|
164
|
+
if ("verifiedNoop" in result) {
|
|
165
|
+
return {
|
|
166
|
+
type: "done",
|
|
167
|
+
requestId,
|
|
168
|
+
jobId,
|
|
169
|
+
outcome: "converged",
|
|
170
|
+
rows: 0,
|
|
171
|
+
files: 0,
|
|
172
|
+
proof: result.verifiedNoop,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
155
175
|
if (result.failed === true) {
|
|
156
176
|
return {
|
|
157
177
|
type: "error",
|
|
@@ -383,7 +383,7 @@ function readBounded(path: string, limit: number, testSeam?: BoundedReadTestSeam
|
|
|
383
383
|
testSeam?.beforeOpen?.(path);
|
|
384
384
|
const flags = process.platform === "win32"
|
|
385
385
|
? fsConstants.O_RDONLY
|
|
386
|
-
: fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW;
|
|
386
|
+
: fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK;
|
|
387
387
|
fd = openSync(path, flags);
|
|
388
388
|
const opened = fstatSync(fd, { bigint: true });
|
|
389
389
|
if (!opened.isFile() || opened.size > BigInt(limit)) throw new Error("invalid bounded file");
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Preserve user/provider plan labels only when they are usable strings. */
|
|
2
|
+
export function codexPlanValue(value: unknown): string | undefined {
|
|
3
|
+
if (typeof value !== "string") return undefined;
|
|
4
|
+
return value.trim() ? value : undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Case-insensitive key used by quota, capacity, and collision policy. */
|
|
8
|
+
export function codexPlanKey(value: unknown): string | undefined {
|
|
9
|
+
return codexPlanValue(value)?.trim().toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function isThirtyDayOnlyCodexPlan(value: unknown): boolean {
|
|
13
|
+
const key = codexPlanKey(value);
|
|
14
|
+
return key === "go" || key === "free";
|
|
15
|
+
}
|
package/src/codex/quota.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { atomicWriteFile, getConfigDir } from "../config";
|
|
4
4
|
import { captureConfigGeneration, type GenerationContext } from "../lib/state-store-sweeper";
|
|
5
|
+
import { isThirtyDayOnlyCodexPlan } from "./plan";
|
|
5
6
|
|
|
6
7
|
export type StoredAccountQuota = {
|
|
7
8
|
weeklyPercent?: number;
|
|
@@ -36,7 +37,7 @@ let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
36
37
|
|
|
37
38
|
export type WhamUsageResponse = {
|
|
38
39
|
email?: string | null;
|
|
39
|
-
plan_type?:
|
|
40
|
+
plan_type?: unknown;
|
|
40
41
|
rate_limit?: {
|
|
41
42
|
// Live WHAM payloads send explicit nulls for absent windows (issue #315 repro).
|
|
42
43
|
primary_window?: WhamUsageWindow | null;
|
|
@@ -72,7 +73,7 @@ export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
|
|
|
72
73
|
|
|
73
74
|
export function isCodexQuotaExhausted(
|
|
74
75
|
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent"> | null,
|
|
75
|
-
plan?:
|
|
76
|
+
plan?: unknown,
|
|
76
77
|
): boolean {
|
|
77
78
|
if (!quota) return false;
|
|
78
79
|
const values = codexQuotaWindowForPlan(plan) === "monthly"
|
|
@@ -98,14 +99,13 @@ export function isCodexQuotaExhausted(
|
|
|
98
99
|
* everything else (including an absent plan) reports weekly. Recovery reads the
|
|
99
100
|
* window the parser actually wrote rather than second-guessing it.
|
|
100
101
|
*/
|
|
101
|
-
export function codexQuotaWindowForPlan(plan?:
|
|
102
|
-
|
|
103
|
-
return normalized === "go" || normalized === "free" ? "monthly" : "weekly";
|
|
102
|
+
export function codexQuotaWindowForPlan(plan?: unknown): "monthly" | "weekly" {
|
|
103
|
+
return isThirtyDayOnlyCodexPlan(plan) ? "monthly" : "weekly";
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
export function isCompleteCodexQuotaRecoverySnapshot(
|
|
107
107
|
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow"> | null,
|
|
108
|
-
plan?:
|
|
108
|
+
plan?: unknown,
|
|
109
109
|
): boolean {
|
|
110
110
|
if (!quota || isCodexQuotaExhausted(quota, plan)) return false;
|
|
111
111
|
// Recovery still fails closed on MISSING EVIDENCE — a credits-only or windowless payload
|
package/src/codex/routing.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
selectPriorityTier,
|
|
19
19
|
} from "./pool-rotation";
|
|
20
20
|
import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
|
|
21
|
+
import { isThirtyDayOnlyCodexPlan } from "./plan";
|
|
21
22
|
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountPlan } from "./main-account";
|
|
22
23
|
import { isSelectableCodexPoolAccount } from "./account-id";
|
|
23
24
|
import type { OcxConfig } from "../types";
|
|
@@ -314,10 +315,9 @@ function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void {
|
|
|
314
315
|
export function computeCodexUsageScore(quota: {
|
|
315
316
|
weeklyPercent?: number;
|
|
316
317
|
monthlyPercent?: number;
|
|
317
|
-
} | null, plan?:
|
|
318
|
+
} | null, plan?: unknown): number {
|
|
318
319
|
if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
|
|
319
|
-
|
|
320
|
-
if (normalizedPlan === "go" || normalizedPlan === "free") {
|
|
320
|
+
if (isThirtyDayOnlyCodexPlan(plan)) {
|
|
321
321
|
return typeof quota.monthlyPercent === "number" && Number.isFinite(quota.monthlyPercent)
|
|
322
322
|
? quota.monthlyPercent
|
|
323
323
|
: CODEX_UNKNOWN_USAGE_SCORE;
|