@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.
Files changed (261) hide show
  1. package/README.md +3 -3
  2. package/bin/ocx.mjs +99 -70
  3. package/gui/dist/assets/index-Be1sDpWw.css +1 -0
  4. package/gui/dist/assets/index-D4OKgUV4.js +114 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +3 -1
  7. package/src/adapters/anthropic.ts +104 -15
  8. package/src/adapters/base.ts +29 -1
  9. package/src/adapters/command-code.ts +42 -6
  10. package/src/adapters/cursor/call-id.ts +44 -0
  11. package/src/adapters/cursor/catalog.ts +541 -0
  12. package/src/adapters/cursor/checkpoint-store.ts +15 -10
  13. package/src/adapters/cursor/cursor-errors.ts +15 -0
  14. package/src/adapters/cursor/discovery.ts +69 -18
  15. package/src/adapters/cursor/effort-map.ts +79 -1
  16. package/src/adapters/cursor/envelope-echo.ts +290 -0
  17. package/src/adapters/cursor/live-models.ts +7 -2
  18. package/src/adapters/cursor/live-transport.ts +14 -0
  19. package/src/adapters/cursor/message-mapper.ts +4 -1
  20. package/src/adapters/cursor/native-exec-desktop.ts +23 -0
  21. package/src/adapters/cursor/native-exec-fs.ts +2 -2
  22. package/src/adapters/cursor/native-exec-network.ts +1 -1
  23. package/src/adapters/cursor/native-exec-shell.ts +5 -2
  24. package/src/adapters/cursor/native-exec.ts +20 -0
  25. package/src/adapters/cursor/protobuf-request.ts +68 -14
  26. package/src/adapters/cursor/request-builder.ts +40 -10
  27. package/src/adapters/cursor/tool-definitions.ts +14 -4
  28. package/src/adapters/cursor/tool-result-normalize.ts +28 -5
  29. package/src/adapters/cursor/types.ts +20 -0
  30. package/src/adapters/cursor.ts +209 -38
  31. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  32. package/src/adapters/google-antigravity-replay.ts +173 -25
  33. package/src/adapters/google-antigravity-wire.ts +5 -0
  34. package/src/adapters/google-errors.ts +44 -12
  35. package/src/adapters/google.ts +136 -24
  36. package/src/adapters/image.ts +1 -1
  37. package/src/adapters/kiro-constants.ts +27 -0
  38. package/src/adapters/kiro-tools.ts +55 -16
  39. package/src/adapters/kiro.ts +182 -23
  40. package/src/adapters/openai-chat.ts +300 -242
  41. package/src/adapters/openai-responses.ts +353 -27
  42. package/src/adapters/run-turn-queue.ts +36 -1
  43. package/src/adapters/tool-catalog-nudge.ts +4 -3
  44. package/src/adapters/xai-tool-schema.ts +436 -0
  45. package/src/adapters/xai-web-search.ts +17 -16
  46. package/src/bridge.ts +90 -37
  47. package/src/chat/inbound.ts +14 -1
  48. package/src/chat/outbound.ts +15 -7
  49. package/src/claude/agents-inject.ts +8 -1
  50. package/src/claude/context-windows.ts +16 -9
  51. package/src/claude/outbound.ts +24 -11
  52. package/src/cli/access.ts +46 -3
  53. package/src/cli/account-api.ts +116 -19
  54. package/src/cli/account-extended.ts +262 -29
  55. package/src/cli/account-main.ts +12 -12
  56. package/src/cli/account.ts +69 -15
  57. package/src/cli/agent.ts +8 -1
  58. package/src/cli/alias.ts +66 -0
  59. package/src/cli/capabilities-command.ts +94 -0
  60. package/src/cli/capabilities.ts +496 -0
  61. package/src/cli/claude-desktop.ts +31 -11
  62. package/src/cli/claude.ts +26 -1
  63. package/src/cli/dispatch.ts +256 -28
  64. package/src/cli/doctor.ts +126 -14
  65. package/src/cli/help.ts +17 -6
  66. package/src/cli/index.ts +39 -9
  67. package/src/cli/init.ts +1 -0
  68. package/src/cli/inspect.ts +230 -0
  69. package/src/cli/models-runtime.ts +95 -0
  70. package/src/cli/models.ts +26 -10
  71. package/src/cli/observe.ts +31 -8
  72. package/src/cli/provider-runtime.ts +16 -2
  73. package/src/cli/provider.ts +2 -1
  74. package/src/cli/registry.ts +49 -10
  75. package/src/cli/runtime-api.ts +51 -7
  76. package/src/cli/status.ts +41 -1
  77. package/src/cli/storage.ts +234 -0
  78. package/src/cli/system-command.ts +16 -0
  79. package/src/cli/system-restart-client.ts +1 -1
  80. package/src/cli/usage-report.ts +184 -0
  81. package/src/cli/v2.ts +34 -10
  82. package/src/cli/version-skew.ts +46 -0
  83. package/src/clients/config-export.ts +85 -3
  84. package/src/clients/effective-status.ts +30 -2
  85. package/src/clients/probes/cline.ts +81 -0
  86. package/src/codex/account-label.ts +21 -0
  87. package/src/codex/account-pause.ts +2 -1
  88. package/src/codex/account-priority.ts +3 -2
  89. package/src/codex/app-server-processes.ts +83 -7
  90. package/src/codex/auth-api.ts +50 -10
  91. package/src/codex/auth-context.ts +21 -18
  92. package/src/codex/autostart-health.ts +16 -0
  93. package/src/codex/catalog/aggregation.ts +18 -0
  94. package/src/codex/catalog/effort.ts +18 -3
  95. package/src/codex/catalog/metadata.ts +27 -1
  96. package/src/codex/catalog/native-models.ts +5 -2
  97. package/src/codex/catalog/parsing.ts +72 -27
  98. package/src/codex/catalog/provider-fetch.ts +187 -31
  99. package/src/codex/catalog/sync.ts +131 -5
  100. package/src/codex/catalog.ts +1 -1
  101. package/src/codex/codex-write-lock.ts +3 -1
  102. package/src/codex/convergence-types.ts +1 -1
  103. package/src/codex/convergence.ts +27 -2
  104. package/src/codex/desired-state.ts +2 -2
  105. package/src/codex/desktop-app-restart.ts +355 -0
  106. package/src/codex/history-job.ts +32 -3
  107. package/src/codex/history-manifest.ts +112 -0
  108. package/src/codex/history-migration-guardian.ts +5 -5
  109. package/src/codex/history-provider.ts +825 -247
  110. package/src/codex/history-worker.ts +8 -5
  111. package/src/codex/inject-coordination.ts +83 -0
  112. package/src/codex/inject.ts +63 -22
  113. package/src/codex/injected-marker.ts +1 -1
  114. package/src/codex/internal/history-writer.ts +4 -3
  115. package/src/codex/log-guard/inspect.ts +22 -4
  116. package/src/codex/model-entitlements.ts +9 -2
  117. package/src/codex/native-profile-startup.ts +157 -27
  118. package/src/codex/native-residue.ts +26 -33
  119. package/src/codex/prompt-layers.ts +371 -25
  120. package/src/codex/prompt-text-probe.ts +238 -0
  121. package/src/codex/quota.ts +91 -10
  122. package/src/codex/routing.ts +9 -0
  123. package/src/codex/shim.ts +56 -3
  124. package/src/codex/subagent-model-fallback.ts +104 -25
  125. package/src/codex/transition-state.ts +116 -8
  126. package/src/combos/failover.ts +27 -0
  127. package/src/combos/types.ts +10 -0
  128. package/src/compatibility/index.ts +26 -0
  129. package/src/compatibility/manifest.ts +253 -0
  130. package/src/compatibility/openai-responses.ts +121 -0
  131. package/src/config/atomic-write.ts +219 -0
  132. package/src/config/paths.ts +40 -0
  133. package/src/config/process-state.ts +309 -0
  134. package/src/config/provider-validation.ts +177 -0
  135. package/src/config/rebase-provenance.ts +68 -0
  136. package/src/config.ts +206 -824
  137. package/src/generated/compatibility-version.json +373 -213
  138. package/src/images/loop.ts +5 -4
  139. package/src/images/plan.ts +5 -4
  140. package/src/integrations/ownership-policy.ts +160 -0
  141. package/src/integrations/ownership.ts +46 -2
  142. package/src/integrations/registry.ts +7 -0
  143. package/src/integrations/state.ts +79 -7
  144. package/src/integrations/writer.ts +26 -2
  145. package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
  146. package/src/lab/fabric/producer-child.ts +1 -1
  147. package/src/lib/admin-secrets.ts +24 -0
  148. package/src/lib/bounded-body.ts +14 -2
  149. package/src/lib/config-ownership.ts +20 -0
  150. package/src/lib/errors.ts +36 -3
  151. package/src/lib/package-tree-integrity.ts +101 -0
  152. package/src/lib/process-control.ts +2 -1
  153. package/src/lib/service-secrets.ts +15 -0
  154. package/src/lib/tool-argument-integers.ts +56 -5
  155. package/src/oauth/callback-server.ts +22 -2
  156. package/src/oauth/cline.ts +203 -0
  157. package/src/oauth/generic-account-failover.ts +231 -0
  158. package/src/oauth/health.ts +1 -1
  159. package/src/oauth/index.ts +65 -6
  160. package/src/oauth/key-providers.ts +10 -1
  161. package/src/oauth/kimi.ts +9 -1
  162. package/src/oauth/kiro.ts +45 -0
  163. package/src/oauth/local-token-detect.ts +2 -0
  164. package/src/oauth/open-browser-choice.ts +26 -0
  165. package/src/oauth/store.ts +14 -5
  166. package/src/oauth/types.ts +15 -0
  167. package/src/providers/auto-compact-budget.ts +65 -0
  168. package/src/providers/command-code-efforts.ts +38 -6
  169. package/src/providers/context-cap.ts +4 -3
  170. package/src/providers/default-aliases.ts +65 -0
  171. package/src/providers/derive.ts +28 -0
  172. package/src/providers/fastwire.ts +7 -1
  173. package/src/providers/label.ts +34 -1
  174. package/src/providers/model-presets.ts +119 -0
  175. package/src/providers/new-model-policy.ts +146 -0
  176. package/src/providers/provider-id-rewrite.ts +2 -1
  177. package/src/providers/quota.ts +65 -7
  178. package/src/providers/registry.ts +184 -69
  179. package/src/providers/slug-codec.ts +52 -0
  180. package/src/providers/xai-transport.ts +21 -0
  181. package/src/reasoning-effort.ts +19 -2
  182. package/src/responses/apply-patch-envelope.ts +63 -0
  183. package/src/responses/code-mode-helper-compat.ts +50 -0
  184. package/src/responses/custom-tool-compat.ts +157 -39
  185. package/src/responses/namespace-tool-compat.ts +84 -4
  186. package/src/responses/parser.ts +7 -2
  187. package/src/responses/reasoning-replay-cache.ts +81 -3
  188. package/src/responses/schema.ts +5 -1
  189. package/src/responses/state.ts +94 -4
  190. package/src/responses/thought-signature-replay.ts +17 -0
  191. package/src/responses/turn-termination.ts +107 -0
  192. package/src/router.ts +40 -2
  193. package/src/routing/analytics.ts +1 -0
  194. package/src/routing/quota.ts +10 -0
  195. package/src/server/auth-cors.ts +27 -7
  196. package/src/server/chat-completions.ts +24 -16
  197. package/src/server/chat-native-sse.ts +3 -3
  198. package/src/server/chat-native.ts +30 -11
  199. package/src/server/claude-messages.ts +1 -1
  200. package/src/server/index.ts +179 -32
  201. package/src/server/lifecycle.ts +52 -1
  202. package/src/server/local-management-read-client.ts +1 -1
  203. package/src/server/local-provider-reload-client.ts +1 -1
  204. package/src/server/management/agent-settings-routes.ts +45 -23
  205. package/src/server/management/codex-prompt-routes.ts +570 -0
  206. package/src/server/management/combo-routes.ts +2 -1
  207. package/src/server/management/config-routes.ts +59 -12
  208. package/src/server/management/context.ts +10 -1
  209. package/src/server/management/logs-usage-routes.ts +30 -24
  210. package/src/server/management/model-routes.ts +274 -4
  211. package/src/server/management/model-rows.ts +4 -0
  212. package/src/server/management/native-integration-routes.ts +2 -1
  213. package/src/server/management/oauth-account-routes.ts +10 -4
  214. package/src/server/management/provider-capability-config.ts +1 -1
  215. package/src/server/management/provider-routes.ts +81 -11
  216. package/src/server/management/route-registry.ts +332 -0
  217. package/src/server/management/routing-profile-routes.ts +2 -2
  218. package/src/server/management/system-restart.ts +1 -1
  219. package/src/server/management-api.ts +18 -16
  220. package/src/server/port-reclaim.ts +1 -1
  221. package/src/server/proxy-liveness.ts +29 -5
  222. package/src/server/relay-eager.ts +147 -20
  223. package/src/server/relay.ts +251 -19
  224. package/src/server/request-log-conversation.ts +51 -0
  225. package/src/server/request-log.ts +70 -16
  226. package/src/server/responses/codex-auth-error.ts +55 -0
  227. package/src/server/responses/collaboration.ts +37 -3
  228. package/src/server/responses/combo-stream-preflight.ts +178 -0
  229. package/src/server/responses/compact.ts +6 -21
  230. package/src/server/responses/core.ts +810 -208
  231. package/src/server/responses/empty-completion-guard.ts +63 -6
  232. package/src/server/responses/fetch-helpers.ts +17 -102
  233. package/src/server/responses/input-admission.ts +3 -1
  234. package/src/server/responses/passthrough-error.ts +33 -9
  235. package/src/server/responses/responses-field-backfill.ts +105 -13
  236. package/src/server/responses/ws-upstream.ts +75 -2
  237. package/src/server/responses-custom-tool-repair.ts +87 -6
  238. package/src/server/responses-terminal-repair.ts +25 -4
  239. package/src/server/responses-undeclared-tool-guard.ts +241 -18
  240. package/src/server/sse-frame-buffer.ts +31 -4
  241. package/src/server/ws-bridge.ts +14 -2
  242. package/src/service.ts +42 -4
  243. package/src/storage/policy-job.ts +14 -4
  244. package/src/storage/policy.ts +88 -23
  245. package/src/types/config.ts +66 -0
  246. package/src/types/provider.ts +69 -0
  247. package/src/types/request.ts +17 -4
  248. package/src/types/tools.ts +116 -11
  249. package/src/types.ts +3 -1
  250. package/src/update/index.ts +5 -4
  251. package/src/update/job.ts +3 -1
  252. package/src/update/transactional-install.mjs +8 -1
  253. package/src/usage/expected-prices.ts +43 -24
  254. package/src/usage/log.ts +63 -12
  255. package/src/usage/summary.ts +211 -8
  256. package/src/vision/describe.ts +18 -13
  257. package/src/web-search/exa-executor.ts +40 -9
  258. package/src/web-search/executor.ts +10 -3
  259. package/src/web-search/loop.ts +5 -4
  260. package/gui/dist/assets/index-BF38heuV.js +0 -104
  261. 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
- const normalized = process.platform === "win32" ? resolve(stateDbPath).toLowerCase() : resolve(stateDbPath);
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): void {
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
- * Only length-preserving shrinks are handled (e.g. "opencodex" -> "openai"); callers that would
102
- * grow the value fall back to append-only, which is correct for the opencodex direction.
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
- * Returns true when line 1 was patched, false when it could not be done safely (missing file,
105
- * non-`session_meta` first line, id mismatch, value already correct, or a length-growing change).
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
- function patchFirstLineProviderInPlace(path: string, expectedId: string, provider: string): boolean {
108
- if (!existsSync(path)) return false;
109
- const fd = openSync(path, "r+");
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
- // Read the first line by growing the probe until we hit a newline. session_meta lines embed
112
- // base_instructions and can be tens of KB; a fixed cap would silently skip the in-place patch
113
- // (and fall back to append-only, re-opening the first-line-clone resurrection gap), so we read
114
- // until the line actually ends rather than guessing a ceiling.
115
- const CHUNK = 1 << 16;
116
- const MAX_FIRST_LINE = 1 << 24; // 16 MiB hard stop so a newline-less/corrupt file can't OOM us.
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 false;
134
- if (meta.record.payload.id !== expectedId) return false;
135
- if (meta.record.payload.model_provider === provider) return false;
136
-
137
- // Locate the exact `"model_provider":"<value>"` token (allowing whitespace after the colon).
138
- const match = firstLine.match(/"model_provider"\s*:\s*"([^"\\]*)"/);
139
- if (!match || match.index === undefined) return false;
140
- const oldToken = match[0];
141
- const newCore = `"model_provider":"${provider}"`;
142
- if (Buffer.byteLength(newCore, "utf8") > Buffer.byteLength(oldToken, "utf8")) return false; // grow: not length-preserving
143
- const pad = " ".repeat(Buffer.byteLength(oldToken, "utf8") - Buffer.byteLength(newCore, "utf8"));
144
- const newToken = `${newCore}${pad}`;
145
-
146
- const patchedLine = firstLine.slice(0, match.index) + newToken + firstLine.slice(match.index + oldToken.length);
147
- // Length must be identical so the trailing bytes (newline + rest of file) are untouched.
148
- if (Buffer.byteLength(patchedLine, "utf8") !== Buffer.byteLength(firstLine, "utf8")) return false;
149
- // Sanity: the patched line must still parse and carry the new provider.
150
- const reparsed = parseSessionMetaLine(patchedLine);
151
- if (!reparsed || reparsed.record.payload.model_provider !== provider) return false;
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 true;
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 BackupEntry {
188
- id: string;
189
- rolloutPath: string;
190
- modelProvider: string;
191
- source: string;
192
- hasUserEvent: number;
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
- interface BackupManifest {
196
- version: 1;
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
- interface NativeRestoreTarget {
245
- modelProvider: string;
246
- source: string;
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
- function samePath(a: string, b: string): boolean {
251
- const left = resolve(a);
252
- const right = resolve(b);
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
- function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupInspection {
257
- if (!existsSync(path)) return { kind: "known", present: false, entries: 0, fingerprint: "absent" };
258
- let parsed: unknown;
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
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
267
- return { kind: "unknown", present: true, reason: "manifest-schema" };
268
- }
269
- const manifest = parsed as Partial<BackupManifest>;
270
- if (manifest.version !== 1 || typeof manifest.stateDbPath !== "string") {
271
- return { kind: "unknown", present: true, reason: "manifest-schema" };
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
- entries: Object.keys(manifest.entries).length,
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?: string): BackupManifest {
316
- if (!existsSync(path)) return { version: 1, stateDbPath, entries: {} };
317
- try {
318
- const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<BackupManifest>;
319
- if (parsed.version !== 1 || !parsed.entries || typeof parsed.entries !== "object") {
320
- return { version: 1, stateDbPath, entries: {} };
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
- if (stateDbPath && typeof parsed.stateDbPath === "string" && !samePath(parsed.stateDbPath, stateDbPath)) {
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 writeBackup(path: string, manifest: BackupManifest, stateDbPath?: string): void {
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: BackupManifest, row: ThreadRow): void {
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) || 0,
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. Returns false when the
527
- * rollout is missing, has no parseable `session_meta`, its latest `session_meta` belongs to a
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
- function updateSessionMeta(path: string, expectedId: string, patch: { provider?: string; source?: string }): boolean {
531
- if (!path || !existsSync(path)) return false;
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) return false;
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
- if (!changed) return false;
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, so for a length-preserving provider change we
558
- // also patch line 1 in place (no inode swap, no truncate). Best-effort: when it can't be done
559
- // safely (e.g. a length-growing change), the trailing append below is still correct for the
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 { patchFirstLineProviderInPlace(path, expectedId, patch.provider); } catch { /* best-effort line-1 patch */ }
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
- // Refresh the line timestamp so the appended record reads as the newest metadata.
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 ejectRemainingOpencodexHistory(db: Database): { rows: number; files: number } {
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
- /* native restore should continue even if an old rollout is missing */
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 openai-direction restore would be a no-op:
677
- * zero threads still tagged opencodex AND an empty backup manifest. Used to skip the
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
- const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe(provider, stateDbPath, backupPath));
702
- return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
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)) return { rows: 0, files: 0 };
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 = RESUMABLE_SOURCES.map(() => "?").join(",");
1133
+ const placeholders = CODEX_HISTORY_RESUMABLE_SOURCES.map(() => "?").join(",");
712
1134
  const openaiRows = db
713
- .query<ThreadRow, string[]>(`
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(...RESUMABLE_SOURCES);
1141
+ .all(...CODEX_HISTORY_RESUMABLE_SOURCES);
720
1142
  const execRows = db
721
- .query<ThreadRow, []>(`
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 markUserEvent = db.query(`
1158
+ const routeOpenai = db.query(`
752
1159
  UPDATE threads
753
- SET has_user_event = 1
1160
+ SET model_provider = 'opencodex',
1161
+ has_user_event = ?
754
1162
  WHERE id = ?
755
- AND trim(coalesce(first_user_message, '')) != ''
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
- for (const row of [...openaiRows, ...execRows]) markUserEvent.run(row.id);
758
- db.query(`
1169
+ const routeExec = db.query(`
759
1170
  UPDATE threads
760
- SET model_provider = 'opencodex'
761
- WHERE model_provider = 'openai'
762
- AND source IN (${placeholders})
763
- `).run(...RESUMABLE_SOURCES);
764
- db.query(`
765
- UPDATE threads
766
- SET source = 'cli'
767
- WHERE model_provider = 'opencodex'
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
- `).run();
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
- update();
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 manifest = readBackup(backupPath, stateDbPath);
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
- const ejected = ejectRemainingOpencodexHistory(db);
788
- return ejected.rows > 0 ? { rows: 0, files: ejected.files, ejectedRows: ejected.rows } : { rows: 0, files: 0 };
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 target = toNativeRestoreTarget(entry);
811
- update.run(target.modelProvider, target.source, target.hasUserEvent, entry.id);
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
- restore();
815
- writeBackup(backupPath, { version: 1, stateDbPath, entries: {} }, stateDbPath);
816
- const ejected = ejectRemainingOpencodexHistory(db);
817
- return ejected.rows > 0
818
- ? { rows: entries.length, files: files + ejected.files, ejectedRows: ejected.rows }
819
- : { rows: entries.length, files };
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 ejectRemainingOpencodexHistory(db);
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-up originals, then eject any remaining
840
- * opencodex-tagged threads to openai. Thin wrapper over the restore path with a
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
- const retried = withHistoryRetryResult(() => syncCodexHistoryProviderUnsafe("openai", stateDbPath, backupPath), opts);
856
- return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
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 (!samePath(backupPath, historyBackupPathFor(stateDbPath))) {
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
- /** Threads still tagged opencodex that the eject path WOULD move (mirrors its WHERE). */
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 opened/read (locked); counts are then unknown, not zero. */
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. The
946
- * pending predicate mirrors ejectRemainingOpencodexHistory exactly rows eject ignores
947
- * (empty first_user_message) are not counted, so 0 really means "migration done".
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
- let backupEntries = 0;
954
- try {
955
- const manifest = readBackup(backupPath, stateDbPath);
956
- backupEntries = Object.keys(manifest.entries).length;
957
- } catch { /* unreadable manifest counts as 0 — restore treats it the same way */ }
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)) return { pendingRows: 0, backupEntries };
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
- const row = db.query<{ n: number }, []>(`
965
- SELECT count(*) AS n
966
- FROM threads
967
- WHERE model_provider = 'opencodex'
968
- AND trim(coalesce(first_user_message, '')) != ''
969
- `).get();
970
- return { pendingRows: row?.n ?? 0, backupEntries };
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
- if (isRecoverableHistoryError(error)) return { pendingRows: 0, backupEntries, failed: true };
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
  }