@iislee/opencodex 2.31.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/README.md +3 -3
- package/bin/ocx.mjs +99 -70
- package/gui/dist/assets/index-Be1sDpWw.css +1 -0
- package/gui/dist/assets/index-D4OKgUV4.js +114 -0
- package/gui/dist/index.html +2 -2
- package/package.json +3 -1
- package/src/adapters/anthropic.ts +104 -15
- package/src/adapters/base.ts +29 -1
- package/src/adapters/command-code.ts +42 -6
- package/src/adapters/cursor/call-id.ts +44 -0
- package/src/adapters/cursor/catalog.ts +541 -0
- package/src/adapters/cursor/checkpoint-store.ts +15 -10
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +69 -18
- package/src/adapters/cursor/effort-map.ts +79 -1
- package/src/adapters/cursor/envelope-echo.ts +290 -0
- package/src/adapters/cursor/live-models.ts +7 -2
- package/src/adapters/cursor/live-transport.ts +14 -0
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-desktop.ts +23 -0
- package/src/adapters/cursor/native-exec-fs.ts +2 -2
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +5 -2
- package/src/adapters/cursor/native-exec.ts +20 -0
- package/src/adapters/cursor/protobuf-request.ts +68 -14
- package/src/adapters/cursor/request-builder.ts +40 -10
- package/src/adapters/cursor/tool-definitions.ts +14 -4
- package/src/adapters/cursor/tool-result-normalize.ts +28 -5
- package/src/adapters/cursor/types.ts +20 -0
- package/src/adapters/cursor.ts +209 -38
- package/src/adapters/exec-tool-result-normalize.ts +99 -0
- package/src/adapters/google-antigravity-replay.ts +173 -25
- package/src/adapters/google-antigravity-wire.ts +5 -0
- package/src/adapters/google-errors.ts +44 -12
- package/src/adapters/google.ts +136 -24
- package/src/adapters/image.ts +1 -1
- package/src/adapters/kiro-constants.ts +27 -0
- package/src/adapters/kiro-tools.ts +55 -16
- package/src/adapters/kiro.ts +182 -23
- package/src/adapters/openai-chat.ts +300 -242
- package/src/adapters/openai-responses.ts +353 -27
- package/src/adapters/run-turn-queue.ts +36 -1
- package/src/adapters/tool-catalog-nudge.ts +4 -3
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/adapters/xai-web-search.ts +17 -16
- package/src/bridge.ts +90 -37
- package/src/chat/inbound.ts +14 -1
- package/src/chat/outbound.ts +15 -7
- package/src/claude/agents-inject.ts +8 -1
- package/src/claude/context-windows.ts +16 -9
- package/src/claude/outbound.ts +24 -11
- package/src/cli/access.ts +46 -3
- package/src/cli/account-api.ts +116 -19
- package/src/cli/account-extended.ts +262 -29
- package/src/cli/account-main.ts +12 -12
- package/src/cli/account.ts +69 -15
- package/src/cli/agent.ts +8 -1
- package/src/cli/alias.ts +66 -0
- 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/claude.ts +26 -1
- package/src/cli/dispatch.ts +256 -28
- package/src/cli/doctor.ts +126 -14
- package/src/cli/help.ts +17 -6
- package/src/cli/index.ts +39 -9
- package/src/cli/init.ts +1 -0
- package/src/cli/inspect.ts +230 -0
- package/src/cli/models-runtime.ts +95 -0
- package/src/cli/models.ts +26 -10
- package/src/cli/observe.ts +31 -8
- package/src/cli/provider-runtime.ts +16 -2
- package/src/cli/provider.ts +2 -1
- package/src/cli/registry.ts +49 -10
- package/src/cli/runtime-api.ts +51 -7
- package/src/cli/status.ts +41 -1
- package/src/cli/storage.ts +234 -0
- package/src/cli/system-command.ts +16 -0
- package/src/cli/system-restart-client.ts +1 -1
- package/src/cli/usage-report.ts +184 -0
- package/src/cli/v2.ts +34 -10
- package/src/cli/version-skew.ts +46 -0
- package/src/clients/config-export.ts +85 -3
- package/src/clients/effective-status.ts +30 -2
- package/src/clients/probes/cline.ts +81 -0
- package/src/codex/account-label.ts +21 -0
- package/src/codex/account-pause.ts +2 -1
- package/src/codex/account-priority.ts +3 -2
- package/src/codex/app-server-processes.ts +83 -7
- package/src/codex/auth-api.ts +50 -10
- package/src/codex/auth-context.ts +21 -18
- package/src/codex/autostart-health.ts +16 -0
- package/src/codex/catalog/aggregation.ts +18 -0
- package/src/codex/catalog/effort.ts +18 -3
- package/src/codex/catalog/metadata.ts +27 -1
- package/src/codex/catalog/native-models.ts +5 -2
- package/src/codex/catalog/parsing.ts +72 -27
- package/src/codex/catalog/provider-fetch.ts +187 -31
- package/src/codex/catalog/sync.ts +131 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-write-lock.ts +3 -1
- package/src/codex/convergence-types.ts +1 -1
- package/src/codex/convergence.ts +27 -2
- package/src/codex/desired-state.ts +2 -2
- package/src/codex/desktop-app-restart.ts +355 -0
- package/src/codex/history-job.ts +32 -3
- package/src/codex/history-manifest.ts +112 -0
- package/src/codex/history-migration-guardian.ts +5 -5
- package/src/codex/history-provider.ts +825 -247
- package/src/codex/history-worker.ts +8 -5
- package/src/codex/inject-coordination.ts +83 -0
- package/src/codex/inject.ts +63 -22
- package/src/codex/injected-marker.ts +1 -1
- package/src/codex/internal/history-writer.ts +4 -3
- package/src/codex/log-guard/inspect.ts +22 -4
- package/src/codex/model-entitlements.ts +9 -2
- package/src/codex/native-profile-startup.ts +157 -27
- package/src/codex/native-residue.ts +26 -33
- package/src/codex/prompt-layers.ts +371 -25
- package/src/codex/prompt-text-probe.ts +238 -0
- package/src/codex/quota.ts +91 -10
- package/src/codex/routing.ts +9 -0
- package/src/codex/shim.ts +56 -3
- package/src/codex/subagent-model-fallback.ts +104 -25
- package/src/codex/transition-state.ts +116 -8
- package/src/combos/failover.ts +27 -0
- package/src/combos/types.ts +10 -0
- package/src/compatibility/index.ts +26 -0
- package/src/compatibility/manifest.ts +253 -0
- package/src/compatibility/openai-responses.ts +121 -0
- package/src/config/atomic-write.ts +219 -0
- package/src/config/paths.ts +40 -0
- package/src/config/process-state.ts +309 -0
- package/src/config/provider-validation.ts +177 -0
- package/src/config/rebase-provenance.ts +68 -0
- package/src/config.ts +206 -824
- package/src/generated/compatibility-version.json +373 -213
- package/src/images/loop.ts +5 -4
- package/src/images/plan.ts +5 -4
- package/src/integrations/ownership-policy.ts +160 -0
- package/src/integrations/ownership.ts +46 -2
- package/src/integrations/registry.ts +7 -0
- package/src/integrations/state.ts +79 -7
- package/src/integrations/writer.ts +26 -2
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
- package/src/lab/fabric/producer-child.ts +1 -1
- package/src/lib/admin-secrets.ts +24 -0
- package/src/lib/bounded-body.ts +14 -2
- package/src/lib/config-ownership.ts +20 -0
- package/src/lib/errors.ts +36 -3
- package/src/lib/package-tree-integrity.ts +101 -0
- package/src/lib/process-control.ts +2 -1
- package/src/lib/service-secrets.ts +15 -0
- package/src/lib/tool-argument-integers.ts +56 -5
- package/src/oauth/callback-server.ts +22 -2
- package/src/oauth/cline.ts +203 -0
- package/src/oauth/generic-account-failover.ts +231 -0
- package/src/oauth/health.ts +1 -1
- package/src/oauth/index.ts +65 -6
- package/src/oauth/key-providers.ts +10 -1
- package/src/oauth/kimi.ts +9 -1
- package/src/oauth/kiro.ts +45 -0
- package/src/oauth/local-token-detect.ts +2 -0
- package/src/oauth/open-browser-choice.ts +26 -0
- package/src/oauth/store.ts +14 -5
- package/src/oauth/types.ts +15 -0
- package/src/providers/auto-compact-budget.ts +65 -0
- package/src/providers/command-code-efforts.ts +38 -6
- package/src/providers/context-cap.ts +4 -3
- package/src/providers/default-aliases.ts +65 -0
- package/src/providers/derive.ts +28 -0
- package/src/providers/fastwire.ts +7 -1
- package/src/providers/label.ts +34 -1
- package/src/providers/model-presets.ts +119 -0
- package/src/providers/new-model-policy.ts +146 -0
- package/src/providers/provider-id-rewrite.ts +2 -1
- package/src/providers/quota.ts +65 -7
- package/src/providers/registry.ts +184 -69
- package/src/providers/slug-codec.ts +52 -0
- package/src/providers/xai-transport.ts +21 -0
- package/src/reasoning-effort.ts +19 -2
- package/src/responses/apply-patch-envelope.ts +63 -0
- package/src/responses/code-mode-helper-compat.ts +50 -0
- package/src/responses/custom-tool-compat.ts +157 -39
- package/src/responses/namespace-tool-compat.ts +84 -4
- package/src/responses/parser.ts +7 -2
- package/src/responses/reasoning-replay-cache.ts +81 -3
- package/src/responses/schema.ts +5 -1
- package/src/responses/state.ts +94 -4
- package/src/responses/thought-signature-replay.ts +17 -0
- package/src/responses/turn-termination.ts +107 -0
- package/src/router.ts +40 -2
- package/src/routing/analytics.ts +1 -0
- package/src/routing/quota.ts +10 -0
- package/src/server/auth-cors.ts +27 -7
- package/src/server/chat-completions.ts +24 -16
- package/src/server/chat-native-sse.ts +3 -3
- package/src/server/chat-native.ts +30 -11
- package/src/server/claude-messages.ts +1 -1
- package/src/server/index.ts +179 -32
- package/src/server/lifecycle.ts +52 -1
- package/src/server/local-management-read-client.ts +1 -1
- package/src/server/local-provider-reload-client.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +45 -23
- package/src/server/management/codex-prompt-routes.ts +570 -0
- package/src/server/management/combo-routes.ts +2 -1
- package/src/server/management/config-routes.ts +59 -12
- package/src/server/management/context.ts +10 -1
- package/src/server/management/logs-usage-routes.ts +30 -24
- package/src/server/management/model-routes.ts +274 -4
- package/src/server/management/model-rows.ts +4 -0
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/oauth-account-routes.ts +10 -4
- package/src/server/management/provider-capability-config.ts +1 -1
- package/src/server/management/provider-routes.ts +81 -11
- package/src/server/management/route-registry.ts +332 -0
- package/src/server/management/routing-profile-routes.ts +2 -2
- package/src/server/management/system-restart.ts +1 -1
- package/src/server/management-api.ts +18 -16
- package/src/server/port-reclaim.ts +1 -1
- package/src/server/proxy-liveness.ts +29 -5
- package/src/server/relay-eager.ts +147 -20
- package/src/server/relay.ts +251 -19
- package/src/server/request-log-conversation.ts +51 -0
- package/src/server/request-log.ts +70 -16
- package/src/server/responses/codex-auth-error.ts +55 -0
- package/src/server/responses/collaboration.ts +37 -3
- package/src/server/responses/combo-stream-preflight.ts +178 -0
- package/src/server/responses/compact.ts +6 -21
- package/src/server/responses/core.ts +810 -208
- package/src/server/responses/empty-completion-guard.ts +63 -6
- package/src/server/responses/fetch-helpers.ts +17 -102
- package/src/server/responses/input-admission.ts +3 -1
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/responses-field-backfill.ts +105 -13
- package/src/server/responses/ws-upstream.ts +75 -2
- package/src/server/responses-custom-tool-repair.ts +87 -6
- package/src/server/responses-terminal-repair.ts +25 -4
- package/src/server/responses-undeclared-tool-guard.ts +241 -18
- package/src/server/sse-frame-buffer.ts +31 -4
- package/src/server/ws-bridge.ts +14 -2
- package/src/service.ts +42 -4
- package/src/storage/policy-job.ts +14 -4
- package/src/storage/policy.ts +88 -23
- package/src/types/config.ts +66 -0
- package/src/types/provider.ts +69 -0
- package/src/types/request.ts +17 -4
- package/src/types/tools.ts +116 -11
- package/src/types.ts +3 -1
- package/src/update/index.ts +5 -4
- package/src/update/job.ts +3 -1
- package/src/update/transactional-install.mjs +8 -1
- package/src/usage/expected-prices.ts +43 -24
- package/src/usage/log.ts +63 -12
- package/src/usage/summary.ts +211 -8
- package/src/vision/describe.ts +18 -13
- package/src/web-search/exa-executor.ts +40 -9
- package/src/web-search/executor.ts +10 -3
- package/src/web-search/loop.ts +5 -4
- package/gui/dist/assets/index-BF38heuV.js +0 -104
- package/gui/dist/assets/index-DMiI18Kv.css +0 -1
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, fsyncSync, lstatSync, 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";
|
|
6
6
|
import { resolveCodexStateDbPath } from "./paths";
|
|
7
7
|
import { atomicWriteFile, getConfigDir } from "../config";
|
|
8
|
+
import {
|
|
9
|
+
CODEX_HISTORY_RESUMABLE_SOURCES,
|
|
10
|
+
codexHistoryBackupId,
|
|
11
|
+
sameCodexHistoryPath,
|
|
12
|
+
validateCodexHistoryBackupManifest,
|
|
13
|
+
type CodexHistoryBackupEntry,
|
|
14
|
+
type CodexHistoryBackupManifest,
|
|
15
|
+
} from "./history-manifest";
|
|
8
16
|
|
|
9
17
|
/**
|
|
10
18
|
* Cap for decompressing a lone `.jsonl.zst` rollout during quarantine restore.
|
|
@@ -21,11 +29,8 @@ export const MAX_ROLLOUT_ZST_DECOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
|
21
29
|
* the same way, since a manifest addressed differently is a different manifest.
|
|
22
30
|
*/
|
|
23
31
|
export function historyBackupPathFor(stateDbPath: string): string {
|
|
24
|
-
|
|
25
|
-
const id = createHash("sha256").update(normalized).digest("hex").slice(0, 16);
|
|
26
|
-
return join(getConfigDir(), `codex-history-backup-${id}.json`);
|
|
32
|
+
return join(getConfigDir(), `codex-history-backup-${codexHistoryBackupId(stateDbPath)}.json`);
|
|
27
33
|
}
|
|
28
|
-
const RESUMABLE_SOURCES = ["cli", "vscode"] as const;
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
36
|
* Open the live `state_5.sqlite` the way the Codex app expects a *secondary* writer to behave:
|
|
@@ -69,10 +74,10 @@ function openStateDb(stateDbPath: string): Database {
|
|
|
69
74
|
* concurrently. We do not touch mtime: a fresh mtime is correct here (the app uses mtime as the
|
|
70
75
|
* rollout's updated_at), and forcing it backwards could hide a real edit from list ordering.
|
|
71
76
|
*/
|
|
72
|
-
function appendRolloutLine(path: string, line: string):
|
|
77
|
+
function appendRolloutLine(path: string, line: string): Buffer {
|
|
73
78
|
const fd = openSync(path, "a");
|
|
79
|
+
const buf = Buffer.from(line.endsWith("\n") ? line : `${line}\n`, "utf8");
|
|
74
80
|
try {
|
|
75
|
-
const buf = Buffer.from(line.endsWith("\n") ? line : `${line}\n`, "utf8");
|
|
76
81
|
let offset = 0;
|
|
77
82
|
while (offset < buf.length) {
|
|
78
83
|
offset += writeSync(fd, buf, offset, buf.length - offset, null);
|
|
@@ -81,6 +86,7 @@ function appendRolloutLine(path: string, line: string): void {
|
|
|
81
86
|
} finally {
|
|
82
87
|
closeSync(fd);
|
|
83
88
|
}
|
|
89
|
+
return buf;
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
/**
|
|
@@ -98,65 +104,104 @@ function appendRolloutLine(path: string, line: string): void {
|
|
|
98
104
|
* insignificant JSON whitespace. We therefore replace the provider value and pad the removed bytes
|
|
99
105
|
* with spaces so the line's byte length is unchanged. Equal length means we can write at offset 0
|
|
100
106
|
* with no truncate and no inode swap, so this composes safely with the app's cached append handle.
|
|
101
|
-
*
|
|
102
|
-
*
|
|
107
|
+
* A previous shrink leaves JSON whitespace in the token slot. That padding is part of the slot,
|
|
108
|
+
* so an exact restore can later grow "openai" back to "opencodex" without moving any bytes.
|
|
103
109
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
110
|
+
* Distinguishes an already-correct line from an unsafe one so exact restore never consumes its
|
|
111
|
+
* manifest after only the trailing metadata was repaired.
|
|
106
112
|
*/
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
113
|
+
type FirstLineProviderResult = "current" | "patched" | "unsafe";
|
|
114
|
+
|
|
115
|
+
type FirstLineProviderPlan =
|
|
116
|
+
| { readonly state: "current" }
|
|
117
|
+
| { readonly state: "patchable"; readonly patchedLine: string }
|
|
118
|
+
| { readonly state: "unsafe" };
|
|
119
|
+
|
|
120
|
+
function readFirstRolloutLine(fd: number): string | null {
|
|
121
|
+
// session_meta lines embed base_instructions and can be tens of KB; grow until the line
|
|
122
|
+
// actually ends rather than imposing a small fixed probe that would reject valid history.
|
|
123
|
+
const CHUNK = 1 << 16;
|
|
124
|
+
const MAX_FIRST_LINE = 1 << 24;
|
|
125
|
+
let collected = Buffer.alloc(0);
|
|
126
|
+
let nlIndex = -1;
|
|
127
|
+
let pos = 0;
|
|
128
|
+
while (nlIndex === -1) {
|
|
129
|
+
const chunk = Buffer.alloc(CHUNK);
|
|
130
|
+
const read = readSync(fd, chunk, 0, CHUNK, pos);
|
|
131
|
+
if (read === 0) break;
|
|
132
|
+
collected = Buffer.concat([collected, chunk.subarray(0, read)]);
|
|
133
|
+
nlIndex = collected.indexOf(0x0a);
|
|
134
|
+
pos += read;
|
|
135
|
+
if (collected.length > MAX_FIRST_LINE) return null;
|
|
136
|
+
}
|
|
137
|
+
return nlIndex === -1 ? null : collected.subarray(0, nlIndex).toString("utf8");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function planFirstLineProvider(firstLine: string, expectedId: string, provider: string): FirstLineProviderPlan {
|
|
141
|
+
const meta = parseSessionMetaLine(firstLine);
|
|
142
|
+
if (!meta || meta.record.payload.id !== expectedId) return { state: "unsafe" };
|
|
143
|
+
if (meta.record.payload.model_provider === provider) return { state: "current" };
|
|
144
|
+
|
|
145
|
+
// Include JSON whitespace after the value. A prior length-preserving shrink stores its spare
|
|
146
|
+
// bytes there, so the exact reverse restore may grow back into that padding.
|
|
147
|
+
const match = firstLine.match(/"model_provider"\s*:\s*"([^"\\]*)"[ \t]*/);
|
|
148
|
+
if (!match || match.index === undefined) return { state: "unsafe" };
|
|
149
|
+
const oldToken = match[0];
|
|
150
|
+
const newCore = `"model_provider":"${provider}"`;
|
|
151
|
+
if (Buffer.byteLength(newCore, "utf8") > Buffer.byteLength(oldToken, "utf8")) return { state: "unsafe" };
|
|
152
|
+
const pad = " ".repeat(Buffer.byteLength(oldToken, "utf8") - Buffer.byteLength(newCore, "utf8"));
|
|
153
|
+
const patchedLine = firstLine.slice(0, match.index) + newCore + pad + firstLine.slice(match.index + oldToken.length);
|
|
154
|
+
if (Buffer.byteLength(patchedLine, "utf8") !== Buffer.byteLength(firstLine, "utf8")) return { state: "unsafe" };
|
|
155
|
+
const reparsed = parseSessionMetaLine(patchedLine);
|
|
156
|
+
if (!reparsed
|
|
157
|
+
|| reparsed.record.payload.id !== expectedId
|
|
158
|
+
|| reparsed.record.payload.model_provider !== provider) return { state: "unsafe" };
|
|
159
|
+
return { state: "patchable", patchedLine };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function inspectFirstLineProvider(path: string, expectedId: string, provider: string): "current" | "patchable" | "unsafe" {
|
|
163
|
+
if (!existsSync(path)) return "unsafe";
|
|
164
|
+
const fd = openSync(path, "r");
|
|
110
165
|
try {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
let collected = Buffer.alloc(0);
|
|
118
|
-
let nlIndex = -1;
|
|
119
|
-
let pos = 0;
|
|
120
|
-
while (nlIndex === -1) {
|
|
121
|
-
const chunk = Buffer.alloc(CHUNK);
|
|
122
|
-
const read = readSync(fd, chunk, 0, CHUNK, pos);
|
|
123
|
-
if (read === 0) break; // EOF with no newline: single-line file, skip
|
|
124
|
-
collected = Buffer.concat([collected, chunk.subarray(0, read)]);
|
|
125
|
-
nlIndex = collected.indexOf(0x0a);
|
|
126
|
-
pos += read;
|
|
127
|
-
if (collected.length > MAX_FIRST_LINE) return false;
|
|
128
|
-
}
|
|
129
|
-
if (nlIndex === -1) return false; // no newline anywhere: skip
|
|
130
|
-
const firstLine = collected.subarray(0, nlIndex).toString("utf8");
|
|
166
|
+
const firstLine = readFirstRolloutLine(fd);
|
|
167
|
+
return firstLine === null ? "unsafe" : planFirstLineProvider(firstLine, expectedId, provider).state;
|
|
168
|
+
} finally {
|
|
169
|
+
closeSync(fd);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
131
172
|
|
|
173
|
+
function readFirstLineProviderValue(path: string, expectedId: string): string | null {
|
|
174
|
+
if (!existsSync(path)) return null;
|
|
175
|
+
const fd = openSync(path, "r");
|
|
176
|
+
try {
|
|
177
|
+
const firstLine = readFirstRolloutLine(fd);
|
|
178
|
+
if (firstLine === null) return null;
|
|
132
179
|
const meta = parseSessionMetaLine(firstLine);
|
|
133
|
-
if (!meta) return
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const out = Buffer.from(patchedLine, "utf8");
|
|
180
|
+
if (!meta || meta.record.payload.id !== expectedId) return null;
|
|
181
|
+
return typeof meta.record.payload.model_provider === "string"
|
|
182
|
+
? meta.record.payload.model_provider
|
|
183
|
+
: null;
|
|
184
|
+
} finally {
|
|
185
|
+
closeSync(fd);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function patchFirstLineProviderInPlace(path: string, expectedId: string, provider: string): FirstLineProviderResult {
|
|
190
|
+
if (!existsSync(path)) return "unsafe";
|
|
191
|
+
const fd = openSync(path, "r+");
|
|
192
|
+
try {
|
|
193
|
+
const firstLine = readFirstRolloutLine(fd);
|
|
194
|
+
if (firstLine === null) return "unsafe";
|
|
195
|
+
const plan = planFirstLineProvider(firstLine, expectedId, provider);
|
|
196
|
+
if (plan.state === "unsafe") return "unsafe";
|
|
197
|
+
if (plan.state === "current") return "current";
|
|
198
|
+
const out = Buffer.from(plan.patchedLine, "utf8");
|
|
154
199
|
let offset = 0;
|
|
155
200
|
while (offset < out.length) {
|
|
156
201
|
offset += writeSync(fd, out, offset, out.length - offset, offset);
|
|
157
202
|
}
|
|
158
203
|
try { fsyncSync(fd); } catch { /* best-effort durability */ }
|
|
159
|
-
return
|
|
204
|
+
return "patched";
|
|
160
205
|
} finally {
|
|
161
206
|
closeSync(fd);
|
|
162
207
|
}
|
|
@@ -164,9 +209,29 @@ function patchFirstLineProviderInPlace(path: string, expectedId: string, provide
|
|
|
164
209
|
|
|
165
210
|
export type CodexHistoryProvider = "openai" | "opencodex";
|
|
166
211
|
|
|
167
|
-
export type CodexHistoryFailureReason = "busy" | "permission";
|
|
212
|
+
export type CodexHistoryFailureReason = "busy" | "permission" | "integrity";
|
|
213
|
+
|
|
214
|
+
class CodexHistoryIntegrityError extends Error {
|
|
215
|
+
constructor(
|
|
216
|
+
code: string,
|
|
217
|
+
readonly progress: { readonly rows: number; readonly files: number } = { rows: 0, files: 0 },
|
|
218
|
+
) {
|
|
219
|
+
super(code);
|
|
220
|
+
this.name = "CodexHistoryIntegrityError";
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function integrityFailureResult(error: CodexHistoryIntegrityError): CodexHistorySyncResult {
|
|
225
|
+
return {
|
|
226
|
+
rows: error.progress.rows,
|
|
227
|
+
files: error.progress.files,
|
|
228
|
+
failed: true,
|
|
229
|
+
failureReason: "integrity",
|
|
230
|
+
};
|
|
231
|
+
}
|
|
168
232
|
|
|
169
233
|
export interface CodexHistorySyncResult {
|
|
234
|
+
/** Rows/files changed before a last-moment integrity race; may be nonzero with `failed`. */
|
|
170
235
|
rows: number;
|
|
171
236
|
files: number;
|
|
172
237
|
ejectedRows?: number;
|
|
@@ -184,18 +249,16 @@ interface ThreadRow {
|
|
|
184
249
|
has_user_event: number;
|
|
185
250
|
}
|
|
186
251
|
|
|
187
|
-
interface
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
252
|
+
interface RestoreRowSnapshot extends ThreadRow {
|
|
253
|
+
first_user_message: string | null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface ApplyRowSnapshot extends ThreadRow {
|
|
257
|
+
first_user_message: string | null;
|
|
193
258
|
}
|
|
194
259
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
stateDbPath?: string;
|
|
198
|
-
entries: Record<string, BackupEntry>;
|
|
260
|
+
function hasFirstUserMessage(value: string | null): boolean {
|
|
261
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
199
262
|
}
|
|
200
263
|
|
|
201
264
|
export interface CodexHistoryVerifiedNoopProof {
|
|
@@ -232,71 +295,125 @@ export type CodexHistoryNoopSnapshot =
|
|
|
232
295
|
|
|
233
296
|
type StrictBackupInspection =
|
|
234
297
|
| { readonly kind: "known"; readonly present: boolean; readonly entries: number; readonly fingerprint: string }
|
|
235
|
-
| { readonly kind: "unknown"; readonly present: boolean; readonly reason: "manifest-read" | "manifest-schema" | "manifest-foreign"
|
|
298
|
+
| { readonly kind: "unknown"; readonly present: boolean; readonly reason: "manifest-read" | "manifest-schema" | "manifest-foreign";
|
|
299
|
+
readonly failureReason?: "busy" | "permission" };
|
|
300
|
+
|
|
301
|
+
type StrictBackupRead =
|
|
302
|
+
| {
|
|
303
|
+
readonly kind: "known";
|
|
304
|
+
readonly present: boolean;
|
|
305
|
+
readonly manifest: CodexHistoryBackupManifest;
|
|
306
|
+
readonly fingerprint: string;
|
|
307
|
+
}
|
|
308
|
+
| {
|
|
309
|
+
readonly kind: "unknown";
|
|
310
|
+
readonly present: true;
|
|
311
|
+
readonly reason: "manifest-read" | "manifest-schema" | "manifest-foreign";
|
|
312
|
+
readonly failureReason?: "busy" | "permission";
|
|
313
|
+
};
|
|
236
314
|
|
|
237
315
|
let afterNoopPendingCountForTests: (() => void) | undefined;
|
|
316
|
+
let beforeHistoryBackupConsumeForTests: (() => void) | undefined;
|
|
317
|
+
let beforeStrictHistoryRolloutAppendForTests: (() => void) | undefined;
|
|
318
|
+
let afterStrictHistoryRolloutAppendForTests: (() => void) | undefined;
|
|
319
|
+
let beforeHistoryApplyTransactionForTests: (() => void) | undefined;
|
|
238
320
|
|
|
239
321
|
/** Test seam: runs after the pending count and before stability validation. */
|
|
240
322
|
export function setAfterNoopPendingCountForTests(hook: (() => void) | undefined): void {
|
|
241
323
|
afterNoopPendingCountForTests = hook;
|
|
242
324
|
}
|
|
243
325
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
hasUserEvent: number;
|
|
326
|
+
/** Test seam: runs after exact DB/rollout readback and before manifest fingerprint CAS. */
|
|
327
|
+
export function setBeforeHistoryBackupConsumeForTests(hook: (() => void) | undefined): void {
|
|
328
|
+
beforeHistoryBackupConsumeForTests = hook;
|
|
248
329
|
}
|
|
249
330
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
331
|
+
/** Test seam: models a same-file append after strict snapshot validation but before our append. */
|
|
332
|
+
export function setBeforeStrictHistoryRolloutAppendForTests(hook: (() => void) | undefined): void {
|
|
333
|
+
beforeStrictHistoryRolloutAppendForTests = hook;
|
|
254
334
|
}
|
|
255
335
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
336
|
+
/** Test seam: models a write/finalization failure after the strict append reached disk. */
|
|
337
|
+
export function setAfterStrictHistoryRolloutAppendForTests(hook: (() => void) | undefined): void {
|
|
338
|
+
afterStrictHistoryRolloutAppendForTests = hook;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Test seam: runs after manifest snapshot publication and before apply's database CAS. */
|
|
342
|
+
export function setBeforeHistoryApplyTransactionForTests(hook: (() => void) | undefined): void {
|
|
343
|
+
beforeHistoryApplyTransactionForTests = hook;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function readBackupStrict(path: string, stateDbPath: string): StrictBackupRead {
|
|
347
|
+
let pathStat: ReturnType<typeof lstatSync>;
|
|
348
|
+
try {
|
|
349
|
+
pathStat = lstatSync(path);
|
|
350
|
+
} catch (error) {
|
|
351
|
+
const code = typeof error === "object" && error && "code" in error ? String((error as { code?: unknown }).code) : "";
|
|
352
|
+
if (code !== "ENOENT") {
|
|
353
|
+
const failureReason = classifyRecoverableHistoryError(error);
|
|
354
|
+
return {
|
|
355
|
+
kind: "unknown",
|
|
356
|
+
present: true,
|
|
357
|
+
reason: "manifest-read",
|
|
358
|
+
...(failureReason === "busy" || failureReason === "permission" ? { failureReason } : {}),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
return {
|
|
362
|
+
kind: "known",
|
|
363
|
+
present: false,
|
|
364
|
+
manifest: { version: 1, stateDbPath, entries: {} },
|
|
365
|
+
fingerprint: "absent",
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
if (!pathStat.isFile() || pathStat.isSymbolicLink()) {
|
|
369
|
+
return { kind: "unknown", present: true, reason: "manifest-read" };
|
|
370
|
+
}
|
|
259
371
|
let raw: string;
|
|
260
372
|
try {
|
|
261
373
|
raw = readFileSync(path, "utf8");
|
|
374
|
+
} catch (error) {
|
|
375
|
+
const failureReason = classifyRecoverableHistoryError(error);
|
|
376
|
+
return {
|
|
377
|
+
kind: "unknown",
|
|
378
|
+
present: true,
|
|
379
|
+
reason: "manifest-read",
|
|
380
|
+
...(failureReason === "busy" || failureReason === "permission" ? { failureReason } : {}),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
let parsed: unknown;
|
|
384
|
+
try {
|
|
262
385
|
parsed = JSON.parse(raw);
|
|
263
386
|
} catch {
|
|
264
387
|
return { kind: "unknown", present: true, reason: "manifest-read" };
|
|
265
388
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
if (!samePath(manifest.stateDbPath, stateDbPath)) {
|
|
274
|
-
return { kind: "unknown", present: true, reason: "manifest-foreign" };
|
|
275
|
-
}
|
|
276
|
-
if (!manifest.entries || typeof manifest.entries !== "object" || Array.isArray(manifest.entries)) {
|
|
277
|
-
return { kind: "unknown", present: true, reason: "manifest-schema" };
|
|
278
|
-
}
|
|
279
|
-
for (const [id, value] of Object.entries(manifest.entries)) {
|
|
280
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
281
|
-
return { kind: "unknown", present: true, reason: "manifest-schema" };
|
|
282
|
-
}
|
|
283
|
-
const entry = value as Partial<BackupEntry>;
|
|
284
|
-
if (entry.id !== id
|
|
285
|
-
|| typeof entry.rolloutPath !== "string"
|
|
286
|
-
|| typeof entry.modelProvider !== "string"
|
|
287
|
-
|| typeof entry.source !== "string"
|
|
288
|
-
|| typeof entry.hasUserEvent !== "number") {
|
|
289
|
-
return { kind: "unknown", present: true, reason: "manifest-schema" };
|
|
290
|
-
}
|
|
389
|
+
const validated = validateCodexHistoryBackupManifest(parsed, stateDbPath);
|
|
390
|
+
if (!validated.ok) {
|
|
391
|
+
return {
|
|
392
|
+
kind: "unknown",
|
|
393
|
+
present: true,
|
|
394
|
+
reason: validated.reason === "foreign-database" ? "manifest-foreign" : "manifest-schema",
|
|
395
|
+
};
|
|
291
396
|
}
|
|
292
397
|
return {
|
|
293
398
|
kind: "known",
|
|
294
399
|
present: true,
|
|
295
|
-
|
|
400
|
+
manifest: validated.manifest,
|
|
296
401
|
fingerprint: createHash("sha256").update(raw).digest("hex"),
|
|
297
402
|
};
|
|
298
403
|
}
|
|
299
404
|
|
|
405
|
+
function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupInspection {
|
|
406
|
+
const read = readBackupStrict(path, stateDbPath);
|
|
407
|
+
return read.kind === "unknown"
|
|
408
|
+
? read
|
|
409
|
+
: {
|
|
410
|
+
kind: "known",
|
|
411
|
+
present: read.present,
|
|
412
|
+
entries: Object.keys(read.manifest.entries).length,
|
|
413
|
+
fingerprint: read.fingerprint,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
300
417
|
function historyFileIdentity(path: string): string | null {
|
|
301
418
|
try {
|
|
302
419
|
const stat = statSync(path);
|
|
@@ -312,23 +429,31 @@ function readHistoryDataVersion(db: Database): number | null {
|
|
|
312
429
|
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
|
|
313
430
|
}
|
|
314
431
|
|
|
315
|
-
function readBackup(path: string, stateDbPath
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
432
|
+
function readBackup(path: string, stateDbPath: string): Extract<StrictBackupRead, { kind: "known" }> {
|
|
433
|
+
const read = readBackupStrict(path, stateDbPath);
|
|
434
|
+
if (read.kind === "unknown") {
|
|
435
|
+
if (read.failureReason) {
|
|
436
|
+
throw Object.assign(
|
|
437
|
+
new Error(read.failureReason === "busy" ? "history backup is busy" : "history backup permission denied"),
|
|
438
|
+
{ code: read.failureReason === "busy" ? "EBUSY" : "EACCES" },
|
|
439
|
+
);
|
|
321
440
|
}
|
|
322
|
-
|
|
323
|
-
return { version: 1, stateDbPath, entries: {} };
|
|
324
|
-
}
|
|
325
|
-
return { version: 1, stateDbPath: parsed.stateDbPath ?? stateDbPath, entries: parsed.entries };
|
|
326
|
-
} catch {
|
|
327
|
-
return { version: 1, stateDbPath, entries: {} };
|
|
441
|
+
throw new CodexHistoryIntegrityError(`history_backup_${read.reason.replaceAll("-", "_")}`);
|
|
328
442
|
}
|
|
443
|
+
return read;
|
|
329
444
|
}
|
|
330
445
|
|
|
331
|
-
function
|
|
446
|
+
function consumeBackupIfUnchanged(path: string, stateDbPath: string, expectedFingerprint: string): void {
|
|
447
|
+
const current = readBackupStrict(path, stateDbPath);
|
|
448
|
+
if (current.kind !== "known"
|
|
449
|
+
|| !current.present
|
|
450
|
+
|| current.fingerprint !== expectedFingerprint) {
|
|
451
|
+
throw new CodexHistoryIntegrityError("history_backup_changed_during_restore");
|
|
452
|
+
}
|
|
453
|
+
unlinkSync(path);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function writeBackup(path: string, manifest: CodexHistoryBackupManifest, stateDbPath?: string): void {
|
|
332
457
|
if (Object.keys(manifest.entries).length === 0) {
|
|
333
458
|
if (existsSync(path)) unlinkSync(path);
|
|
334
459
|
return;
|
|
@@ -337,17 +462,181 @@ function writeBackup(path: string, manifest: BackupManifest, stateDbPath?: strin
|
|
|
337
462
|
atomicWriteFile(path, JSON.stringify({ ...manifest, stateDbPath: manifest.stateDbPath ?? stateDbPath }, null, 2) + "\n");
|
|
338
463
|
}
|
|
339
464
|
|
|
340
|
-
function rememberOriginal(manifest:
|
|
465
|
+
function rememberOriginal(manifest: CodexHistoryBackupManifest, row: ThreadRow): void {
|
|
341
466
|
if (manifest.entries[row.id]) return;
|
|
342
467
|
manifest.entries[row.id] = {
|
|
343
468
|
id: row.id,
|
|
344
469
|
rolloutPath: row.rollout_path,
|
|
345
470
|
modelProvider: row.model_provider,
|
|
346
471
|
source: row.source,
|
|
347
|
-
hasUserEvent: Number(row.has_user_event)
|
|
472
|
+
hasUserEvent: Number(row.has_user_event) === 1 ? 1 : 0,
|
|
348
473
|
};
|
|
349
474
|
}
|
|
350
475
|
|
|
476
|
+
function rowMatchesRestoreTuple(
|
|
477
|
+
row: RestoreRowSnapshot,
|
|
478
|
+
modelProvider: string,
|
|
479
|
+
source: string,
|
|
480
|
+
hasUserEvent: number,
|
|
481
|
+
): boolean {
|
|
482
|
+
return row.model_provider === modelProvider
|
|
483
|
+
&& row.source === source
|
|
484
|
+
&& row.has_user_event === hasUserEvent;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function rowMatchesExpectedPostImage(row: RestoreRowSnapshot, entry: CodexHistoryBackupEntry): boolean {
|
|
488
|
+
if (entry.modelProvider === "openai") {
|
|
489
|
+
const postHasUserEvent = hasFirstUserMessage(row.first_user_message) ? 1 : entry.hasUserEvent;
|
|
490
|
+
return rowMatchesRestoreTuple(row, "opencodex", entry.source, postHasUserEvent);
|
|
491
|
+
}
|
|
492
|
+
return hasFirstUserMessage(row.first_user_message)
|
|
493
|
+
&& (
|
|
494
|
+
rowMatchesRestoreTuple(row, "opencodex", "cli", 1)
|
|
495
|
+
// Older restore code coerced an opencodex/exec original into this exact tuple before
|
|
496
|
+
// consuming its manifest. Accept that one known post-image so an interrupted old restore
|
|
497
|
+
// can use its preserved first-line padding to recover exact provenance.
|
|
498
|
+
|| rowMatchesRestoreTuple(row, "openai", "cli", 1)
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
interface RestoreRolloutSnapshot {
|
|
503
|
+
readonly identity: string;
|
|
504
|
+
readonly latestProvider: string;
|
|
505
|
+
readonly latestSource: string;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function normalizedSessionMetaTuple(meta: ParsedSessionMeta): { provider: string; source: string } {
|
|
509
|
+
const payload = meta.record.payload;
|
|
510
|
+
return {
|
|
511
|
+
provider: typeof payload.model_provider === "string" && payload.model_provider
|
|
512
|
+
? payload.model_provider
|
|
513
|
+
: "openai",
|
|
514
|
+
source: typeof payload.source === "string" && payload.source ? payload.source : "cli",
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function rolloutMatchesRestoreTuple(
|
|
519
|
+
meta: ParsedSessionMeta,
|
|
520
|
+
entry: CodexHistoryBackupEntry,
|
|
521
|
+
provider: string,
|
|
522
|
+
source: string,
|
|
523
|
+
): boolean {
|
|
524
|
+
const tuple = normalizedSessionMetaTuple(meta);
|
|
525
|
+
return meta.record.payload.id === entry.id
|
|
526
|
+
&& tuple.provider === provider
|
|
527
|
+
&& tuple.source === source;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function rolloutMatchesExpectedPostImage(meta: ParsedSessionMeta, entry: CodexHistoryBackupEntry): boolean {
|
|
531
|
+
if (entry.modelProvider === "openai") {
|
|
532
|
+
const tuple = normalizedSessionMetaTuple(meta);
|
|
533
|
+
const rawSource = meta.record.payload.source;
|
|
534
|
+
return meta.record.payload.id === entry.id
|
|
535
|
+
&& tuple.provider === "opencodex"
|
|
536
|
+
// Older/native session_meta records can omit source even when SQLite identifies the
|
|
537
|
+
// surface as vscode. Apply changes only the provider, so absence is a valid post-image;
|
|
538
|
+
// restore appends the exact manifest source before consuming provenance.
|
|
539
|
+
&& ((typeof rawSource !== "string" || !rawSource) || tuple.source === entry.source);
|
|
540
|
+
}
|
|
541
|
+
return rolloutMatchesRestoreTuple(meta, entry, "opencodex", "cli")
|
|
542
|
+
// Keep the same one-version recovery bridge as the database tuple check: older forced
|
|
543
|
+
// restore code could already have produced openai/cli before consuming this manifest.
|
|
544
|
+
|| rolloutMatchesRestoreTuple(meta, entry, "openai", "cli");
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function snapshotRolloutForRestore(entry: CodexHistoryBackupEntry): RestoreRolloutSnapshot {
|
|
548
|
+
const identityBefore = historyFileIdentity(entry.rolloutPath);
|
|
549
|
+
if (identityBefore === null) {
|
|
550
|
+
throw new CodexHistoryIntegrityError("history_backup_rollout_unrestorable");
|
|
551
|
+
}
|
|
552
|
+
const latest = readLatestSessionMeta(entry.rolloutPath);
|
|
553
|
+
if (!latest
|
|
554
|
+
|| (!rolloutMatchesRestoreTuple(latest, entry, entry.modelProvider, entry.source)
|
|
555
|
+
&& !rolloutMatchesExpectedPostImage(latest, entry))) {
|
|
556
|
+
throw new CodexHistoryIntegrityError("history_backup_rollout_postimage_mismatch");
|
|
557
|
+
}
|
|
558
|
+
const firstProvider = readFirstLineProviderValue(entry.rolloutPath, entry.id);
|
|
559
|
+
if (firstProvider !== "openai" && firstProvider !== "opencodex") {
|
|
560
|
+
throw new CodexHistoryIntegrityError("history_backup_rollout_postimage_mismatch");
|
|
561
|
+
}
|
|
562
|
+
if (inspectFirstLineProvider(entry.rolloutPath, entry.id, entry.modelProvider) === "unsafe") {
|
|
563
|
+
throw new CodexHistoryIntegrityError("history_backup_rollout_unrestorable");
|
|
564
|
+
}
|
|
565
|
+
if (historyFileIdentity(entry.rolloutPath) !== identityBefore) {
|
|
566
|
+
throw new CodexHistoryIntegrityError("history_backup_rollout_changed_during_restore");
|
|
567
|
+
}
|
|
568
|
+
const tuple = normalizedSessionMetaTuple(latest);
|
|
569
|
+
return {
|
|
570
|
+
identity: identityBefore,
|
|
571
|
+
latestProvider: tuple.provider,
|
|
572
|
+
latestSource: tuple.source,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
interface RestoreTargetPreflight {
|
|
577
|
+
readonly snapshots: Map<string, RestoreRowSnapshot>;
|
|
578
|
+
readonly rolloutSnapshots: Map<string, RestoreRolloutSnapshot>;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Read-only authority shared by restore and status/doctor. Every manifest entry must still
|
|
583
|
+
* identify either its exact target tuple or the one OpenCodex post-image, and its rollout
|
|
584
|
+
* must be present, stable, same-id, and durably restorable before callers call it pending.
|
|
585
|
+
*/
|
|
586
|
+
function preflightRestoreTargets(
|
|
587
|
+
getCurrent: (id: string) => RestoreRowSnapshot | null,
|
|
588
|
+
entries: CodexHistoryBackupEntry[],
|
|
589
|
+
): RestoreTargetPreflight {
|
|
590
|
+
const snapshots = preflightRestoreRows(getCurrent, entries);
|
|
591
|
+
const rolloutSnapshots = new Map<string, RestoreRolloutSnapshot>();
|
|
592
|
+
for (const entry of entries) {
|
|
593
|
+
// Validate every rollout before the first mutation. A later missing, foreign, or
|
|
594
|
+
// unpatchable entry must not leave an earlier file partially restored.
|
|
595
|
+
rolloutSnapshots.set(entry.id, snapshotRolloutForRestore(entry));
|
|
596
|
+
}
|
|
597
|
+
return { snapshots, rolloutSnapshots };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Cheap manifest-to-database authority check used by recurring no-op probes. */
|
|
601
|
+
function preflightRestoreRows(
|
|
602
|
+
getCurrent: (id: string) => RestoreRowSnapshot | null,
|
|
603
|
+
entries: CodexHistoryBackupEntry[],
|
|
604
|
+
): Map<string, RestoreRowSnapshot> {
|
|
605
|
+
const snapshots = new Map<string, RestoreRowSnapshot>();
|
|
606
|
+
for (const entry of entries) {
|
|
607
|
+
const row = getCurrent(entry.id);
|
|
608
|
+
if (!row || typeof row.rollout_path !== "string" || !sameCodexHistoryPath(row.rollout_path, entry.rolloutPath)) {
|
|
609
|
+
throw new CodexHistoryIntegrityError("history_backup_target_mismatch");
|
|
610
|
+
}
|
|
611
|
+
if (!rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent)
|
|
612
|
+
&& !rowMatchesExpectedPostImage(row, entry)) {
|
|
613
|
+
throw new CodexHistoryIntegrityError("history_backup_postimage_mismatch");
|
|
614
|
+
}
|
|
615
|
+
snapshots.set(entry.id, row);
|
|
616
|
+
}
|
|
617
|
+
return snapshots;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function assertRestoreReadback(
|
|
621
|
+
getCurrent: (id: string) => RestoreRowSnapshot | null,
|
|
622
|
+
entries: CodexHistoryBackupEntry[],
|
|
623
|
+
): void {
|
|
624
|
+
for (const entry of entries) {
|
|
625
|
+
const row = getCurrent(entry.id);
|
|
626
|
+
if (!row
|
|
627
|
+
|| !sameCodexHistoryPath(row.rollout_path, entry.rolloutPath)
|
|
628
|
+
|| !rowMatchesRestoreTuple(row, entry.modelProvider, entry.source, entry.hasUserEvent)) {
|
|
629
|
+
throw new CodexHistoryIntegrityError("history_backup_database_readback_mismatch");
|
|
630
|
+
}
|
|
631
|
+
const latest = readLatestSessionMeta(entry.rolloutPath);
|
|
632
|
+
if (inspectFirstLineProvider(entry.rolloutPath, entry.id, entry.modelProvider) !== "current"
|
|
633
|
+
|| !latest
|
|
634
|
+
|| !rolloutMatchesRestoreTuple(latest, entry, entry.modelProvider, entry.source)) {
|
|
635
|
+
throw new CodexHistoryIntegrityError("history_backup_rollout_readback_mismatch");
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
351
640
|
interface ParsedSessionMeta {
|
|
352
641
|
record: { type?: unknown; timestamp?: unknown; payload: { model_provider?: unknown; source?: unknown } & Record<string, unknown> };
|
|
353
642
|
}
|
|
@@ -373,6 +662,10 @@ function parseSessionMetaLine(line: string): ParsedSessionMeta | null {
|
|
|
373
662
|
*/
|
|
374
663
|
export function readLatestSessionMeta(path: string): ParsedSessionMeta | null {
|
|
375
664
|
const raw = readFileSync(path, "utf8");
|
|
665
|
+
return readLatestSessionMetaFromText(raw);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function readLatestSessionMetaFromText(raw: string): ParsedSessionMeta | null {
|
|
376
669
|
const lines = raw.split("\n");
|
|
377
670
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
378
671
|
const line = lines[i];
|
|
@@ -384,6 +677,39 @@ export function readLatestSessionMeta(path: string): ParsedSessionMeta | null {
|
|
|
384
677
|
return null;
|
|
385
678
|
}
|
|
386
679
|
|
|
680
|
+
function readLatestSessionMetaForIdFromText(raw: string, expectedId: string): ParsedSessionMeta | null {
|
|
681
|
+
const lines = raw.split("\n");
|
|
682
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
683
|
+
const line = lines[i];
|
|
684
|
+
if (!line || !line.includes("\"session_meta\"")) continue;
|
|
685
|
+
const meta = parseSessionMetaLine(line);
|
|
686
|
+
if (meta?.record.payload.id === expectedId) return meta;
|
|
687
|
+
}
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function compensateConcurrentSessionMetaAppend(
|
|
692
|
+
path: string,
|
|
693
|
+
expectedId: string,
|
|
694
|
+
appended: Buffer,
|
|
695
|
+
minimumOffset: number,
|
|
696
|
+
): void {
|
|
697
|
+
try {
|
|
698
|
+
const raw = readFileSync(path);
|
|
699
|
+
const ownOffset = raw.lastIndexOf(appended);
|
|
700
|
+
if (ownOffset < minimumOffset) return;
|
|
701
|
+
const withoutOwnAppend = Buffer.concat([
|
|
702
|
+
raw.subarray(0, ownOffset),
|
|
703
|
+
raw.subarray(ownOffset + appended.length),
|
|
704
|
+
]).toString("utf8");
|
|
705
|
+
const prior = readLatestSessionMetaForIdFromText(withoutOwnAppend, expectedId);
|
|
706
|
+
if (prior) appendRolloutLine(path, JSON.stringify(prior.record));
|
|
707
|
+
} catch {
|
|
708
|
+
// The caller reports an integrity conflict and retains the manifest. Compensation is
|
|
709
|
+
// best-effort because a second write failure must not erase the original failure evidence.
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
387
713
|
/**
|
|
388
714
|
* Fields needed to re-insert a production-shaped `threads` row from a rollout JSONL when a
|
|
389
715
|
* Phase-2 quarantine predates full `satellite-backup.json` thread snapshots.
|
|
@@ -523,15 +849,34 @@ function parseThreadFieldsFromRolloutText(raw: string): RolloutThreadFields | nu
|
|
|
523
849
|
/**
|
|
524
850
|
* Make a thread's rollout reflect a provider/source change by APPENDING a new `session_meta` line,
|
|
525
851
|
* rather than rewriting line 1. The appended line clones the latest metadata payload (so no field
|
|
526
|
-
* is accidentally reset to empty) and applies only the requested changes.
|
|
527
|
-
*
|
|
528
|
-
* different thread id, or it already matches the desired values.
|
|
852
|
+
* is accidentally reset to empty) and applies only the requested changes. `durableProvider`
|
|
853
|
+
* reports whether line 1 already carried (or was safely patched to) the requested provider.
|
|
529
854
|
*/
|
|
530
|
-
|
|
531
|
-
|
|
855
|
+
interface SessionMetaUpdateResult {
|
|
856
|
+
changed: boolean;
|
|
857
|
+
durableProvider: boolean;
|
|
858
|
+
conflict?: true;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function updateSessionMeta(
|
|
862
|
+
path: string,
|
|
863
|
+
expectedId: string,
|
|
864
|
+
patch: { provider?: string; source?: string },
|
|
865
|
+
options: {
|
|
866
|
+
requireDurableProvider?: boolean;
|
|
867
|
+
expectedFileIdentity?: string;
|
|
868
|
+
expectedLatestProvider?: string;
|
|
869
|
+
expectedLatestSource?: string;
|
|
870
|
+
} = {},
|
|
871
|
+
): SessionMetaUpdateResult {
|
|
872
|
+
if (!path || !existsSync(path)) return { changed: false, durableProvider: false };
|
|
873
|
+
if (options.expectedFileIdentity !== undefined
|
|
874
|
+
&& historyFileIdentity(path) !== options.expectedFileIdentity) {
|
|
875
|
+
return { changed: false, durableProvider: false, conflict: true };
|
|
876
|
+
}
|
|
532
877
|
|
|
533
878
|
const latest = readLatestSessionMeta(path);
|
|
534
|
-
if (!latest) return false;
|
|
879
|
+
if (!latest) return { changed: false, durableProvider: false };
|
|
535
880
|
const record = latest.record;
|
|
536
881
|
|
|
537
882
|
// The app ignores `session_meta` lines whose payload id != the canonical thread id
|
|
@@ -539,8 +884,23 @@ function updateSessionMeta(path: string, expectedId: string, patch: { provider?:
|
|
|
539
884
|
// metadata, so an id-mismatched latest line means we'd be cloning the wrong thread's meta and
|
|
540
885
|
// appending a line the app would discard. Skip rather than write a no-op/misleading line.
|
|
541
886
|
const payloadId = record.payload.id;
|
|
542
|
-
if (typeof payloadId !== "string" || payloadId !== expectedId)
|
|
887
|
+
if (typeof payloadId !== "string" || payloadId !== expectedId) {
|
|
888
|
+
return { changed: false, durableProvider: false };
|
|
889
|
+
}
|
|
890
|
+
const latestProvider = typeof record.payload.model_provider === "string" && record.payload.model_provider
|
|
891
|
+
? record.payload.model_provider
|
|
892
|
+
: "openai";
|
|
893
|
+
const latestSource = typeof record.payload.source === "string" && record.payload.source
|
|
894
|
+
? record.payload.source
|
|
895
|
+
: "cli";
|
|
896
|
+
if ((options.expectedLatestProvider !== undefined && latestProvider !== options.expectedLatestProvider)
|
|
897
|
+
|| (options.expectedLatestSource !== undefined && latestSource !== options.expectedLatestSource)
|
|
898
|
+
|| (options.expectedFileIdentity !== undefined
|
|
899
|
+
&& historyFileIdentity(path) !== options.expectedFileIdentity)) {
|
|
900
|
+
return { changed: false, durableProvider: false, conflict: true };
|
|
901
|
+
}
|
|
543
902
|
|
|
903
|
+
const previousRecord = JSON.stringify(record);
|
|
544
904
|
let changed = false;
|
|
545
905
|
if (patch.provider !== undefined && record.payload.model_provider !== patch.provider) {
|
|
546
906
|
record.payload.model_provider = patch.provider;
|
|
@@ -550,40 +910,85 @@ function updateSessionMeta(path: string, expectedId: string, patch: { provider?:
|
|
|
550
910
|
record.payload.source = patch.source;
|
|
551
911
|
changed = true;
|
|
552
912
|
}
|
|
553
|
-
|
|
913
|
+
const strictRestore = options.expectedFileIdentity !== undefined;
|
|
914
|
+
if (strictRestore) {
|
|
915
|
+
if (historyFileIdentity(path) !== options.expectedFileIdentity) {
|
|
916
|
+
return { changed: false, durableProvider: false, conflict: true };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
let appended: Buffer | null = null;
|
|
920
|
+
let beforeSize = 0;
|
|
921
|
+
if (changed) {
|
|
922
|
+
beforeSize = statSync(path).size;
|
|
923
|
+
beforeStrictHistoryRolloutAppendForTests?.();
|
|
924
|
+
record.timestamp = new Date().toISOString();
|
|
925
|
+
appended = appendRolloutLine(path, JSON.stringify(record));
|
|
926
|
+
afterStrictHistoryRolloutAppendForTests?.();
|
|
927
|
+
let cleanAppend = false;
|
|
928
|
+
try {
|
|
929
|
+
const raw = readFileSync(path);
|
|
930
|
+
cleanAppend = raw.length === beforeSize + appended.length
|
|
931
|
+
&& raw.subarray(beforeSize).equals(appended);
|
|
932
|
+
} catch {
|
|
933
|
+
cleanAppend = false;
|
|
934
|
+
}
|
|
935
|
+
if (!cleanAppend) {
|
|
936
|
+
compensateConcurrentSessionMetaAppend(path, expectedId, appended, beforeSize);
|
|
937
|
+
return { changed: true, durableProvider: false, conflict: true };
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
let firstLine: FirstLineProviderResult = "current";
|
|
942
|
+
if (patch.provider !== undefined) {
|
|
943
|
+
try {
|
|
944
|
+
firstLine = patchFirstLineProviderInPlace(path, expectedId, patch.provider);
|
|
945
|
+
} catch {
|
|
946
|
+
firstLine = "unsafe";
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
if (options.requireDurableProvider && firstLine === "unsafe") {
|
|
950
|
+
// Restore the pre-operation last-writer-wins tuple after an append succeeded but the
|
|
951
|
+
// first-line durability repair failed. The extra lines remain audit evidence; the manifest
|
|
952
|
+
// remains authoritative and the retry cannot mistake this for convergence.
|
|
953
|
+
if (appended) appendRolloutLine(path, previousRecord);
|
|
954
|
+
return {
|
|
955
|
+
changed: changed || historyFileIdentity(path) !== options.expectedFileIdentity,
|
|
956
|
+
durableProvider: false,
|
|
957
|
+
};
|
|
958
|
+
}
|
|
959
|
+
return {
|
|
960
|
+
changed: changed || firstLine === "patched",
|
|
961
|
+
durableProvider: firstLine !== "unsafe",
|
|
962
|
+
};
|
|
963
|
+
}
|
|
554
964
|
|
|
555
965
|
// Cover Codex's *other* provider reader: `read_session_meta_line` reads only line 1, and the
|
|
556
966
|
// app clones it when writing later git/memory-mode metadata. Appending alone leaves a stale
|
|
557
|
-
// line-1 provider that the clone would re-append
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
// SQLite replay path.
|
|
967
|
+
// line-1 provider that the clone would re-append. Exact restore requires this repair before it
|
|
968
|
+
// may update SQLite or consume the only provenance manifest; forward routing remains best-effort.
|
|
969
|
+
let firstLine: FirstLineProviderResult = "current";
|
|
561
970
|
if (patch.provider !== undefined) {
|
|
562
|
-
try {
|
|
971
|
+
try {
|
|
972
|
+
firstLine = patchFirstLineProviderInPlace(path, expectedId, patch.provider);
|
|
973
|
+
} catch {
|
|
974
|
+
firstLine = "unsafe";
|
|
975
|
+
}
|
|
976
|
+
if (options.requireDurableProvider && firstLine === "unsafe") {
|
|
977
|
+
return { changed: false, durableProvider: false };
|
|
978
|
+
}
|
|
563
979
|
}
|
|
564
980
|
|
|
565
|
-
|
|
981
|
+
const firstLineChanged = firstLine === "patched";
|
|
982
|
+
if (!changed) return { changed: firstLineChanged, durableProvider: firstLine !== "unsafe" };
|
|
983
|
+
|
|
984
|
+
// Forward/legacy mode remains best-effort. Strict manifest restore uses the CAS-style append
|
|
985
|
+
// branch above so a concurrent same-id provider decision cannot be overwritten.
|
|
566
986
|
record.timestamp = new Date().toISOString();
|
|
567
987
|
appendRolloutLine(path, JSON.stringify(record));
|
|
568
|
-
return true;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
function toNativeRestoreTarget(entry: BackupEntry): NativeRestoreTarget {
|
|
572
|
-
if (entry.modelProvider !== "opencodex") {
|
|
573
|
-
return {
|
|
574
|
-
modelProvider: entry.modelProvider,
|
|
575
|
-
source: entry.source,
|
|
576
|
-
hasUserEvent: entry.hasUserEvent,
|
|
577
|
-
};
|
|
578
|
-
}
|
|
579
|
-
return {
|
|
580
|
-
modelProvider: "openai",
|
|
581
|
-
source: entry.source === "exec" ? "cli" : entry.source,
|
|
582
|
-
hasUserEvent: 1,
|
|
583
|
-
};
|
|
988
|
+
return { changed: true, durableProvider: firstLine !== "unsafe" };
|
|
584
989
|
}
|
|
585
990
|
|
|
586
|
-
function
|
|
991
|
+
function relabelAllRoutedHistoryToOpenai(db: Database): { rows: number; files: number } {
|
|
587
992
|
const rows = db
|
|
588
993
|
.query<ThreadRow, []>(`
|
|
589
994
|
SELECT id, rollout_path, model_provider, source, has_user_event
|
|
@@ -599,9 +1004,9 @@ function ejectRemainingOpencodexHistory(db: Database): { rows: number; files: nu
|
|
|
599
1004
|
if (updateSessionMeta(row.rollout_path, row.id, {
|
|
600
1005
|
provider: "openai",
|
|
601
1006
|
source: row.source === "exec" ? "cli" : undefined,
|
|
602
|
-
})) files++;
|
|
1007
|
+
}).changed) files++;
|
|
603
1008
|
} catch {
|
|
604
|
-
/*
|
|
1009
|
+
/* explicit legacy recovery still relabels the DB when an old rollout is missing */
|
|
605
1010
|
}
|
|
606
1011
|
}
|
|
607
1012
|
|
|
@@ -673,8 +1078,10 @@ export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) =>
|
|
|
673
1078
|
}
|
|
674
1079
|
|
|
675
1080
|
/**
|
|
676
|
-
* True when a READONLY probe proves the
|
|
677
|
-
*
|
|
1081
|
+
* True when a READONLY probe proves the native-direction restore would be a no-op:
|
|
1082
|
+
* the history database is readable and the backup manifest has no restore entries. Bare
|
|
1083
|
+
* opencodex-tagged rows are not actionable: without a manifest their original provider is
|
|
1084
|
+
* unknown, so only the explicit legacy recovery command may relabel them. Used to skip the
|
|
678
1085
|
* write-open entirely in the Design B steady state — on Windows the Codex app holds
|
|
679
1086
|
* `state_5.sqlite` (WAL, busy_timeout 5s), so an unnecessary write open can stall for
|
|
680
1087
|
* seconds and surface a false lock warning, while WAL always admits readers. A failed
|
|
@@ -682,7 +1089,9 @@ export function withHistoryRetry<T>(fn: () => T, io: { sleepFn?: (ms: number) =>
|
|
|
682
1089
|
* to the write attempt and keep today's behavior for genuinely unknown state.
|
|
683
1090
|
*/
|
|
684
1091
|
function openaiRestoreIsNoop(stateDbPath: string, backupPath: string): boolean {
|
|
685
|
-
const pending = countPendingOpencodexHistory(stateDbPath, backupPath
|
|
1092
|
+
const pending = countPendingOpencodexHistory(stateDbPath, backupPath, {
|
|
1093
|
+
validateRestoreTargets: false,
|
|
1094
|
+
});
|
|
686
1095
|
return !pending.failed && pending.pendingRows === 0 && pending.backupEntries === 0;
|
|
687
1096
|
}
|
|
688
1097
|
|
|
@@ -698,28 +1107,41 @@ export function syncCodexHistoryProvider(
|
|
|
698
1107
|
&& openaiRestoreIsNoop(stateDbPath, backupPath)) {
|
|
699
1108
|
return { rows: 0, files: 0 };
|
|
700
1109
|
}
|
|
701
|
-
|
|
702
|
-
|
|
1110
|
+
try {
|
|
1111
|
+
const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath));
|
|
1112
|
+
return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
|
|
1113
|
+
} catch (error) {
|
|
1114
|
+
if (error instanceof CodexHistoryIntegrityError) {
|
|
1115
|
+
return integrityFailureResult(error);
|
|
1116
|
+
}
|
|
1117
|
+
throw error;
|
|
1118
|
+
}
|
|
703
1119
|
}
|
|
704
1120
|
|
|
705
1121
|
function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbPath: string, backupPath: string): CodexHistorySyncResult {
|
|
706
|
-
if (!existsSync(stateDbPath))
|
|
1122
|
+
if (!existsSync(stateDbPath)) {
|
|
1123
|
+
const backup = readBackup(backupPath, stateDbPath);
|
|
1124
|
+
if (provider === "openai" && Object.keys(backup.manifest.entries).length > 0) {
|
|
1125
|
+
throw new CodexHistoryIntegrityError("history_state_database_missing");
|
|
1126
|
+
}
|
|
1127
|
+
return { rows: 0, files: 0 };
|
|
1128
|
+
}
|
|
707
1129
|
if (provider === "openai") return restoreCodexHistoryProvider(stateDbPath, backupPath);
|
|
708
1130
|
|
|
709
1131
|
const db = openStateDb(stateDbPath);
|
|
710
1132
|
try {
|
|
711
|
-
const placeholders =
|
|
1133
|
+
const placeholders = CODEX_HISTORY_RESUMABLE_SOURCES.map(() => "?").join(",");
|
|
712
1134
|
const openaiRows = db
|
|
713
|
-
.query<
|
|
714
|
-
SELECT id, rollout_path, model_provider, source, has_user_event
|
|
1135
|
+
.query<ApplyRowSnapshot, string[]>(`
|
|
1136
|
+
SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message
|
|
715
1137
|
FROM threads
|
|
716
1138
|
WHERE model_provider = 'openai'
|
|
717
1139
|
AND source IN (${placeholders})
|
|
718
1140
|
`)
|
|
719
|
-
.all(...
|
|
1141
|
+
.all(...CODEX_HISTORY_RESUMABLE_SOURCES);
|
|
720
1142
|
const execRows = db
|
|
721
|
-
.query<
|
|
722
|
-
SELECT id, rollout_path, model_provider, source, has_user_event
|
|
1143
|
+
.query<ApplyRowSnapshot, []>(`
|
|
1144
|
+
SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message
|
|
723
1145
|
FROM threads
|
|
724
1146
|
WHERE model_provider = 'opencodex'
|
|
725
1147
|
AND source = 'exec'
|
|
@@ -727,49 +1149,92 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP
|
|
|
727
1149
|
`)
|
|
728
1150
|
.all();
|
|
729
1151
|
|
|
730
|
-
const manifest = readBackup(backupPath, stateDbPath);
|
|
1152
|
+
const manifest = readBackup(backupPath, stateDbPath).manifest;
|
|
731
1153
|
for (const row of [...openaiRows, ...execRows]) rememberOriginal(manifest, row);
|
|
732
1154
|
writeBackup(backupPath, manifest, stateDbPath);
|
|
733
1155
|
|
|
734
1156
|
let files = 0;
|
|
735
|
-
for (const row of openaiRows) {
|
|
736
|
-
try {
|
|
737
|
-
if (updateSessionMeta(row.rollout_path, row.id, { provider: "opencodex" })) files++;
|
|
738
|
-
} catch {
|
|
739
|
-
/* best-effort; keep DB migration moving even if one old rollout is malformed */
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
for (const row of execRows) {
|
|
743
|
-
try {
|
|
744
|
-
if (updateSessionMeta(row.rollout_path, row.id, { source: "cli" })) files++;
|
|
745
|
-
} catch {
|
|
746
|
-
/* best-effort; keep DB migration moving even if one old rollout is malformed */
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
1157
|
const update = db.transaction(() => {
|
|
751
|
-
const
|
|
1158
|
+
const routeOpenai = db.query(`
|
|
752
1159
|
UPDATE threads
|
|
753
|
-
SET
|
|
1160
|
+
SET model_provider = 'opencodex',
|
|
1161
|
+
has_user_event = ?
|
|
754
1162
|
WHERE id = ?
|
|
755
|
-
AND
|
|
1163
|
+
AND rollout_path = ?
|
|
1164
|
+
AND model_provider = ?
|
|
1165
|
+
AND source = ?
|
|
1166
|
+
AND has_user_event = ?
|
|
1167
|
+
AND first_user_message IS ?
|
|
756
1168
|
`);
|
|
757
|
-
|
|
758
|
-
db.query(`
|
|
1169
|
+
const routeExec = db.query(`
|
|
759
1170
|
UPDATE threads
|
|
760
|
-
SET
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
AND source = 'exec'
|
|
1171
|
+
SET source = 'cli',
|
|
1172
|
+
has_user_event = 1
|
|
1173
|
+
WHERE id = ?
|
|
1174
|
+
AND rollout_path = ?
|
|
1175
|
+
AND model_provider = ?
|
|
1176
|
+
AND source = ?
|
|
1177
|
+
AND has_user_event = ?
|
|
1178
|
+
AND first_user_message IS ?
|
|
769
1179
|
AND trim(coalesce(first_user_message, '')) != ''
|
|
770
|
-
`)
|
|
1180
|
+
`);
|
|
1181
|
+
// CAS only the rows that were recorded in this manifest. A thread inserted after the
|
|
1182
|
+
// snapshot must stay native rather than becoming an untracked bare routed row.
|
|
1183
|
+
for (const row of openaiRows) {
|
|
1184
|
+
const targetEvent = hasFirstUserMessage(row.first_user_message) ? 1 : row.has_user_event;
|
|
1185
|
+
const result = routeOpenai.run(
|
|
1186
|
+
targetEvent,
|
|
1187
|
+
row.id,
|
|
1188
|
+
row.rollout_path,
|
|
1189
|
+
row.model_provider,
|
|
1190
|
+
row.source,
|
|
1191
|
+
row.has_user_event,
|
|
1192
|
+
row.first_user_message,
|
|
1193
|
+
);
|
|
1194
|
+
if (result.changes !== 1) {
|
|
1195
|
+
throw new CodexHistoryIntegrityError("history_apply_database_changed_during_route");
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
for (const row of execRows) {
|
|
1199
|
+
const result = routeExec.run(
|
|
1200
|
+
row.id,
|
|
1201
|
+
row.rollout_path,
|
|
1202
|
+
row.model_provider,
|
|
1203
|
+
row.source,
|
|
1204
|
+
row.has_user_event,
|
|
1205
|
+
row.first_user_message,
|
|
1206
|
+
);
|
|
1207
|
+
if (result.changes !== 1) {
|
|
1208
|
+
throw new CodexHistoryIntegrityError("history_apply_database_changed_during_route");
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
// File metadata remains best-effort, but only after every database CAS matched. Thus a
|
|
1213
|
+
// stale snapshot or a newly inserted row cannot be routed before its provenance exists.
|
|
1214
|
+
for (const row of openaiRows) {
|
|
1215
|
+
try {
|
|
1216
|
+
if (updateSessionMeta(row.rollout_path, row.id, { provider: "opencodex" }).changed) files++;
|
|
1217
|
+
} catch {
|
|
1218
|
+
/* keep DB migration moving; the manifest still carries exact original metadata */
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
for (const row of execRows) {
|
|
1222
|
+
try {
|
|
1223
|
+
if (updateSessionMeta(row.rollout_path, row.id, { source: "cli" }).changed) files++;
|
|
1224
|
+
} catch {
|
|
1225
|
+
/* keep DB migration moving; the manifest still carries exact original metadata */
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
771
1228
|
});
|
|
772
|
-
|
|
1229
|
+
try {
|
|
1230
|
+
beforeHistoryApplyTransactionForTests?.();
|
|
1231
|
+
update();
|
|
1232
|
+
} catch (error) {
|
|
1233
|
+
if (files > 0) {
|
|
1234
|
+
throw new CodexHistoryIntegrityError("history_apply_partial_route", { rows: 0, files });
|
|
1235
|
+
}
|
|
1236
|
+
throw error;
|
|
1237
|
+
}
|
|
773
1238
|
|
|
774
1239
|
return { rows: openaiRows.length + execRows.length, files };
|
|
775
1240
|
} finally {
|
|
@@ -778,26 +1243,24 @@ function syncCodexHistoryProviderUnsafe(provider: CodexHistoryProvider, stateDbP
|
|
|
778
1243
|
}
|
|
779
1244
|
|
|
780
1245
|
function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): CodexHistorySyncResult {
|
|
781
|
-
const
|
|
1246
|
+
const backup = readBackup(backupPath, stateDbPath);
|
|
1247
|
+
const manifest = backup.manifest;
|
|
782
1248
|
const entries = Object.values(manifest.entries);
|
|
783
1249
|
|
|
784
1250
|
const db = openStateDb(stateDbPath);
|
|
785
1251
|
try {
|
|
786
|
-
if (entries.length === 0) {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1252
|
+
if (entries.length === 0) return { rows: 0, files: 0 };
|
|
1253
|
+
|
|
1254
|
+
// Validate the whole manifest-to-database target set before touching a rollout. Only the
|
|
1255
|
+
// OpenCodex post-image (or an already-restored target from an interrupted retry) is owned by
|
|
1256
|
+
// this manifest. Any other tuple is a newer/foreign provider decision and must win.
|
|
1257
|
+
const current = db.query<RestoreRowSnapshot, [string]>(`
|
|
1258
|
+
SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message
|
|
1259
|
+
FROM threads WHERE id = ?
|
|
1260
|
+
`);
|
|
1261
|
+
const { snapshots, rolloutSnapshots } = preflightRestoreTargets(id => current.get(id), entries);
|
|
790
1262
|
|
|
791
1263
|
let files = 0;
|
|
792
|
-
for (const entry of entries) {
|
|
793
|
-
const target = toNativeRestoreTarget(entry);
|
|
794
|
-
try {
|
|
795
|
-
if (updateSessionMeta(entry.rolloutPath, entry.id, { provider: target.modelProvider, source: target.source })) files++;
|
|
796
|
-
} catch {
|
|
797
|
-
/* best-effort; keep DB restore moving even if one rollout disappeared */
|
|
798
|
-
}
|
|
799
|
-
}
|
|
800
|
-
|
|
801
1264
|
const restore = db.transaction(() => {
|
|
802
1265
|
const update = db.query(`
|
|
803
1266
|
UPDATE threads
|
|
@@ -805,18 +1268,101 @@ function restoreCodexHistoryProvider(stateDbPath: string, backupPath: string): C
|
|
|
805
1268
|
source = ?,
|
|
806
1269
|
has_user_event = ?
|
|
807
1270
|
WHERE id = ?
|
|
1271
|
+
AND rollout_path = ?
|
|
1272
|
+
AND model_provider = ?
|
|
1273
|
+
AND source = ?
|
|
1274
|
+
AND has_user_event = ?
|
|
1275
|
+
AND first_user_message IS ?
|
|
808
1276
|
`);
|
|
809
1277
|
for (const entry of entries) {
|
|
810
|
-
const
|
|
811
|
-
|
|
1278
|
+
const before = snapshots.get(entry.id);
|
|
1279
|
+
if (!before) throw new CodexHistoryIntegrityError("history_backup_snapshot_missing");
|
|
1280
|
+
const result = update.run(
|
|
1281
|
+
entry.modelProvider,
|
|
1282
|
+
entry.source,
|
|
1283
|
+
entry.hasUserEvent,
|
|
1284
|
+
entry.id,
|
|
1285
|
+
before.rollout_path,
|
|
1286
|
+
before.model_provider,
|
|
1287
|
+
before.source,
|
|
1288
|
+
before.has_user_event,
|
|
1289
|
+
before.first_user_message,
|
|
1290
|
+
);
|
|
1291
|
+
if (result.changes !== 1) {
|
|
1292
|
+
throw new CodexHistoryIntegrityError("history_backup_database_changed_during_restore");
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
// Only after every database CAS matched may a rollout move. Keeping the SQLite
|
|
1296
|
+
// transaction open means a file-side refusal rolls the database back, while the manifest
|
|
1297
|
+
// remains the durable retry journal for an exceptional I/O failure.
|
|
1298
|
+
for (const entry of entries) {
|
|
1299
|
+
const before = rolloutSnapshots.get(entry.id);
|
|
1300
|
+
if (!before) throw new CodexHistoryIntegrityError("history_backup_rollout_snapshot_missing");
|
|
1301
|
+
let updated: SessionMetaUpdateResult;
|
|
1302
|
+
try {
|
|
1303
|
+
updated = updateSessionMeta(
|
|
1304
|
+
entry.rolloutPath,
|
|
1305
|
+
entry.id,
|
|
1306
|
+
{ provider: entry.modelProvider, source: entry.source },
|
|
1307
|
+
{
|
|
1308
|
+
requireDurableProvider: true,
|
|
1309
|
+
expectedFileIdentity: before.identity,
|
|
1310
|
+
expectedLatestProvider: before.latestProvider,
|
|
1311
|
+
expectedLatestSource: before.latestSource,
|
|
1312
|
+
},
|
|
1313
|
+
);
|
|
1314
|
+
} catch (error) {
|
|
1315
|
+
if (historyFileIdentity(entry.rolloutPath) !== before.identity) files++;
|
|
1316
|
+
throw error;
|
|
1317
|
+
}
|
|
1318
|
+
if (updated.changed) files++;
|
|
1319
|
+
if (updated.conflict) {
|
|
1320
|
+
throw new CodexHistoryIntegrityError("history_backup_rollout_changed_during_restore");
|
|
1321
|
+
}
|
|
1322
|
+
if (!updated.durableProvider) {
|
|
1323
|
+
throw new CodexHistoryIntegrityError("history_backup_rollout_unrestorable");
|
|
1324
|
+
}
|
|
812
1325
|
}
|
|
813
1326
|
});
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
1327
|
+
try {
|
|
1328
|
+
restore();
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
if (files > 0) {
|
|
1331
|
+
throw new CodexHistoryIntegrityError("history_backup_partial_restore", { rows: 0, files });
|
|
1332
|
+
}
|
|
1333
|
+
throw error;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
try {
|
|
1337
|
+
const getCurrent = (id: string) => current.get(id);
|
|
1338
|
+
assertRestoreReadback(getCurrent, entries);
|
|
1339
|
+
beforeHistoryBackupConsumeForTests?.();
|
|
1340
|
+
// The hook models the exact last-moment race: neither a newer database decision nor a
|
|
1341
|
+
// same-id foreign session_meta may be hidden by deleting the only provenance manifest.
|
|
1342
|
+
assertRestoreReadback(getCurrent, entries);
|
|
1343
|
+
consumeBackupIfUnchanged(backupPath, stateDbPath, backup.fingerprint);
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
if (error instanceof CodexHistoryIntegrityError) {
|
|
1346
|
+
throw new CodexHistoryIntegrityError(error.message, { rows: entries.length, files });
|
|
1347
|
+
}
|
|
1348
|
+
const failureReason = classifyRecoverableHistoryError(error);
|
|
1349
|
+
if (failureReason) {
|
|
1350
|
+
return {
|
|
1351
|
+
rows: entries.length,
|
|
1352
|
+
files,
|
|
1353
|
+
failed: true,
|
|
1354
|
+
failureReason,
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
// Once exact targets were written, an unclassified finalization failure is an
|
|
1358
|
+
// applied-but-not-converged integrity state. Preserve that progress instead of
|
|
1359
|
+
// reporting a zero-change failure that invites an unsafe blind retry.
|
|
1360
|
+
throw new CodexHistoryIntegrityError("history_backup_finalization_failed", {
|
|
1361
|
+
rows: entries.length,
|
|
1362
|
+
files,
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
return { rows: entries.length, files };
|
|
820
1366
|
} finally {
|
|
821
1367
|
db.close();
|
|
822
1368
|
}
|
|
@@ -827,7 +1373,7 @@ export function restoreLegacyOpenaiHistory(stateDbPath = resolveCodexStateDbPath
|
|
|
827
1373
|
const retried = withHistoryRetryResult(() => {
|
|
828
1374
|
const db = openStateDb(stateDbPath);
|
|
829
1375
|
try {
|
|
830
|
-
return
|
|
1376
|
+
return relabelAllRoutedHistoryToOpenai(db);
|
|
831
1377
|
} finally {
|
|
832
1378
|
db.close();
|
|
833
1379
|
}
|
|
@@ -836,8 +1382,9 @@ export function restoreLegacyOpenaiHistory(stateDbPath = resolveCodexStateDbPath
|
|
|
836
1382
|
}
|
|
837
1383
|
|
|
838
1384
|
/**
|
|
839
|
-
* One-time Design-B migration: restore backed
|
|
840
|
-
* opencodex-tagged threads
|
|
1385
|
+
* One-time Design-B migration: restore only manifest-backed originals. Untracked
|
|
1386
|
+
* opencodex-tagged threads have unknown provider provenance and remain routed unless the
|
|
1387
|
+
* user explicitly invokes legacy OpenAI recovery. Thin wrapper over the restore path with a
|
|
841
1388
|
* configurable retry budget — the daemon migration guardian uses `{ attempts: 1 }`
|
|
842
1389
|
* per tick so a locked DB never stalls the event loop beyond one sqlite busy wait.
|
|
843
1390
|
*/
|
|
@@ -846,14 +1393,20 @@ export function migrateHistoryToOpenai(
|
|
|
846
1393
|
backupPath = historyBackupPathFor(stateDbPath),
|
|
847
1394
|
opts: { attempts?: number; delayMs?: number; sleepFn?: (ms: number) => void } = {},
|
|
848
1395
|
): CodexHistorySyncResult {
|
|
849
|
-
if (!existsSync(stateDbPath)) return { rows: 0, files: 0 };
|
|
850
1396
|
// Steady-state gate: this migration is Design-B-specific (inject + guardian callers),
|
|
851
1397
|
// and after the one-time migration every start would otherwise write-open the DB for
|
|
852
1398
|
// nothing. A missing DB with a leftover backup manifest does NOT satisfy the gate
|
|
853
1399
|
// (backupEntries > 0), so the guardian's fresh-reinstall re-count protection holds.
|
|
854
1400
|
if (openaiRestoreIsNoop(stateDbPath, backupPath)) return { rows: 0, files: 0 };
|
|
855
|
-
|
|
856
|
-
|
|
1401
|
+
try {
|
|
1402
|
+
const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts);
|
|
1403
|
+
return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
if (error instanceof CodexHistoryIntegrityError) {
|
|
1406
|
+
return integrityFailureResult(error);
|
|
1407
|
+
}
|
|
1408
|
+
throw error;
|
|
1409
|
+
}
|
|
857
1410
|
}
|
|
858
1411
|
|
|
859
1412
|
/**
|
|
@@ -870,7 +1423,7 @@ export function snapshotCodexHistoryNoop(
|
|
|
870
1423
|
const stateDbPresent = existsSync(stateDbPath);
|
|
871
1424
|
const backupPresent = existsSync(backupPath);
|
|
872
1425
|
const base = { canonicalStateDbPath, stateDbPresent, canonicalBackupPath, backupPresent };
|
|
873
|
-
if (!
|
|
1426
|
+
if (!sameCodexHistoryPath(backupPath, historyBackupPathFor(stateDbPath))) {
|
|
874
1427
|
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "backup-path" };
|
|
875
1428
|
}
|
|
876
1429
|
const backup = inspectBackupForNoop(backupPath, stateDbPath);
|
|
@@ -894,7 +1447,9 @@ export function snapshotCodexHistoryNoop(
|
|
|
894
1447
|
if (dataVersionBefore === null) {
|
|
895
1448
|
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
|
|
896
1449
|
}
|
|
897
|
-
const pending = countPendingOpencodexHistory(stateDbPath, backupPath
|
|
1450
|
+
const pending = countPendingOpencodexHistory(stateDbPath, backupPath, {
|
|
1451
|
+
validateRestoreTargets: false,
|
|
1452
|
+
});
|
|
898
1453
|
if (pending.failed) {
|
|
899
1454
|
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
|
|
900
1455
|
}
|
|
@@ -932,48 +1487,71 @@ export function snapshotCodexHistoryNoop(
|
|
|
932
1487
|
}
|
|
933
1488
|
|
|
934
1489
|
export interface PendingHistoryCount {
|
|
935
|
-
/**
|
|
1490
|
+
/** Compatibility field; bare routed rows are never automatic restore work. */
|
|
936
1491
|
pendingRows: number;
|
|
937
1492
|
/** Entries still recorded in the backup manifest (restore targets). */
|
|
938
1493
|
backupEntries: number;
|
|
939
|
-
/** Set when the DB could not be
|
|
1494
|
+
/** Set when the DB/manifest could not be read or their bound identity is invalid. */
|
|
940
1495
|
failed?: true;
|
|
1496
|
+
/** Distinguishes retryable contention/access from a manifest or target that needs review. */
|
|
1497
|
+
failureReason?: CodexHistoryFailureReason;
|
|
941
1498
|
}
|
|
942
1499
|
|
|
943
1500
|
/**
|
|
944
1501
|
* Read-only migration progress probe for the guardian and `ocx doctor`. Opens sqlite
|
|
945
|
-
* readonly with a SHORT busy timeout so a locked DB cannot stall a daemon tick.
|
|
946
|
-
*
|
|
947
|
-
*
|
|
1502
|
+
* readonly with a SHORT busy timeout so a locked DB cannot stall a daemon tick. Only a
|
|
1503
|
+
* valid, database-bound backup manifest is actionable work; bare routed rows remain
|
|
1504
|
+
* untouched because their original provider is not known. Operator diagnostics keep the
|
|
1505
|
+
* default deep rollout validation. Recurring no-op probes explicitly opt out because any
|
|
1506
|
+
* nonempty manifest already prevents a no-op and the mutation path always preflights files.
|
|
948
1507
|
*/
|
|
949
1508
|
export function countPendingOpencodexHistory(
|
|
950
1509
|
stateDbPath = resolveCodexStateDbPath(),
|
|
951
1510
|
backupPath = historyBackupPathFor(stateDbPath),
|
|
1511
|
+
opts: { validateRestoreTargets?: boolean } = {},
|
|
952
1512
|
): PendingHistoryCount {
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1513
|
+
const backup = readBackupStrict(backupPath, stateDbPath);
|
|
1514
|
+
if (backup.kind === "unknown") {
|
|
1515
|
+
return {
|
|
1516
|
+
pendingRows: 0,
|
|
1517
|
+
backupEntries: 0,
|
|
1518
|
+
failed: true,
|
|
1519
|
+
failureReason: backup.failureReason ?? "integrity",
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
const entries = Object.values(backup.manifest.entries);
|
|
1523
|
+
const backupEntries = entries.length;
|
|
958
1524
|
|
|
959
|
-
if (!existsSync(stateDbPath))
|
|
1525
|
+
if (!existsSync(stateDbPath)) {
|
|
1526
|
+
return backupEntries > 0
|
|
1527
|
+
? { pendingRows: 0, backupEntries, failed: true, failureReason: "integrity" }
|
|
1528
|
+
: { pendingRows: 0, backupEntries };
|
|
1529
|
+
}
|
|
960
1530
|
try {
|
|
961
1531
|
const db = new Database(stateDbPath, { readonly: true });
|
|
962
1532
|
try {
|
|
963
1533
|
db.exec("PRAGMA busy_timeout = 100");
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1534
|
+
// Prove the expected history schema is readable without counting unowned routed rows.
|
|
1535
|
+
db.query("SELECT 1 FROM threads LIMIT 1").get();
|
|
1536
|
+
if (entries.length > 0) {
|
|
1537
|
+
const current = db.query<RestoreRowSnapshot, [string]>(`
|
|
1538
|
+
SELECT id, rollout_path, model_provider, source, has_user_event, first_user_message
|
|
1539
|
+
FROM threads WHERE id = ?
|
|
1540
|
+
`);
|
|
1541
|
+
if (opts.validateRestoreTargets === false) {
|
|
1542
|
+
preflightRestoreRows(id => current.get(id), entries);
|
|
1543
|
+
} else {
|
|
1544
|
+
preflightRestoreTargets(id => current.get(id), entries);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
return { pendingRows: 0, backupEntries };
|
|
971
1548
|
} finally {
|
|
972
1549
|
db.close();
|
|
973
1550
|
}
|
|
974
1551
|
} catch (error) {
|
|
975
|
-
|
|
1552
|
+
const reason = classifyRecoverableHistoryError(error);
|
|
1553
|
+
if (reason) return { pendingRows: 0, backupEntries, failed: true, failureReason: reason };
|
|
976
1554
|
// Schema drift (e.g. a future codex renames the table) is a "cannot know" too, not a crash.
|
|
977
|
-
return { pendingRows: 0, backupEntries, failed: true };
|
|
1555
|
+
return { pendingRows: 0, backupEntries, failed: true, failureReason: "integrity" };
|
|
978
1556
|
}
|
|
979
1557
|
}
|