@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913

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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-D7ynYo2K.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-CWXut3rG.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -0,0 +1,932 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { basename, join } from "node:path";
12
+ import { randomUUID } from "node:crypto";
13
+ import { Database } from "bun:sqlite";
14
+ import { resolveCodexHomeDir } from "../../codex/home";
15
+ import { readThreadFieldsFromRollout } from "../../codex/history-provider";
16
+ import { discoverRuntimeDbPaths, insertRowsConflictIgnore, isSqlRowArray, mapDbError, probeStateDbWritable, tableColumnNames, tableExists, withWritableDb } from "./db";
17
+ import type { ReconcileErr, SqlRow } from "./db";
18
+ import { ARCHIVED_SESSIONS_DIR, TRASH_DIR, TRASH_EPOCH_DIR, ZST_SUFFIX, isExistError, isRolloutFileName, isSafeArchivedPhysicalRel, renameNoReplace, toForwardSlash } from "./paths";
19
+ import { RESTORE_PENDING_FILE, readRestorePending, writeRestorePending } from "./pending";
20
+ import type { RestorePendingSections, RestorePendingState } from "./pending";
21
+ import { SATELLITE_BACKUP_FILE, beginSatelliteWriteLocks, commitSatelliteLock, readSatelliteBackupFile, remapSatelliteBackupPaths, restoreConsolidateGlobalJob, rollbackAllSatelliteLocks } from "./satellite";
22
+ import type { SatelliteBackup, SatelliteWriteLocks } from "./satellite";
23
+ import { absFromRel, removeEmptyTrashRoot } from "./staging";
24
+ import type { StagedFile } from "./staging";
25
+ import type { CleanupManifestEntry, CleanupMode, RestoreErrorCode, RestoreResult, TrashEntrySummary } from "./types";
26
+
27
+ interface TrashManifest {
28
+ quarantinedAt?: number;
29
+ mode?: CleanupMode;
30
+ entries?: CleanupManifestEntry[];
31
+ }
32
+
33
+ /**
34
+ * Parse a trash `manifest.json` atomically.
35
+ *
36
+ * Any missing `entries` array, or any malformed entry / `physicalRelPaths` value /
37
+ * required field, rejects the **entire** manifest (returns null). Individual bad
38
+ * entries are never filtered out so a partial parse cannot silently drop evidence.
39
+ */
40
+ function parseTrashManifest(raw: string): TrashManifest | null {
41
+ try {
42
+ const parsed = JSON.parse(raw) as unknown;
43
+ if (!parsed || typeof parsed !== "object") return null;
44
+ const o = parsed as Record<string, unknown>;
45
+ if (!Array.isArray(o.entries)) return null;
46
+
47
+ const entries: CleanupManifestEntry[] = [];
48
+ for (const e of o.entries) {
49
+ if (!e || typeof e !== "object" || Array.isArray(e)) return null;
50
+ const entry = e as Record<string, unknown>;
51
+ if (typeof entry.relPath !== "string" || entry.relPath.length === 0) return null;
52
+ if (typeof entry.bytes !== "number" || !Number.isFinite(entry.bytes)) return null;
53
+ if (typeof entry.mtimeMs !== "number" || !Number.isFinite(entry.mtimeMs)) return null;
54
+ if (!Array.isArray(entry.physicalRelPaths) || entry.physicalRelPaths.length === 0) return null;
55
+ const physical: string[] = [];
56
+ for (const p of entry.physicalRelPaths) {
57
+ // Do not strip bad elements — one malformed path invalidates the whole manifest.
58
+ if (typeof p !== "string" || p.length === 0) return null;
59
+ physical.push(p);
60
+ }
61
+ if ("threadId" in entry && typeof entry.threadId !== "string") return null;
62
+ if ("rolloutPath" in entry && typeof entry.rolloutPath !== "string") return null;
63
+ if (
64
+ "archived" in entry
65
+ && entry.archived !== null
66
+ && typeof entry.archived !== "number"
67
+ ) {
68
+ return null;
69
+ }
70
+ entries.push({
71
+ relPath: entry.relPath,
72
+ bytes: entry.bytes,
73
+ mtimeMs: entry.mtimeMs,
74
+ physicalRelPaths: physical,
75
+ ...(typeof entry.threadId === "string" ? { threadId: entry.threadId } : {}),
76
+ ...(typeof entry.rolloutPath === "string" ? { rolloutPath: entry.rolloutPath } : {}),
77
+ ...(entry.archived === null || typeof entry.archived === "number"
78
+ ? { archived: entry.archived as number | null }
79
+ : {}),
80
+ });
81
+ }
82
+
83
+ const out: TrashManifest = { entries };
84
+ if (typeof o.quarantinedAt === "number" && Number.isFinite(o.quarantinedAt)) {
85
+ out.quarantinedAt = o.quarantinedAt;
86
+ }
87
+ if (o.mode === "quarantine" || o.mode === "permanent") out.mode = o.mode;
88
+ return out;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Validate a trash entry id as a single `.trash/<epoch>` segment under CODEX_HOME.
96
+ * Returns the absolute stage directory, or null when the id is unsafe / missing.
97
+ */
98
+ export function resolveTrashStageDir(
99
+ trashId: string,
100
+ codexHome: string,
101
+ ): { ok: true; stageDir: string; id: string } | { ok: false; error: RestoreErrorCode } {
102
+ const normalized = toForwardSlash(trashId.trim()).replace(/\/+$/, "");
103
+ if (!normalized.startsWith(`${TRASH_DIR}/`)) return { ok: false, error: "invalid_trash" };
104
+ const rest = normalized.slice(TRASH_DIR.length + 1);
105
+ if (!rest || rest.includes("/") || rest.includes("\\") || rest.includes("..")) {
106
+ return { ok: false, error: "invalid_trash" };
107
+ }
108
+ if (!TRASH_EPOCH_DIR.test(rest)) return { ok: false, error: "invalid_trash" };
109
+ let stageDir: string;
110
+ try {
111
+ stageDir = absFromRel(codexHome, `${TRASH_DIR}/${rest}`);
112
+ } catch {
113
+ return { ok: false, error: "invalid_trash" };
114
+ }
115
+ if (!existsSync(stageDir)) return { ok: false, error: "missing_trash" };
116
+ try {
117
+ if (!statSync(stageDir).isDirectory()) return { ok: false, error: "invalid_trash" };
118
+ } catch {
119
+ return { ok: false, error: "missing_trash" };
120
+ }
121
+ return { ok: true, stageDir, id: `${TRASH_DIR}/${rest}` };
122
+ }
123
+
124
+ function sumTrashEntryBytes(stageDir: string, manifest: TrashManifest | null): {
125
+ fileCount: number;
126
+ bytes: number;
127
+ } {
128
+ let fileCount = 0;
129
+ let bytes = 0;
130
+ let names: string[] = [];
131
+ try {
132
+ names = readdirSync(stageDir);
133
+ } catch {
134
+ return { fileCount: 0, bytes: 0 };
135
+ }
136
+ for (const name of names) {
137
+ if (
138
+ name === "manifest.json"
139
+ || name === SATELLITE_BACKUP_FILE
140
+ || name === RESTORE_PENDING_FILE
141
+ ) {
142
+ continue;
143
+ }
144
+ if (!isRolloutFileName(name)) continue;
145
+ try {
146
+ const st = statSync(join(stageDir, name));
147
+ if (!st.isFile()) continue;
148
+ fileCount += 1;
149
+ bytes += st.size;
150
+ } catch { /* */ }
151
+ }
152
+ // Prefer live FS counts; fall back to manifest totals when the stage is empty of rollouts.
153
+ if (fileCount === 0 && manifest?.entries?.length) {
154
+ fileCount = manifest.entries.reduce((n, e) => n + Math.max(1, e.physicalRelPaths.length), 0);
155
+ bytes = manifest.entries.reduce((n, e) => n + (e.bytes || 0), 0);
156
+ }
157
+ return { fileCount, bytes };
158
+ }
159
+
160
+ /** List quarantine entries under `CODEX_HOME/.trash/` (relative ids only). */
161
+ export function listTrashEntries(
162
+ codexHome: string = resolveCodexHomeDir(),
163
+ ): TrashEntrySummary[] {
164
+ const trashRoot = join(codexHome, TRASH_DIR);
165
+ let names: string[] = [];
166
+ try {
167
+ names = readdirSync(trashRoot);
168
+ } catch {
169
+ return [];
170
+ }
171
+ const out: TrashEntrySummary[] = [];
172
+ for (const name of names) {
173
+ if (!TRASH_EPOCH_DIR.test(name)) continue;
174
+ const stageDir = join(trashRoot, name);
175
+ try {
176
+ if (!statSync(stageDir).isDirectory()) continue;
177
+ } catch {
178
+ continue;
179
+ }
180
+ let manifest: TrashManifest | null = null;
181
+ try {
182
+ manifest = parseTrashManifest(readFileSync(join(stageDir, "manifest.json"), "utf8"));
183
+ } catch {
184
+ manifest = null;
185
+ }
186
+ const { fileCount, bytes } = sumTrashEntryBytes(stageDir, manifest);
187
+ // Skip empty collision placeholders left behind without a manifest or rollouts.
188
+ if (fileCount === 0 && !manifest?.entries?.length) {
189
+ try {
190
+ if (!existsSync(join(stageDir, "manifest.json"))) continue;
191
+ } catch {
192
+ continue;
193
+ }
194
+ }
195
+ out.push({
196
+ id: `${TRASH_DIR}/${name}`,
197
+ epoch: name,
198
+ fileCount,
199
+ bytes,
200
+ ...(manifest?.quarantinedAt !== undefined ? { quarantinedAt: manifest.quarantinedAt } : {}),
201
+ ...(manifest?.mode ? { mode: manifest.mode } : {}),
202
+ });
203
+ }
204
+ out.sort((a, b) => {
205
+ const aq = a.quarantinedAt ?? (Number(a.epoch.split("-")[0]) || 0);
206
+ const bq = b.quarantinedAt ?? (Number(b.epoch.split("-")[0]) || 0);
207
+ return bq - aq || b.epoch.localeCompare(a.epoch);
208
+ });
209
+ return out;
210
+ }
211
+
212
+ /** True when a snapshotted thread row covers every NOT NULL column on the live schema. */
213
+ function threadSnapshotCoversRequiredColumns(row: SqlRow, requiredCols: string[]): boolean {
214
+ for (const col of requiredCols) {
215
+ if (!(col in row) || row[col] === undefined) return false;
216
+ }
217
+ return true;
218
+ }
219
+
220
+ function requiredThreadColumnNames(db: Database): string[] {
221
+ if (!tableExists(db, "threads")) return [];
222
+ const rows = db.query<{ name: string; notnull: number }, []>(
223
+ `PRAGMA table_info("threads")`,
224
+ ).all();
225
+ return rows.filter(r => r.notnull === 1).map(r => r.name);
226
+ }
227
+
228
+ /**
229
+ * Build a production-shaped thread row for schemas that predate full satellite snapshots.
230
+ * Prefer `readThreadFieldsFromRollout` (canonical history/session_meta path); fall back to
231
+ * the sparse manifest fields only when the live schema does not require model/source/message.
232
+ */
233
+ function reconstructThreadRowFromRollout(
234
+ entry: CleanupManifestEntry,
235
+ rolloutAbsPath: string,
236
+ allowedCols: Set<string>,
237
+ requiredCols: string[],
238
+ ): SqlRow | null {
239
+ if (typeof entry.threadId !== "string" || typeof entry.rolloutPath !== "string") return null;
240
+
241
+ const fields = readThreadFieldsFromRollout(rolloutAbsPath);
242
+ const row: SqlRow = {
243
+ id: entry.threadId,
244
+ rollout_path: entry.rolloutPath,
245
+ };
246
+
247
+ if (fields) {
248
+ // Prefer manifest thread id (binding) but keep rollout-derived listing fields.
249
+ if (allowedCols.has("model_provider")) row.model_provider = fields.modelProvider;
250
+ if (allowedCols.has("source")) row.source = fields.source;
251
+ if (allowedCols.has("first_user_message")) row.first_user_message = fields.firstUserMessage;
252
+ if (allowedCols.has("has_user_event")) row.has_user_event = fields.hasUserEvent;
253
+ if (allowedCols.has("cwd") && fields.cwd !== undefined) row.cwd = fields.cwd;
254
+ if (allowedCols.has("history_mode") && fields.historyMode !== undefined) {
255
+ row.history_mode = fields.historyMode;
256
+ }
257
+ if (allowedCols.has("cli_version") && fields.cliVersion !== undefined) {
258
+ row.cli_version = fields.cliVersion;
259
+ }
260
+ }
261
+
262
+ if (allowedCols.has("archived")) {
263
+ row.archived = entry.archived ?? 1;
264
+ }
265
+ if (allowedCols.has("archived_at")) {
266
+ row.archived_at = null;
267
+ }
268
+
269
+ // Fill remaining NOT NULL columns with safe empties when the rollout lacked them
270
+ // (e.g. fixture rollouts without a user turn still need first_user_message = '').
271
+ for (const col of requiredCols) {
272
+ if (row[col] !== undefined) continue;
273
+ if (col === "id" || col === "rollout_path") continue;
274
+ if (col === "model_provider") row[col] = "openai";
275
+ else if (col === "source") row[col] = "cli";
276
+ else if (col === "first_user_message") row[col] = "";
277
+ else if (col === "has_user_event") row[col] = 0;
278
+ else if (col === "archived") row[col] = entry.archived ?? 1;
279
+ else return null; // unknown required column we cannot invent
280
+ }
281
+
282
+ // If the schema requires listing fields, refuse when the rollout was unreadable.
283
+ const needsSessionMeta = requiredCols.some(
284
+ c => c === "model_provider" || c === "source" || c === "first_user_message",
285
+ );
286
+ if (needsSessionMeta && !fields) return null;
287
+
288
+ return row;
289
+ }
290
+
291
+ function restoreThreadsFromManifest(
292
+ stateDbPath: string | null,
293
+ entries: CleanupManifestEntry[],
294
+ backup: SatelliteBackup | null,
295
+ busyTimeoutMs: number,
296
+ codexHome: string,
297
+ ): { ok: true } | ReconcileErr {
298
+ const manifestThreadIds = entries
299
+ .map(e => e.threadId)
300
+ .filter((id): id is string => typeof id === "string");
301
+ const backupThreadIds = backup?.threadIds ?? [];
302
+ const needsThreads = manifestThreadIds.length > 0
303
+ || backupThreadIds.length > 0
304
+ || Boolean(backup?.threads?.length);
305
+
306
+ if (needsThreads && (!stateDbPath || !existsSync(stateDbPath))) {
307
+ return { ok: false, error: "db_reconcile_failed" };
308
+ }
309
+ if (!stateDbPath || !existsSync(stateDbPath)) {
310
+ return { ok: true };
311
+ }
312
+
313
+ const result = withWritableDb(stateDbPath, busyTimeoutMs, db => {
314
+ if (!tableExists(db, "threads")) throw new Error("missing_threads_table");
315
+
316
+ const requiredCols = requiredThreadColumnNames(db);
317
+ const allowedCols = tableColumnNames(db, "threads");
318
+ const snapshotThreads = backup?.threads && isSqlRowArray(backup.threads)
319
+ ? backup.threads
320
+ : [];
321
+ const completeSnapshots = snapshotThreads.filter(row =>
322
+ threadSnapshotCoversRequiredColumns(row, requiredCols),
323
+ );
324
+ const coveredIds = new Set(
325
+ completeSnapshots
326
+ .map(r => r.id)
327
+ .filter((id): id is string => typeof id === "string"),
328
+ );
329
+
330
+ // Legacy Phase-2 quarantine (no / incomplete satellite thread snapshots): reconstruct
331
+ // every required column from the restored rollout via the history-provider session path.
332
+ const toReconstruct = entries.filter(
333
+ e => typeof e.threadId === "string"
334
+ && typeof e.rolloutPath === "string"
335
+ && !coveredIds.has(e.threadId!),
336
+ );
337
+ const reconstructed: SqlRow[] = [];
338
+ for (const entry of toReconstruct) {
339
+ let abs: string | undefined;
340
+ try {
341
+ abs = absFromRel(codexHome, entry.rolloutPath!);
342
+ } catch {
343
+ abs = undefined;
344
+ }
345
+ // Legacy compressed-only quarantine: manifest rolloutPath is often the logical
346
+ // `.jsonl` name while the only restored physical file is `.jsonl.zst`.
347
+ if (!abs || !existsSync(abs)) {
348
+ for (const rel of entry.physicalRelPaths) {
349
+ try {
350
+ const candidate = absFromRel(codexHome, rel);
351
+ if (existsSync(candidate)) {
352
+ abs = candidate;
353
+ break;
354
+ }
355
+ } catch {
356
+ /* try next physical path */
357
+ }
358
+ }
359
+ }
360
+ if (!abs) throw new Error("missing_rollout_for_thread");
361
+ // Prefer a plain .jsonl sibling when present; otherwise readThreadFieldsFromRollout
362
+ // decompresses a lone .jsonl.zst in memory (bounded) for legacy quarantine restores.
363
+ if (abs.endsWith(ZST_SUFFIX)) {
364
+ const plain = abs.slice(0, -".zst".length);
365
+ if (existsSync(plain)) abs = plain;
366
+ }
367
+ const row = reconstructThreadRowFromRollout(entry, abs, allowedCols, requiredCols);
368
+ if (!row) throw new Error("thread_reconstruct_failed");
369
+ reconstructed.push(row);
370
+ }
371
+
372
+ if (completeSnapshots.length > 0) {
373
+ insertRowsConflictIgnore(db, "threads", completeSnapshots);
374
+ }
375
+ if (reconstructed.length > 0) {
376
+ insertRowsConflictIgnore(db, "threads", reconstructed);
377
+ }
378
+
379
+ if (backup?.dynamicTools && isSqlRowArray(backup.dynamicTools) && tableExists(db, "thread_dynamic_tools")) {
380
+ insertRowsConflictIgnore(db, "thread_dynamic_tools", backup.dynamicTools);
381
+ }
382
+ if (backup?.spawnEdges && isSqlRowArray(backup.spawnEdges) && tableExists(db, "thread_spawn_edges")) {
383
+ insertRowsConflictIgnore(db, "thread_spawn_edges", backup.spawnEdges);
384
+ }
385
+ });
386
+ if (!result.ok) return result;
387
+ return { ok: true };
388
+ }
389
+
390
+ /** Test-only failure injection for restore atomicity regressions. */
391
+ export interface RestoreTestHooks {
392
+ /** After state threads/dependents commit, before satellite commits. */
393
+ failAfterStateCommit?: boolean;
394
+ /** After the first satellite DB commit (logs → memories → goals). */
395
+ failAfterFirstSatelliteCommit?: boolean;
396
+ /** When the leftover staged-rollout completeness gate runs. */
397
+ failAtLeftoverStageGate?: boolean;
398
+ /** Fail the initial restore-pending.json write (before any file moves). */
399
+ failInitialPendingWrite?: boolean;
400
+ /** Fail a later pending update after the temp is written but before rename. */
401
+ failPendingWriteBeforeRename?: boolean;
402
+ /** Crash immediately after file moves (marker already durable). */
403
+ failAfterFileMoves?: boolean;
404
+ /**
405
+ * After this many successful rollout moves in the current attempt, throw.
406
+ * Exercises mid-loop failure with some dests placed and others still staged.
407
+ */
408
+ failAfterMoveCount?: number;
409
+ /** Fail renaming the completed stage to a non-listable tombstone dir. */
410
+ failStageTombstoneRename?: boolean;
411
+ /** After tombstone rename, skip best-effort tombstone delete (orphan is OK). */
412
+ failTombstoneDelete?: boolean;
413
+ /**
414
+ * Test-only: spin-wait this many ms after rollout file moves, before DB
415
+ * reconcile, so cleanup can race an in-flight restore.
416
+ */
417
+ holdAfterFileMovesMs?: number;
418
+ /**
419
+ * Test-only: publish a ready file after rollout moves, then wait until the
420
+ * release file exists. This makes cross-thread race tests phase-driven.
421
+ */
422
+ pauseAfterFileMoves?: { readyPath: string; releasePath: string };
423
+ }
424
+
425
+ /**
426
+ * Resume must not clear owed satellite work when the matching backup section is
427
+ * absent — fail closed per section instead.
428
+ */
429
+ function failClosedSatelliteResume(
430
+ priorPending: RestorePendingState,
431
+ satelliteBackup: SatelliteBackup | null,
432
+ ): RestoreErrorCode | null {
433
+ const owed = priorPending.pending;
434
+ if (!owed.logs && !owed.memories && !owed.goals) return null;
435
+ if (!satelliteBackup) return "db_reconcile_failed";
436
+ if (owed.logs && !satelliteBackup.logs) return "db_reconcile_failed";
437
+ if (owed.memories && !satelliteBackup.memories) return "db_reconcile_failed";
438
+ if (owed.goals && !satelliteBackup.goals) return "db_reconcile_failed";
439
+ return null;
440
+ }
441
+
442
+ /**
443
+ * Successful restore finalization: rename the stage to a tombstone name that
444
+ * `listTrashEntries` ignores, then delete the tombstone best-effort. A failed
445
+ * rename leaves the original stage (and all evidence) intact for retry.
446
+ */
447
+ function finalizeRestoredStage(
448
+ stageDir: string,
449
+ codexHome: string,
450
+ hooks?: Pick<RestoreTestHooks, "failStageTombstoneRename" | "failTombstoneDelete">,
451
+ ): boolean {
452
+ const trashRoot = join(codexHome, TRASH_DIR);
453
+ const epoch = basename(stageDir);
454
+ const tombstoneName = `.tombstone-${epoch}-${randomUUID()}`;
455
+ const tombstonePath = join(trashRoot, tombstoneName);
456
+ try {
457
+ if (hooks?.failStageTombstoneRename) throw new Error("test_fail_stage_tombstone_rename");
458
+ renameSync(stageDir, tombstonePath);
459
+ } catch {
460
+ return false;
461
+ }
462
+ if (!hooks?.failTombstoneDelete) {
463
+ try { rmSync(tombstonePath, { recursive: true, force: true }); } catch { /* best-effort */ }
464
+ }
465
+ return true;
466
+ }
467
+
468
+ /**
469
+ * Restore one quarantine entry: move JSONL back, re-insert threads (+ satellites
470
+ * when satellite-backup.json is present), then remove the trash directory.
471
+ *
472
+ * Late failures after files have moved never compensate metadata or restage.
473
+ * Instead they persist `restore-pending.json` (accepted dest paths + which
474
+ * state/logs/memories/goals sections still need work) atomically *before* any
475
+ * rollout move, then update it after each section so a retry can accept existing
476
+ * destinations and resume only missing metadata.
477
+ */
478
+ export function restoreTrashEntry(
479
+ trashId: string,
480
+ options?: {
481
+ codexHome?: string;
482
+ busyTimeoutMs?: number;
483
+ _test?: RestoreTestHooks;
484
+ },
485
+ ): RestoreResult {
486
+ const codexHome = options?.codexHome ?? resolveCodexHomeDir();
487
+ const busyTimeoutMs = options?.busyTimeoutMs ?? 100;
488
+ const hooks = options?._test;
489
+
490
+ const resolved = resolveTrashStageDir(trashId, codexHome);
491
+ if (!resolved.ok) {
492
+ return { ok: false, count: 0, bytes: 0, restoredPaths: [], error: resolved.error };
493
+ }
494
+ const { stageDir, id } = resolved;
495
+
496
+ let manifestRaw: string;
497
+ try {
498
+ manifestRaw = readFileSync(join(stageDir, "manifest.json"), "utf8");
499
+ } catch {
500
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
501
+ }
502
+ const manifest = parseTrashManifest(manifestRaw);
503
+ if (!manifest?.entries?.length) {
504
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
505
+ }
506
+
507
+ const pendingRead = readRestorePending(stageDir);
508
+ if (pendingRead.status === "invalid") {
509
+ // Malformed marker means an incomplete restore may already have moved files;
510
+ // never treat it as a fresh restore.
511
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "fs_failed" };
512
+ }
513
+ const priorPending = pendingRead.status === "valid" ? pendingRead.state : null;
514
+ const acceptedDest = new Set(priorPending?.acceptedDestRels ?? []);
515
+
516
+ // Partial permanent purges may leave only a subset of physical files on disk —
517
+ // trim to survivors rather than failing the whole entry for a purged twin.
518
+ // Resume also treats already-restored accepted destinations as survivors.
519
+ const entries: CleanupManifestEntry[] = [];
520
+ for (const entry of manifest.entries) {
521
+ if (!entry.physicalRelPaths.every(isSafeArchivedPhysicalRel)) {
522
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
523
+ }
524
+ const surviving = entry.physicalRelPaths.filter(rel => {
525
+ if (existsSync(join(stageDir, basename(rel)))) return true;
526
+ if (!acceptedDest.has(rel)) return false;
527
+ try {
528
+ return existsSync(absFromRel(codexHome, rel));
529
+ } catch {
530
+ return false;
531
+ }
532
+ });
533
+ if (surviving.length === 0) {
534
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "fs_failed" };
535
+ }
536
+ entries.push({ ...entry, physicalRelPaths: surviving });
537
+ }
538
+
539
+ const paths = discoverRuntimeDbPaths(codexHome);
540
+ const backupRead = readSatelliteBackupFile(stageDir);
541
+ if (backupRead.status === "invalid") {
542
+ return {
543
+ ok: false,
544
+ trashDir: id,
545
+ count: 0,
546
+ bytes: 0,
547
+ restoredPaths: [],
548
+ error: "db_reconcile_failed",
549
+ };
550
+ }
551
+
552
+ let satelliteBackup: SatelliteBackup | null = null;
553
+ if (backupRead.status === "ok") {
554
+ const remapped = remapSatelliteBackupPaths(backupRead.backup, paths);
555
+ if (!remapped.ok) {
556
+ return {
557
+ ok: false,
558
+ trashDir: id,
559
+ count: 0,
560
+ bytes: 0,
561
+ restoredPaths: [],
562
+ error: "db_reconcile_failed",
563
+ };
564
+ }
565
+ satelliteBackup = remapped.backup;
566
+ }
567
+
568
+ if (priorPending) {
569
+ const resumeErr = failClosedSatelliteResume(priorPending, satelliteBackup);
570
+ if (resumeErr) {
571
+ return {
572
+ ok: false,
573
+ trashDir: id,
574
+ count: 0,
575
+ bytes: 0,
576
+ restoredPaths: [],
577
+ error: resumeErr,
578
+ };
579
+ }
580
+ }
581
+
582
+ const pendingSections: RestorePendingSections = {
583
+ state: priorPending ? priorPending.pending.state : true,
584
+ logs: priorPending ? priorPending.pending.logs : Boolean(satelliteBackup?.logs),
585
+ memories: priorPending ? priorPending.pending.memories : Boolean(satelliteBackup?.memories),
586
+ goals: priorPending ? priorPending.pending.goals : Boolean(satelliteBackup?.goals),
587
+ };
588
+
589
+ const needAnySatellite = pendingSections.logs || pendingSections.memories || pendingSections.goals;
590
+ if (pendingSections.state) {
591
+ const needsThreads = entries.some(e => typeof e.threadId === "string")
592
+ || Boolean(satelliteBackup?.threadIds?.length)
593
+ || Boolean(satelliteBackup?.threads?.length);
594
+ if (needsThreads && (!paths.state || !existsSync(paths.state))) {
595
+ return {
596
+ ok: false,
597
+ trashDir: id,
598
+ count: 0,
599
+ bytes: 0,
600
+ restoredPaths: [],
601
+ error: "db_reconcile_failed",
602
+ };
603
+ }
604
+ const probe = probeStateDbWritable(codexHome, busyTimeoutMs);
605
+ if (!probe.ok) {
606
+ return {
607
+ ok: false,
608
+ trashDir: id,
609
+ count: 0,
610
+ bytes: 0,
611
+ restoredPaths: [],
612
+ error: probe.error === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
613
+ };
614
+ }
615
+ }
616
+
617
+ // Acquire only the satellite locks still needed so a busy DB for an already-
618
+ // finished section cannot block resume. Locks happen before moves on a fresh
619
+ // attempt so failure stays retryable (nothing has left the stage yet).
620
+ let satelliteLocks: SatelliteWriteLocks | undefined;
621
+ if (needAnySatellite) {
622
+ try {
623
+ satelliteLocks = beginSatelliteWriteLocks(paths, busyTimeoutMs, {
624
+ logs: pendingSections.logs,
625
+ memories: pendingSections.memories,
626
+ goals: pendingSections.goals,
627
+ });
628
+ } catch (error) {
629
+ return {
630
+ ok: false,
631
+ trashDir: id,
632
+ count: 0,
633
+ bytes: 0,
634
+ restoredPaths: [],
635
+ error: mapDbError(error) === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
636
+ };
637
+ }
638
+ }
639
+
640
+ const failBeforeMoves = (error: RestoreErrorCode): RestoreResult => {
641
+ if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
642
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error };
643
+ };
644
+
645
+ // Plan renames: staged basename → original archived_sessions path.
646
+ // Resume accepts destinations already restored by this incomplete attempt.
647
+ const alreadyMoved: StagedFile[] = [];
648
+ const toMove: StagedFile[] = [];
649
+ for (const entry of entries) {
650
+ for (const rel of entry.physicalRelPaths) {
651
+ const base = basename(rel);
652
+ const from = join(stageDir, base);
653
+ let to: string;
654
+ try {
655
+ to = absFromRel(codexHome, rel);
656
+ } catch {
657
+ return failBeforeMoves("invalid_trash");
658
+ }
659
+ const fromExists = existsSync(from);
660
+ const toExists = existsSync(to);
661
+ if (toExists && acceptedDest.has(rel) && !fromExists) {
662
+ alreadyMoved.push({ from, to, relPath: rel });
663
+ continue;
664
+ }
665
+ if (toExists) {
666
+ return failBeforeMoves("dest_exists");
667
+ }
668
+ if (!fromExists) {
669
+ return failBeforeMoves("fs_failed");
670
+ }
671
+ toMove.push({ from, to, relPath: rel });
672
+ }
673
+ }
674
+
675
+ const planned = [...alreadyMoved, ...toMove];
676
+ const restoredPaths = [...new Set(entries.map(e => e.relPath))];
677
+ const bytes = entries.reduce((sum, e) => sum + (e.bytes || 0), 0);
678
+ const partialCounts = { count: restoredPaths.length, bytes, restoredPaths };
679
+
680
+ let pendingWriteCount = 0;
681
+ const persistPending = (): void => {
682
+ pendingWriteCount += 1;
683
+ const isInitial = pendingWriteCount === 1;
684
+ writeRestorePending(
685
+ stageDir,
686
+ {
687
+ version: 1,
688
+ filesRestored: true,
689
+ acceptedDestRels: planned.map(m => m.relPath),
690
+ pending: { ...pendingSections },
691
+ },
692
+ {
693
+ failWrite: Boolean(isInitial && hooks?.failInitialPendingWrite),
694
+ failBeforeRename: Boolean(!isInitial && hooks?.failPendingWriteBeforeRename),
695
+ },
696
+ );
697
+ };
698
+
699
+ // Durable resume marker before any rollout leaves the stage. Crash after a
700
+ // later move can still accept destinations from this marker.
701
+ try {
702
+ persistPending();
703
+ } catch {
704
+ return failBeforeMoves("fs_failed");
705
+ }
706
+
707
+ const newlyMoved: StagedFile[] = [];
708
+ try {
709
+ mkdirSync(join(codexHome, ARCHIVED_SESSIONS_DIR), { recursive: true });
710
+ for (const item of toMove) {
711
+ // Atomic no-replace (.trash ↔ archived_sessions). Mid-loop failure keeps
712
+ // already-placed dests and the durable planned acceptedDestRels marker.
713
+ renameNoReplace(item.from, item.to);
714
+ newlyMoved.push(item);
715
+ if (
716
+ hooks?.failAfterMoveCount !== undefined
717
+ && newlyMoved.length >= hooks.failAfterMoveCount
718
+ ) {
719
+ throw new Error("test_fail_after_move_count");
720
+ }
721
+ }
722
+ } catch (error) {
723
+ // Marker was written before any move. Never reverse successful renames or
724
+ // drop/narrow acceptedDestRels — resume must accept placed dests and finish
725
+ // the remaining staged files.
726
+ if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
727
+ const placed = [...alreadyMoved, ...newlyMoved];
728
+ const placedPhysical = new Set(placed.map(m => m.relPath));
729
+ const partialEntries = entries.filter(e =>
730
+ e.physicalRelPaths.every(rel => placedPhysical.has(rel)),
731
+ );
732
+ const midMoveRestored = [...new Set(partialEntries.map(e => e.relPath))];
733
+ return {
734
+ ok: false,
735
+ trashDir: id,
736
+ count: midMoveRestored.length,
737
+ bytes: partialEntries.reduce((sum, e) => sum + (e.bytes || 0), 0),
738
+ restoredPaths: midMoveRestored,
739
+ error: isExistError(error) ? "dest_exists" : "fs_failed",
740
+ };
741
+ }
742
+
743
+ const moved = [...alreadyMoved, ...newlyMoved];
744
+
745
+ /**
746
+ * Never compensate DBs or restage files after moves. Keep restored files,
747
+ * persist which sections remain, and return accurate partial counts.
748
+ */
749
+ const abortAfterMoves = (error: RestoreErrorCode): RestoreResult => {
750
+ if (satelliteLocks) {
751
+ rollbackAllSatelliteLocks(satelliteLocks);
752
+ satelliteLocks = undefined;
753
+ }
754
+ try {
755
+ persistPending();
756
+ } catch {
757
+ /* best-effort — files already restored; prior atomic marker remains */
758
+ }
759
+ return { ok: false, trashDir: id, ...partialCounts, error };
760
+ };
761
+
762
+ if (hooks?.pauseAfterFileMoves) {
763
+ writeFileSync(hooks.pauseAfterFileMoves.readyPath, "ready\n");
764
+ while (!existsSync(hooks.pauseAfterFileMoves.releasePath)) Bun.sleepSync(10);
765
+ }
766
+
767
+ if (hooks?.holdAfterFileMovesMs !== undefined) {
768
+ const holdMs = Math.max(0, Math.floor(hooks.holdAfterFileMovesMs));
769
+ if (holdMs > 0) {
770
+ const deadline = Date.now() + holdMs;
771
+ while (Date.now() < deadline) { /* test-only spin wait */ }
772
+ }
773
+ }
774
+
775
+ if (hooks?.failAfterFileMoves) {
776
+ return abortAfterMoves("fs_failed");
777
+ }
778
+
779
+ if (pendingSections.state) {
780
+ const threadsRestored = restoreThreadsFromManifest(
781
+ paths.state,
782
+ entries,
783
+ satelliteBackup,
784
+ busyTimeoutMs,
785
+ codexHome,
786
+ );
787
+ if (!threadsRestored.ok) {
788
+ return abortAfterMoves(
789
+ threadsRestored.error === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
790
+ );
791
+ }
792
+ pendingSections.state = false;
793
+ try {
794
+ persistPending();
795
+ } catch {
796
+ return abortAfterMoves("fs_failed");
797
+ }
798
+ }
799
+
800
+ if (hooks?.failAfterStateCommit) {
801
+ return abortAfterMoves("db_reconcile_failed");
802
+ }
803
+
804
+ if (satelliteLocks && satelliteBackup) {
805
+ const locks = satelliteLocks;
806
+ try {
807
+ // Commit one satellite DB at a time; uncommitted txs roll back via
808
+ // rollbackAllSatelliteLocks. Completed sections are cleared in pending.
809
+ if (pendingSections.logs && satelliteBackup.logs) {
810
+ if (!locks.logs) throw new Error("missing_logs_lock");
811
+ if (!tableExists(locks.logs.db, "logs")) throw new Error("missing_logs_table");
812
+ insertRowsConflictIgnore(locks.logs.db, "logs", satelliteBackup.logs.rows);
813
+ commitSatelliteLock(locks.logs);
814
+ locks.logs = undefined;
815
+ pendingSections.logs = false;
816
+ persistPending();
817
+ if (hooks?.failAfterFirstSatelliteCommit) {
818
+ throw new Error("test_fail_after_first_satellite");
819
+ }
820
+ }
821
+ if (pendingSections.memories && satelliteBackup.memories) {
822
+ if (!locks.memories) throw new Error("missing_memories_lock");
823
+ const mem = satelliteBackup.memories;
824
+ if (!tableExists(locks.memories.db, "stage1_outputs")) {
825
+ throw new Error("missing_stage1_outputs_table");
826
+ }
827
+ insertRowsConflictIgnore(locks.memories.db, "stage1_outputs", mem.stage1);
828
+ if (tableExists(locks.memories.db, "jobs")) {
829
+ insertRowsConflictIgnore(locks.memories.db, "jobs", mem.stage1Jobs);
830
+ if (mem.consolidateTouched) {
831
+ restoreConsolidateGlobalJob(
832
+ locks.memories.db,
833
+ mem.consolidateJob,
834
+ mem.consolidatePostImage,
835
+ );
836
+ }
837
+ }
838
+ commitSatelliteLock(locks.memories);
839
+ locks.memories = undefined;
840
+ pendingSections.memories = false;
841
+ persistPending();
842
+ if (hooks?.failAfterFirstSatelliteCommit && !satelliteBackup.logs) {
843
+ throw new Error("test_fail_after_first_satellite");
844
+ }
845
+ }
846
+ if (pendingSections.goals && satelliteBackup.goals) {
847
+ if (!locks.goals) throw new Error("missing_goals_lock");
848
+ const g = satelliteBackup.goals;
849
+ if (!tableExists(locks.goals.db, "thread_goals")) {
850
+ throw new Error("missing_thread_goals_table");
851
+ }
852
+ insertRowsConflictIgnore(locks.goals.db, "thread_goals", g.goals);
853
+ if (tableExists(locks.goals.db, "thread_goal_continuation_deferrals")) {
854
+ insertRowsConflictIgnore(
855
+ locks.goals.db,
856
+ "thread_goal_continuation_deferrals",
857
+ g.deferrals,
858
+ );
859
+ }
860
+ commitSatelliteLock(locks.goals);
861
+ locks.goals = undefined;
862
+ pendingSections.goals = false;
863
+ persistPending();
864
+ if (
865
+ hooks?.failAfterFirstSatelliteCommit
866
+ && !satelliteBackup.logs
867
+ && !satelliteBackup.memories
868
+ ) {
869
+ throw new Error("test_fail_after_first_satellite");
870
+ }
871
+ }
872
+ // Close any locks acquired for DBs that had no pending work / backup rows.
873
+ rollbackAllSatelliteLocks(locks);
874
+ satelliteLocks = undefined;
875
+ } catch (error) {
876
+ return abortAfterMoves(
877
+ mapDbError(error) === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
878
+ );
879
+ }
880
+ }
881
+
882
+ if (
883
+ pendingSections.state
884
+ || pendingSections.logs
885
+ || pendingSections.memories
886
+ || pendingSections.goals
887
+ ) {
888
+ return abortAfterMoves("db_reconcile_failed");
889
+ }
890
+
891
+ // Completeness gate: every planned file must sit at its restored path, and the stage
892
+ // must hold no leftover rollout files, before we destroy the quarantine evidence.
893
+ for (const item of moved) {
894
+ if (!existsSync(item.to) || existsSync(item.from)) {
895
+ return abortAfterMoves("fs_failed");
896
+ }
897
+ }
898
+ try {
899
+ if (hooks?.failAtLeftoverStageGate) {
900
+ return abortAfterMoves("fs_failed");
901
+ }
902
+ for (const name of readdirSync(stageDir)) {
903
+ if (
904
+ name === "manifest.json"
905
+ || name === SATELLITE_BACKUP_FILE
906
+ || name === RESTORE_PENDING_FILE
907
+ ) {
908
+ continue;
909
+ }
910
+ if (!isRolloutFileName(name)) continue;
911
+ return abortAfterMoves("fs_failed");
912
+ }
913
+ } catch {
914
+ return abortAfterMoves("fs_failed");
915
+ }
916
+
917
+ if (!finalizeRestoredStage(stageDir, codexHome, hooks)) {
918
+ return {
919
+ ok: false,
920
+ trashDir: id,
921
+ ...partialCounts,
922
+ error: "fs_failed",
923
+ };
924
+ }
925
+ removeEmptyTrashRoot(codexHome);
926
+
927
+ return {
928
+ ok: true,
929
+ trashDir: id,
930
+ ...partialCounts,
931
+ };
932
+ }