@bitkyc08/opencodex 2.38.0 → 2.39.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/bin/ocx.mjs +58 -6
- package/gui/dist/assets/index-D-lchsPw.js +112 -0
- package/gui/dist/assets/index-uvENYLin.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/baseten.svg +13 -0
- package/gui/dist/provider-icons/bizrouter.svg +41 -0
- package/gui/dist/provider-icons/cerebras.svg +26 -0
- package/gui/dist/provider-icons/deepinfra.svg +75 -0
- package/gui/dist/provider-icons/digitalocean.svg +10 -0
- package/gui/dist/provider-icons/featherless.svg +4 -0
- package/gui/dist/provider-icons/gajae-code.svg +410 -0
- package/gui/dist/provider-icons/hermes-agent.svg +207 -0
- package/gui/dist/provider-icons/hyperbolic.svg +18 -0
- package/gui/dist/provider-icons/kilo.svg +13 -0
- package/gui/dist/provider-icons/litellm.svg +1 -0
- package/gui/dist/provider-icons/minimax.svg +1 -0
- package/gui/dist/provider-icons/nanogpt.svg +74 -0
- package/gui/dist/provider-icons/nebius.svg +1 -0
- package/gui/dist/provider-icons/neuralwatt.svg +27 -0
- package/gui/dist/provider-icons/nous.svg +149 -0
- package/gui/dist/provider-icons/novita.svg +32 -0
- package/gui/dist/provider-icons/orcarouter.svg +175 -0
- package/gui/dist/provider-icons/parallel.svg +13 -0
- package/gui/dist/provider-icons/sambanova.svg +276 -0
- package/gui/dist/provider-icons/scaleway.svg +11 -0
- package/gui/dist/provider-icons/siliconflow.svg +18 -0
- package/gui/dist/provider-icons/synthetic.svg +12 -0
- package/gui/dist/provider-icons/together.svg +18 -0
- package/gui/dist/provider-icons/umans.svg +30 -0
- package/gui/dist/provider-icons/venice.svg +165 -0
- package/gui/dist/provider-icons/vultr.svg +15 -0
- package/gui/dist/provider-icons/zai.svg +218 -0
- package/gui/dist/provider-icons/zenmux.svg +1 -0
- package/package.json +1 -1
- package/src/adapters/cursor/live-models.ts +1 -0
- package/src/adapters/openai-responses.ts +72 -10
- package/src/bridge.ts +15 -15
- package/src/cli/dispatch.ts +54 -18
- package/src/cli/index.ts +420 -30
- package/src/cli/integrations.ts +24 -2
- package/src/cli/uninstall-plan.ts +86 -0
- package/src/codex/account-store.ts +121 -8
- package/src/codex/auth-api.ts +202 -33
- package/src/codex/catalog/provider-fetch.ts +76 -9
- package/src/codex/history-job.ts +10 -0
- package/src/codex/history-manifest.ts +35 -2
- package/src/codex/history-provider.ts +196 -19
- package/src/codex/history-worker.ts +3 -0
- package/src/codex/quota-401-recovery.ts +190 -0
- package/src/codex/quota-recovery-timing.ts +28 -0
- package/src/codex/quota.ts +6 -0
- package/src/codex/routing.ts +68 -16
- package/src/codex/subagent-model-fallback.ts +4 -1
- package/src/config/pending-teardown-names.d.mts +8 -0
- package/src/config/pending-teardown-names.mjs +69 -0
- package/src/config/pending-teardown.ts +286 -0
- package/src/generated/compatibility-version.json +84 -32
- package/src/integrations/journal.ts +12 -1
- package/src/integrations/writer.ts +82 -7
- package/src/lib/process-control.ts +30 -5
- package/src/lib/state-store-registrations.ts +8 -0
- package/src/oauth/index.ts +220 -13
- package/src/oauth/store.ts +220 -18
- package/src/responses/spill-store.ts +20 -0
- package/src/responses/state.ts +288 -2
- package/src/server/management/integration-routes.ts +36 -5
- package/src/server/management-api.ts +66 -12
- package/src/server/responses/encrypted-payload.ts +18 -1
- package/src/server/stop-teardown.ts +84 -0
- package/src/service.ts +155 -20
- package/src/update/index.ts +36 -4
- package/src/update/proxy-liveness-probe.d.mts +6 -0
- package/src/update/proxy-liveness-probe.mjs +84 -0
- package/src/update/stop-contract.d.mts +2 -0
- package/src/update/stop-contract.mjs +15 -0
- package/src/update/stop-decision.d.mts +10 -0
- package/src/update/stop-decision.mjs +34 -0
- package/src/vision/eligibility.ts +19 -1
- package/src/vision/index.ts +4 -18
- package/gui/dist/assets/index-C14iCj_Q.js +0 -112
- package/gui/dist/assets/index-D7PIz7_g.css +0 -1
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import type { OcxConfig, OcxProviderConfig } from "../../types";
|
|
31
31
|
import { modelInList } from "../../types";
|
|
32
32
|
import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort";
|
|
33
|
+
import { isModelVisionSidecarConsumer } from "../../vision/eligibility";
|
|
33
34
|
import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata";
|
|
34
35
|
import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive";
|
|
35
36
|
import {
|
|
@@ -670,11 +671,14 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig,
|
|
|
670
671
|
const configuredMaxInput = configuredMaxInputTokens(prov, model.id);
|
|
671
672
|
const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id);
|
|
672
673
|
let inputModalities = configuredInputModalities(prov, model.id);
|
|
673
|
-
//
|
|
674
|
-
//
|
|
674
|
+
// The shared vision-sidecar consumer predicate keeps catalog advertisement and request-time
|
|
675
|
+
// planning aligned. The catalog must still advertise image input — the Codex app
|
|
675
676
|
// gates attachments client-side on input_modalities, and a text-only entry would block images
|
|
676
|
-
// before the sidecar ever runs ("This model does not support image inputs").
|
|
677
|
-
|
|
677
|
+
// before the sidecar ever runs ("This model does not support image inputs"). Discovery-derived
|
|
678
|
+
// text-only rows stay untouched: the runtime predicate only reads these two config sources, so
|
|
679
|
+
// it would not convert those.
|
|
680
|
+
const sidecarCovered = isModelVisionSidecarConsumer(prov, model.id);
|
|
681
|
+
if (sidecarCovered) {
|
|
678
682
|
const base = inputModalities ?? model.inputModalities ?? ["text"];
|
|
679
683
|
inputModalities = base.includes("image") ? [...base] : [...base, "image"];
|
|
680
684
|
}
|
|
@@ -936,9 +940,70 @@ export function resolveComboCatalogMember(
|
|
|
936
940
|
};
|
|
937
941
|
}
|
|
938
942
|
|
|
943
|
+
const DATED_VARIANT_YYYYMMDD = /^(\d{4})(\d{2})(\d{2})$/;
|
|
944
|
+
const DATED_VARIANT_YYMMDD = /^(2\d)(\d{2})(\d{2})$/;
|
|
945
|
+
const DATED_VARIANT_MMDD_OR_YYMM = /^(\d{2})(\d{2})$/;
|
|
946
|
+
|
|
947
|
+
/** Whether a Gregorian year contains February 29th. */
|
|
948
|
+
function isLeapYear(year: number): boolean {
|
|
949
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Whether a month/day pair exists in the given year. Without a year, February 29th is
|
|
954
|
+
* accepted because it occurs in at least one calendar year.
|
|
955
|
+
*/
|
|
956
|
+
function isValidCalendarDate(year: number | undefined, month: number, day: number): boolean {
|
|
957
|
+
if (year !== undefined && (year < 1 || year > 9999)) return false;
|
|
958
|
+
if (month < 1 || month > 12 || day < 1) return false;
|
|
959
|
+
const daysInMonth = [
|
|
960
|
+
31, year === undefined || isLeapYear(year) ? 29 : 28, 31, 30, 31, 30,
|
|
961
|
+
31, 31, 30, 31, 30, 31,
|
|
962
|
+
];
|
|
963
|
+
return day <= daysInMonth[month - 1]!;
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* Release-date suffixes providers actually publish: `YYYYMMDD` (`-20251001`), `YYMMDD`
|
|
968
|
+
* (`-260806`), `MMDD` (`-0813`) and `YYMM` (`-2512`). A `\d{8}`-only rule matched none of
|
|
969
|
+
* the dated ids on a real multi-provider install, so DeepSeek, Kimi, Mistral, Qwen and
|
|
970
|
+
* Solar aliases all fell through to `droppedConfiguredIds` (#3024).
|
|
971
|
+
*
|
|
972
|
+
* Calendar validation rejects impossible month-end and leap-day values as well as ordinary
|
|
973
|
+
* numeric suffixes such as `-2048`, `-4096` and `-8192`. `-1024` is the one irreducible
|
|
974
|
+
* collision — it is a valid `MMDD` (October 24th) — so it reads as dated. That is a known,
|
|
975
|
+
* accepted cost; the test table pins it so it cannot become a surprise later.
|
|
976
|
+
*
|
|
977
|
+
* Hyphenated ISO suffixes (`-2024-08-06`, `-05-06`) are deliberately out of scope: a
|
|
978
|
+
* hyphenated suffix is ambiguous against ordinary name segments and needs its own call.
|
|
979
|
+
*/
|
|
980
|
+
function isDatedVariantSuffix(suffix: string): boolean {
|
|
981
|
+
const yyyyMmDd = DATED_VARIANT_YYYYMMDD.exec(suffix);
|
|
982
|
+
if (yyyyMmDd) {
|
|
983
|
+
return isValidCalendarDate(
|
|
984
|
+
Number(yyyyMmDd[1]), Number(yyyyMmDd[2]), Number(yyyyMmDd[3]),
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const yyMmDd = DATED_VARIANT_YYMMDD.exec(suffix);
|
|
989
|
+
if (yyMmDd) {
|
|
990
|
+
return isValidCalendarDate(
|
|
991
|
+
2000 + Number(yyMmDd[1]), Number(yyMmDd[2]), Number(yyMmDd[3]),
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const mmDdOrYyMm = DATED_VARIANT_MMDD_OR_YYMM.exec(suffix);
|
|
996
|
+
if (!mmDdOrYyMm) return false;
|
|
997
|
+
const first = Number(mmDdOrYyMm[1]);
|
|
998
|
+
const second = Number(mmDdOrYyMm[2]);
|
|
999
|
+
return isValidCalendarDate(undefined, first, second)
|
|
1000
|
+
|| (first >= 20 && first <= 29 && second >= 1 && second <= 12);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/** Whether `liveId` is a supported dated release of the configured base id. */
|
|
939
1004
|
export function isDatedVariantId(liveId: string, configuredId: string): boolean {
|
|
940
1005
|
if (!liveId.startsWith(`${configuredId}-`)) return false;
|
|
941
|
-
return
|
|
1006
|
+
return isDatedVariantSuffix(liveId.slice(configuredId.length + 1));
|
|
942
1007
|
}
|
|
943
1008
|
|
|
944
1009
|
export const lastDropWarnSignature = new Map<string, string>();
|
|
@@ -2113,9 +2178,10 @@ async function gatherRoutedModelsUncached(
|
|
|
2113
2178
|
...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}),
|
|
2114
2179
|
...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}),
|
|
2115
2180
|
} : base;
|
|
2116
|
-
// Vision-sidecar coverage
|
|
2117
|
-
// noVisionModels, advertise image input so the
|
|
2118
|
-
// (#349/#344). Deliberately NOT the full
|
|
2181
|
+
// Vision-sidecar coverage only: when the enriched provider's shared predicate matches
|
|
2182
|
+
// noVisionModels or text-without-image modelInputModalities, advertise image input so the
|
|
2183
|
+
// Codex app lets images reach the sidecar (#349/#344). Deliberately NOT the full
|
|
2184
|
+
// applyProviderConfigHints pass — custom rows are a
|
|
2119
2185
|
// user override, so their explicit contextWindow / inputModalities / reasoning fields must be
|
|
2120
2186
|
// preserved verbatim (the hint pass would cap context and overwrite modalities from registry).
|
|
2121
2187
|
const mergedContext = typeof merged.contextWindow === "number" && merged.contextWindow > 0
|
|
@@ -2141,7 +2207,8 @@ async function gatherRoutedModelsUncached(
|
|
|
2141
2207
|
}
|
|
2142
2208
|
: mergedWithHardBounds;
|
|
2143
2209
|
const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider;
|
|
2144
|
-
|
|
2210
|
+
// Reuse the request-time consumer predicate so custom rows cannot drift from catalog hints.
|
|
2211
|
+
if (enrichedProvider && isModelVisionSidecarConsumer(enrichedProvider, mergedWithAutoCompact.id)) {
|
|
2145
2212
|
const current = mergedWithAutoCompact.inputModalities ?? ["text"];
|
|
2146
2213
|
if (!current.includes("image")) {
|
|
2147
2214
|
return { ...mergedWithAutoCompact, inputModalities: [...current, "image"] };
|
package/src/codex/history-job.ts
CHANGED
|
@@ -110,6 +110,8 @@ export type CodexHistoryJobOutcome =
|
|
|
110
110
|
| { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
|
|
111
111
|
| { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout";
|
|
112
112
|
readonly message: string; readonly historyFailureReason?: CodexHistoryFailureReason;
|
|
113
|
+
/** Specific integrity condition when `historyFailureReason` is `"integrity"`. */
|
|
114
|
+
readonly historyIntegrityCode?: string;
|
|
113
115
|
readonly rows?: number; readonly files?: number };
|
|
114
116
|
|
|
115
117
|
/**
|
|
@@ -258,6 +260,13 @@ export function describeHistoryJobFailure(
|
|
|
258
260
|
return "permission was denied while writing Codex history; this is not a Codex app lock. Run 'ocx doctor'.";
|
|
259
261
|
}
|
|
260
262
|
if (outcome.historyFailureReason === "integrity") {
|
|
263
|
+
// Not every integrity stop is a retry. An ambiguous reroute means two histories
|
|
264
|
+
// produced the same row and no durable fact separates them, so retrying reaches the
|
|
265
|
+
// same refusal - the manifest needs a person, and saying "run doctor" sends them the
|
|
266
|
+
// wrong way.
|
|
267
|
+
if (outcome.historyIntegrityCode === "history_apply_ambiguous_reroute") {
|
|
268
|
+
return "a Codex history entry could not be re-routed because its manifest cannot prove whether an earlier relabel was undone; nothing was changed and the manifest was kept. Resolve it manually rather than retrying.";
|
|
269
|
+
}
|
|
261
270
|
return partiallyChanged
|
|
262
271
|
? "the history backup or its restore target changed after a partial restore; the manifest was retained for review and safe retry. Run 'ocx doctor'."
|
|
263
272
|
: "the history backup or its restore target failed integrity checks; no unverified provider metadata was applied. Run 'ocx doctor'.";
|
|
@@ -298,6 +307,7 @@ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutco
|
|
|
298
307
|
reason: "worker-error",
|
|
299
308
|
message: redactWorkerMessage(result.message),
|
|
300
309
|
...(result.reason ? { historyFailureReason: result.reason } : {}),
|
|
310
|
+
...(result.integrityCode ? { historyIntegrityCode: result.integrityCode } : {}),
|
|
301
311
|
...(result.rows !== undefined && result.files !== undefined
|
|
302
312
|
? { rows: result.rows, files: result.files }
|
|
303
313
|
: {}),
|
|
@@ -10,10 +10,37 @@ export interface CodexHistoryBackupEntry {
|
|
|
10
10
|
modelProvider: string;
|
|
11
11
|
source: string;
|
|
12
12
|
hasUserEvent: 0 | 1;
|
|
13
|
+
/**
|
|
14
|
+
* Whether the row had a non-empty `first_user_message` when the snapshot was taken.
|
|
15
|
+
*
|
|
16
|
+
* Routing derives the post-image `has_user_event` from the message AT SNAPSHOT TIME
|
|
17
|
+
* (`history-provider.ts` `routeOpenai`), so a restore that recomputes it from the
|
|
18
|
+
* message as it is NOW will mistake the user's first message for OpenCodex's own write
|
|
19
|
+
* and erase it. Only the emptiness is recorded, never the text: this manifest is a file
|
|
20
|
+
* on disk and the message is user content.
|
|
21
|
+
*
|
|
22
|
+
* Optional because manifests written before this field exists cannot be given one. An
|
|
23
|
+
* entry without it falls back to the current-row reading, which is exactly as imprecise
|
|
24
|
+
* as the behaviour it replaces and no worse.
|
|
25
|
+
*/
|
|
26
|
+
hadFirstUserMessage?: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Whether OpenCodex's routing relabel is known to have landed for this entry.
|
|
29
|
+
*
|
|
30
|
+
* `pending` is written before the routing write and resolved after it, so a crash
|
|
31
|
+
* between the two leaves an honest "unknown" rather than a confident wrong answer. The
|
|
32
|
+
* observed row resolves it: the recorded original means the write did not land, the
|
|
33
|
+
* expected post-image means it did.
|
|
34
|
+
*
|
|
35
|
+
* Absent on entries written before the field existed. Those refuse only in the one
|
|
36
|
+
* genuinely undecidable case — original tuple with `has_user_event` moved 0 to 1 —
|
|
37
|
+
* which `dev` already refuses today.
|
|
38
|
+
*/
|
|
39
|
+
relabel?: "pending" | "committed" | "none";
|
|
13
40
|
}
|
|
14
41
|
|
|
15
42
|
export interface CodexHistoryBackupManifest {
|
|
16
|
-
version: 1;
|
|
43
|
+
version: 1 | 2;
|
|
17
44
|
stateDbPath: string;
|
|
18
45
|
entries: Record<string, CodexHistoryBackupEntry>;
|
|
19
46
|
}
|
|
@@ -76,7 +103,7 @@ export function validateCodexHistoryBackupManifest(
|
|
|
76
103
|
expectedStateDbPath: string,
|
|
77
104
|
): CodexHistoryManifestValidation {
|
|
78
105
|
if (!isRecord(raw)
|
|
79
|
-
|| raw.version !== 1
|
|
106
|
+
|| (raw.version !== 1 && raw.version !== 2)
|
|
80
107
|
|| typeof raw.stateDbPath !== "string"
|
|
81
108
|
|| !raw.stateDbPath.trim()
|
|
82
109
|
|| !isAbsolute(raw.stateDbPath)
|
|
@@ -103,6 +130,12 @@ export function validateCodexHistoryBackupManifest(
|
|
|
103
130
|
|| typeof value.hasUserEvent !== "number"
|
|
104
131
|
|| !Number.isSafeInteger(value.hasUserEvent)
|
|
105
132
|
|| (value.hasUserEvent !== 0 && value.hasUserEvent !== 1)
|
|
133
|
+
// Optional, but not unvalidated: a truthy `hadFirstUserMessage: "false"` would select
|
|
134
|
+
// the wrong restore verdict, and an unrecognized `relabel` would be read as a state
|
|
135
|
+
// the classifier does not have.
|
|
136
|
+
|| (value.hadFirstUserMessage !== undefined && typeof value.hadFirstUserMessage !== "boolean")
|
|
137
|
+
|| (value.relabel !== undefined
|
|
138
|
+
&& value.relabel !== "pending" && value.relabel !== "committed" && value.relabel !== "none")
|
|
106
139
|
|| !hasAllowedProvenance(value)) {
|
|
107
140
|
return { ok: false, reason: "schema", scope: "entry-provenance" };
|
|
108
141
|
}
|
|
@@ -227,6 +227,11 @@ function integrityFailureResult(error: CodexHistoryIntegrityError): CodexHistory
|
|
|
227
227
|
files: error.progress.files,
|
|
228
228
|
failed: true,
|
|
229
229
|
failureReason: "integrity",
|
|
230
|
+
// The specific code, so an operator sees WHICH integrity condition stopped the
|
|
231
|
+
// transition rather than a generic "run doctor". `history_apply_ambiguous_reroute` in
|
|
232
|
+
// particular needs manual resolution: the manifest is intact and the safe move is to
|
|
233
|
+
// inspect it, not to retry.
|
|
234
|
+
integrityCode: error.message,
|
|
230
235
|
};
|
|
231
236
|
}
|
|
232
237
|
|
|
@@ -239,6 +244,15 @@ export interface CodexHistorySyncResult {
|
|
|
239
244
|
failed?: true;
|
|
240
245
|
/** Why the retry budget was exhausted when `failed` is set. */
|
|
241
246
|
failureReason?: CodexHistoryFailureReason;
|
|
247
|
+
/**
|
|
248
|
+
* The specific integrity condition, when `failureReason` is `"integrity"`.
|
|
249
|
+
*
|
|
250
|
+
* `failureReason` alone tells an operator only that something was inconsistent, which
|
|
251
|
+
* reads as "retry or run doctor". Some of these are not retryable —
|
|
252
|
+
* `history_apply_ambiguous_reroute` means two histories produced the same row and the
|
|
253
|
+
* manifest needs a human — so the code travels with the result.
|
|
254
|
+
*/
|
|
255
|
+
integrityCode?: string;
|
|
242
256
|
}
|
|
243
257
|
|
|
244
258
|
interface ThreadRow {
|
|
@@ -361,7 +375,10 @@ function readBackupStrict(path: string, stateDbPath: string): StrictBackupRead {
|
|
|
361
375
|
return {
|
|
362
376
|
kind: "known",
|
|
363
377
|
present: false,
|
|
364
|
-
|
|
378
|
+
// New manifests carry the snapshot and relabel fields, so they are v2. v1 stays
|
|
379
|
+
// readable: an entry written before those fields existed falls back to the
|
|
380
|
+
// current-row reading, which is the behaviour it was written under.
|
|
381
|
+
manifest: { version: 2, stateDbPath, entries: {} },
|
|
365
382
|
fingerprint: "absent",
|
|
366
383
|
};
|
|
367
384
|
}
|
|
@@ -462,14 +479,80 @@ function writeBackup(path: string, manifest: CodexHistoryBackupManifest, stateDb
|
|
|
462
479
|
atomicWriteFile(path, JSON.stringify({ ...manifest, stateDbPath: manifest.stateDbPath ?? stateDbPath }, null, 2) + "\n");
|
|
463
480
|
}
|
|
464
481
|
|
|
465
|
-
function rememberOriginal(manifest: CodexHistoryBackupManifest, row:
|
|
466
|
-
|
|
482
|
+
function rememberOriginal(manifest: CodexHistoryBackupManifest, row: ApplyRowSnapshot): void {
|
|
483
|
+
const existing = manifest.entries[row.id];
|
|
484
|
+
if (existing) {
|
|
485
|
+
// A surviving entry means a previous route/restore cycle did not consume its manifest.
|
|
486
|
+
// Its `relabel` describes THAT attempt, and this one has not written yet, so a stale
|
|
487
|
+
// `committed` would let a later restore treat the marker as proof that OpenCodex
|
|
488
|
+
// authored an event flag the user had since set.
|
|
489
|
+
//
|
|
490
|
+
// The provenance tuple stays — it is the ORIGINAL, and a routed row must never
|
|
491
|
+
// overwrite it. But `hadFirstUserMessage` is not provenance: it describes the input to
|
|
492
|
+
// one routing write, and this attempt has its own. Leaving the previous attempt's value
|
|
493
|
+
// makes the new routed row match the expected post-image and erases activity that
|
|
494
|
+
// arrived in between. Re-record it, and promote the manifest so the field is covered by
|
|
495
|
+
// the schema that declares it.
|
|
496
|
+
const previousRelabel = existing.relabel;
|
|
497
|
+
const previousHadFirstUserMessage = existing.hadFirstUserMessage;
|
|
498
|
+
existing.relabel = "pending";
|
|
499
|
+
existing.hadFirstUserMessage = hasFirstUserMessage(row.first_user_message);
|
|
500
|
+
// `hasUserEvent` is the value restore returns to, and the previous attempt's can be two
|
|
501
|
+
// events stale — a restore that already landed, plus whatever the user did afterwards.
|
|
502
|
+
// Refreshing it needs proof that the previous relabel was UNDONE, because the original
|
|
503
|
+
// tuple alone is not proof: route-then-legacy-recovery lands on that same tuple, so
|
|
504
|
+
// refreshing there would adopt OpenCodex's own write as the user's baseline.
|
|
505
|
+
//
|
|
506
|
+
const atOriginalTuple = row.model_provider === existing.modelProvider
|
|
507
|
+
&& row.source === existing.source;
|
|
508
|
+
const observedEvent: 0 | 1 = Number(row.has_user_event) === 1 ? 1 : 0;
|
|
509
|
+
if (observedEvent !== existing.hasUserEvent) {
|
|
510
|
+
// The row's event disagrees with the recorded baseline. Whether that is decidable is
|
|
511
|
+
// a function of DIRECTION and ORIGIN, not of one flag:
|
|
512
|
+
//
|
|
513
|
+
// - `1 -> 0` is always foreign. Nothing in this system clears the flag: routing only
|
|
514
|
+
// ever sets it, the user only ever sets it, and legacy recovery sets it to 1. A
|
|
515
|
+
// baseline that moved down is a decision this manifest does not own.
|
|
516
|
+
// - `0 -> 1` on an exec-origin entry is the user's. `routeExec` moves `source` to
|
|
517
|
+
// `cli` and legacy recovery does not move it back, so an exec-origin row wearing
|
|
518
|
+
// its original tuple was never routed away and back.
|
|
519
|
+
// - `0 -> 1` with `relabel: "none"` is the user's: a restore landed and undid the
|
|
520
|
+
// previous relabel, so the observed row is the honest pre-route state.
|
|
521
|
+
// - `0 -> 1` where the previous route would have written 0 is the user's, because
|
|
522
|
+
// OpenCodex could not have authored a 1 it never writes.
|
|
523
|
+
// - `0 -> 1` where the previous route WOULD have written 1, or where a legacy entry
|
|
524
|
+
// records nothing about it, is undecidable: routing-never-landed-plus-activity and
|
|
525
|
+
// routing-landed-then-legacy-recovery produce the same row. Refuse rather than pick.
|
|
526
|
+
const reverseDrift = observedEvent === 0;
|
|
527
|
+
const execOrigin = existing.modelProvider !== "openai";
|
|
528
|
+
const priorRouteWroteZero = previousHadFirstUserMessage === false;
|
|
529
|
+
const decidable = !reverseDrift
|
|
530
|
+
&& atOriginalTuple
|
|
531
|
+
&& (execOrigin || previousRelabel === "none" || priorRouteWroteZero);
|
|
532
|
+
if (!decidable) {
|
|
533
|
+
throw new CodexHistoryIntegrityError("history_apply_ambiguous_reroute");
|
|
534
|
+
}
|
|
535
|
+
existing.hasUserEvent = observedEvent;
|
|
536
|
+
}
|
|
537
|
+
manifest.version = 2;
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
manifest.version = 2;
|
|
467
541
|
manifest.entries[row.id] = {
|
|
468
542
|
id: row.id,
|
|
469
543
|
rolloutPath: row.rollout_path,
|
|
470
544
|
modelProvider: row.model_provider,
|
|
471
545
|
source: row.source,
|
|
472
546
|
hasUserEvent: Number(row.has_user_event) === 1 ? 1 : 0,
|
|
547
|
+
// Emptiness only, never the text: the manifest is a file on disk and the message is
|
|
548
|
+
// user content. Routing derives the post-image event flag from the message as it was
|
|
549
|
+
// HERE, so a restore that re-reads the current message would mistake later user
|
|
550
|
+
// activity for OpenCodex's own write.
|
|
551
|
+
hadFirstUserMessage: hasFirstUserMessage(row.first_user_message),
|
|
552
|
+
// The routing write has not happened yet. Resolved to "committed" after it lands, or
|
|
553
|
+
// left pending if the process dies between the two - in which case the observed row
|
|
554
|
+
// is what decides.
|
|
555
|
+
relabel: "pending",
|
|
473
556
|
};
|
|
474
557
|
}
|
|
475
558
|
|
|
@@ -486,7 +569,13 @@ function rowMatchesRestoreTuple(
|
|
|
486
569
|
|
|
487
570
|
function rowMatchesExpectedPostImage(row: RestoreRowSnapshot, entry: CodexHistoryBackupEntry): boolean {
|
|
488
571
|
if (entry.modelProvider === "openai") {
|
|
489
|
-
|
|
572
|
+
// Routing derived this from the message AT SNAPSHOT TIME (`routeOpenai`), so read the
|
|
573
|
+
// recorded flag when the manifest has one. Recomputing from the row's CURRENT message
|
|
574
|
+
// mistakes a first message the user sent after routing for OpenCodex's own write, and
|
|
575
|
+
// restore then erases it. Manifests written before the flag existed fall back to the
|
|
576
|
+
// current reading, which is exactly the behaviour this replaces and no worse.
|
|
577
|
+
const hadMessage = entry.hadFirstUserMessage ?? hasFirstUserMessage(row.first_user_message);
|
|
578
|
+
const postHasUserEvent = hadMessage ? 1 : entry.hasUserEvent;
|
|
490
579
|
return rowMatchesRestoreTuple(row, "opencodex", entry.source, postHasUserEvent);
|
|
491
580
|
}
|
|
492
581
|
return hasFirstUserMessage(row.first_user_message)
|
|
@@ -499,6 +588,60 @@ function rowMatchesExpectedPostImage(row: RestoreRowSnapshot, entry: CodexHistor
|
|
|
499
588
|
);
|
|
500
589
|
}
|
|
501
590
|
|
|
591
|
+
/**
|
|
592
|
+
* What `has_user_event` should read after restore, or `null` when the row is not one this
|
|
593
|
+
* manifest owns.
|
|
594
|
+
*
|
|
595
|
+
* The field has two writers, so a final state cannot establish authorship on its own. Four
|
|
596
|
+
* shapes cover every row reachable in practice, and the tuple the row wears says which:
|
|
597
|
+
*
|
|
598
|
+
* - **A** exactly the recorded original: untouched, or already restored.
|
|
599
|
+
* - **B** the expected post-image: OpenCodex wrote it, so the recorded value is authoritative.
|
|
600
|
+
* - **C** the original tuple with the flag moved 0 to 1: either Codex-side user activity, or
|
|
601
|
+
* OpenCodex routing that legacy recovery has since pulled back to the original provider.
|
|
602
|
+
* - **D** the post-image tuple with the flag moved 0 to 1: a routed row the user then touched.
|
|
603
|
+
* No provenance needed - a row wearing the routed tuple was written by OpenCodex, so drift
|
|
604
|
+
* on top of it can only be what followed.
|
|
605
|
+
*
|
|
606
|
+
* Only C is ambiguous, and only when the route's own expected event was 1: then "routing
|
|
607
|
+
* never landed and the user typed" and "routing landed and legacy recovery pulled it back"
|
|
608
|
+
* produce an identical row, and nothing durable separates them. That one cell refuses. A
|
|
609
|
+
* guess there either erases real activity or fabricates it.
|
|
610
|
+
*/
|
|
611
|
+
export function restoredUserEventFor(row: RestoreRowSnapshot, entry: CodexHistoryBackupEntry): 0 | 1 | null {
|
|
612
|
+
if (rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent)) {
|
|
613
|
+
return entry.hasUserEvent; // A
|
|
614
|
+
}
|
|
615
|
+
if (rowMatchesExpectedPostImage(row, entry)) return entry.hasUserEvent; // B
|
|
616
|
+
|
|
617
|
+
const drifted = Number(row.has_user_event) === 1 && entry.hasUserEvent === 0;
|
|
618
|
+
if (!drifted) return null;
|
|
619
|
+
|
|
620
|
+
const routeExpectedEvent = entry.hadFirstUserMessage ?? hasFirstUserMessage(row.first_user_message) ? 1 : 0;
|
|
621
|
+
|
|
622
|
+
// D: wearing the routed tuple, so the 1 arrived after OpenCodex wrote the row. The tuple
|
|
623
|
+
// is the one routing actually produces — `routeOpenai` keeps the source, `routeExec`
|
|
624
|
+
// moves exec to cli — so D and C cannot both match rather than merely being ordered.
|
|
625
|
+
const routedSource = entry.modelProvider === "openai" ? entry.source : "cli";
|
|
626
|
+
if (rowMatchesRestoreTuple(row, "opencodex", routedSource, 1)) return 1;
|
|
627
|
+
|
|
628
|
+
// C: wearing the original tuple.
|
|
629
|
+
if (rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, 1)) {
|
|
630
|
+
// An exec-origin entry cannot reach here by legacy recovery: routeExec moves source to
|
|
631
|
+
// cli and recovery does not move it back, so the original tuple is unreachable that way.
|
|
632
|
+
if (entry.modelProvider !== "openai") return 1;
|
|
633
|
+
if (entry.relabel === "none") return 1;
|
|
634
|
+
if (entry.relabel === "committed") {
|
|
635
|
+
// OpenCodex authored the 1 only if its own routing write would have produced one.
|
|
636
|
+
return routeExpectedEvent === 1 ? 0 : 1;
|
|
637
|
+
}
|
|
638
|
+
if (entry.relabel === undefined) return null; // legacy manifest: the pre-existing refusal
|
|
639
|
+
// pending: two histories reach this exact row and nothing durable tells them apart.
|
|
640
|
+
return routeExpectedEvent === 1 ? null : 1;
|
|
641
|
+
}
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
|
|
502
645
|
interface RestoreRolloutSnapshot {
|
|
503
646
|
readonly identity: string;
|
|
504
647
|
readonly latestProvider: string;
|
|
@@ -549,7 +692,7 @@ function snapshotRolloutForRestore(entry: CodexHistoryBackupEntry): RestoreRollo
|
|
|
549
692
|
if (identityBefore === null) {
|
|
550
693
|
throw new CodexHistoryIntegrityError("history_backup_rollout_unrestorable");
|
|
551
694
|
}
|
|
552
|
-
const latest =
|
|
695
|
+
const latest = readLatestSessionMetaForId(entry.rolloutPath, entry.id);
|
|
553
696
|
if (!latest
|
|
554
697
|
|| (!rolloutMatchesRestoreTuple(latest, entry, entry.modelProvider, entry.source)
|
|
555
698
|
&& !rolloutMatchesExpectedPostImage(latest, entry))) {
|
|
@@ -608,8 +751,7 @@ function preflightRestoreRows(
|
|
|
608
751
|
if (!row || typeof row.rollout_path !== "string" || !sameCodexHistoryPath(row.rollout_path, entry.rolloutPath)) {
|
|
609
752
|
throw new CodexHistoryIntegrityError("history_backup_target_mismatch");
|
|
610
753
|
}
|
|
611
|
-
if (
|
|
612
|
-
&& !rowMatchesExpectedPostImage(row, entry)) {
|
|
754
|
+
if (restoredUserEventFor(row, entry) === null) {
|
|
613
755
|
throw new CodexHistoryIntegrityError("history_backup_postimage_mismatch");
|
|
614
756
|
}
|
|
615
757
|
snapshots.set(entry.id, row);
|
|
@@ -625,10 +767,11 @@ function assertRestoreReadback(
|
|
|
625
767
|
const row = getCurrent(entry.id);
|
|
626
768
|
if (!row
|
|
627
769
|
|| !sameCodexHistoryPath(row.rollout_path, entry.rolloutPath)
|
|
628
|
-
|| !rowMatchesRestoreTuple(row, entry.modelProvider, entry.source,
|
|
770
|
+
|| !rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, Number(row.has_user_event) === 1 ? 1 : 0)
|
|
771
|
+
|| restoredUserEventFor(row, entry) === null) {
|
|
629
772
|
throw new CodexHistoryIntegrityError("history_backup_database_readback_mismatch");
|
|
630
773
|
}
|
|
631
|
-
const latest =
|
|
774
|
+
const latest = readLatestSessionMetaForId(entry.rolloutPath, entry.id);
|
|
632
775
|
if (inspectFirstLineProvider(entry.rolloutPath, entry.id, entry.modelProvider) !== "current"
|
|
633
776
|
|| !latest
|
|
634
777
|
|| !rolloutMatchesRestoreTuple(latest, entry, entry.modelProvider, entry.source)) {
|
|
@@ -665,6 +808,19 @@ export function readLatestSessionMeta(path: string): ParsedSessionMeta | null {
|
|
|
665
808
|
return readLatestSessionMetaFromText(raw);
|
|
666
809
|
}
|
|
667
810
|
|
|
811
|
+
/**
|
|
812
|
+
* Same fold as {@link readLatestSessionMeta}, restricted to this thread's own metadata.
|
|
813
|
+
*
|
|
814
|
+
* A forked/branched rollout appends the SOURCE thread's `session_meta` after its own, and the
|
|
815
|
+
* app discards any record whose payload id is not the canonical thread id (codex-rs
|
|
816
|
+
* `apply_session_meta_from_item`). Reading the last line regardless of id therefore answers
|
|
817
|
+
* with a foreign thread's provider, which is neither what the app honors nor what we may patch.
|
|
818
|
+
*/
|
|
819
|
+
function readLatestSessionMetaForId(path: string, expectedId: string): ParsedSessionMeta | null {
|
|
820
|
+
const raw = readFileSync(path, "utf8");
|
|
821
|
+
return readLatestSessionMetaForIdFromText(raw, expectedId);
|
|
822
|
+
}
|
|
823
|
+
|
|
668
824
|
function readLatestSessionMetaFromText(raw: string): ParsedSessionMeta | null {
|
|
669
825
|
const lines = raw.split("\n");
|
|
670
826
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
@@ -875,18 +1031,15 @@ function updateSessionMeta(
|
|
|
875
1031
|
return { changed: false, durableProvider: false, conflict: true };
|
|
876
1032
|
}
|
|
877
1033
|
|
|
878
|
-
|
|
1034
|
+
// Resolve by id. The app ignores `session_meta` lines whose payload id != the canonical
|
|
1035
|
+
// thread id (codex-rs `apply_session_meta_from_item`), and a forked rollout trails the source
|
|
1036
|
+
// session's metadata, so the last line is not necessarily this thread's. Patching that record
|
|
1037
|
+
// would clone the wrong thread's meta into a line the app discards; skipping the file entirely
|
|
1038
|
+
// left forked threads unroutable and, once routed, unrestorable.
|
|
1039
|
+
const latest = readLatestSessionMetaForId(path, expectedId);
|
|
879
1040
|
if (!latest) return { changed: false, durableProvider: false };
|
|
880
1041
|
const record = latest.record;
|
|
881
1042
|
|
|
882
|
-
// The app ignores `session_meta` lines whose payload id != the canonical thread id
|
|
883
|
-
// (codex-rs `apply_session_meta_from_item`). Forked rollouts can embed a source session's
|
|
884
|
-
// metadata, so an id-mismatched latest line means we'd be cloning the wrong thread's meta and
|
|
885
|
-
// appending a line the app would discard. Skip rather than write a no-op/misleading line.
|
|
886
|
-
const payloadId = record.payload.id;
|
|
887
|
-
if (typeof payloadId !== "string" || payloadId !== expectedId) {
|
|
888
|
-
return { changed: false, durableProvider: false };
|
|
889
|
-
}
|
|
890
1043
|
const latestProvider = typeof record.payload.model_provider === "string" && record.payload.model_provider
|
|
891
1044
|
? record.payload.model_provider
|
|
892
1045
|
: "openai";
|
|
@@ -1236,6 +1389,15 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP
|
|
|
1236
1389
|
throw error;
|
|
1237
1390
|
}
|
|
1238
1391
|
|
|
1392
|
+
// The routing writes landed. Resolve every pending marker and rewrite the manifest, so a
|
|
1393
|
+
// later restore knows the relabel is OpenCodex's rather than having to infer it. A crash
|
|
1394
|
+
// before this point leaves `pending`, which the observed row resolves at restore time.
|
|
1395
|
+
for (const row of [...openaiRows, ...execRows]) {
|
|
1396
|
+
const entry = manifest.entries[row.id];
|
|
1397
|
+
if (entry?.relabel === "pending") entry.relabel = "committed";
|
|
1398
|
+
}
|
|
1399
|
+
writeBackup(backupPath, manifest, stateDbPath);
|
|
1400
|
+
|
|
1239
1401
|
return { rows: openaiRows.length + execRows.length, files };
|
|
1240
1402
|
} finally {
|
|
1241
1403
|
db.close();
|
|
@@ -1277,10 +1439,13 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
|
|
|
1277
1439
|
for (const entry of entries) {
|
|
1278
1440
|
const before = snapshots.get(entry.id);
|
|
1279
1441
|
if (!before) throw new CodexHistoryIntegrityError("history_backup_snapshot_missing");
|
|
1442
|
+
// Codex-side activity that arrived after OpenCodex wrote the row is the user's, and
|
|
1443
|
+
// restoring the manifest's snapshot over it would erase it.
|
|
1444
|
+
const restoredEvent = restoredUserEventFor(before, entry) ?? entry.hasUserEvent;
|
|
1280
1445
|
const result = update.run(
|
|
1281
1446
|
entry.modelProvider,
|
|
1282
1447
|
entry.source,
|
|
1283
|
-
|
|
1448
|
+
restoredEvent,
|
|
1284
1449
|
entry.id,
|
|
1285
1450
|
before.rollout_path,
|
|
1286
1451
|
before.model_provider,
|
|
@@ -1345,6 +1510,18 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
|
|
|
1345
1510
|
if (error instanceof CodexHistoryIntegrityError) {
|
|
1346
1511
|
throw new CodexHistoryIntegrityError(error.message, { rows: entries.length, files });
|
|
1347
1512
|
}
|
|
1513
|
+
// The restore landed and its readback passed; only finalization failed, so the
|
|
1514
|
+
// manifest survives on disk. Record that its relabel is undone, or a later routing
|
|
1515
|
+
// attempt cannot tell this entry from one still mid-route and has to keep a baseline
|
|
1516
|
+
// that is now stale. Best-effort: a failure here leaves exactly the prior state.
|
|
1517
|
+
try {
|
|
1518
|
+
for (const entry of entries) {
|
|
1519
|
+
const stored = manifest.entries[entry.id];
|
|
1520
|
+
if (stored) stored.relabel = "none";
|
|
1521
|
+
}
|
|
1522
|
+
manifest.version = 2;
|
|
1523
|
+
writeBackup(backupPath, manifest, stateDbPath);
|
|
1524
|
+
} catch { /* the surviving manifest keeps its previous marker */ }
|
|
1348
1525
|
const failureReason = classifyRecoverableHistoryError(error);
|
|
1349
1526
|
if (failureReason) {
|
|
1350
1527
|
return {
|
|
@@ -75,6 +75,8 @@ export type HistoryWorkerResult =
|
|
|
75
75
|
readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
|
|
76
76
|
| { readonly type: "error"; readonly requestId: string; readonly jobId: string;
|
|
77
77
|
readonly message: string; readonly reason?: CodexHistoryFailureReason;
|
|
78
|
+
/** Specific integrity condition, so a non-retryable one can be named as such. */
|
|
79
|
+
readonly integrityCode?: string;
|
|
78
80
|
readonly rows?: number; readonly files?: number };
|
|
79
81
|
|
|
80
82
|
const OPERATIONS: ReadonlySet<string> = new Set<CodexHistoryWorkerOperation>([
|
|
@@ -181,6 +183,7 @@ export function runHistoryUnitUnderLock(
|
|
|
181
183
|
jobId,
|
|
182
184
|
message: "history_transition_failed",
|
|
183
185
|
...(result.failureReason ? { reason: result.failureReason } : {}),
|
|
186
|
+
...(result.integrityCode ? { integrityCode: result.integrityCode } : {}),
|
|
184
187
|
...(result.rows > 0 || result.files > 0 ? { rows: result.rows, files: result.files } : {}),
|
|
185
188
|
};
|
|
186
189
|
}
|