@iislee/opencodex 2.11.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 (476) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +225 -0
  3. package/assets/architecture.png +0 -0
  4. package/assets/banner.png +0 -0
  5. package/assets/claude-code-models.gif +0 -0
  6. package/assets/codex-app-picker.png +0 -0
  7. package/bin/ocx.mjs +451 -0
  8. package/bin/package-main.mjs +9 -0
  9. package/gui/dist/assets/index-DTpMHS4F.js +67 -0
  10. package/gui/dist/assets/index-ZNVDE3C7.css +1 -0
  11. package/gui/dist/favicon.png +0 -0
  12. package/gui/dist/icons.svg +24 -0
  13. package/gui/dist/index.html +25 -0
  14. package/gui/dist/logo.png +0 -0
  15. package/gui/dist/provider-icons/alibaba-color.svg +1 -0
  16. package/gui/dist/provider-icons/antigravity-color.svg +1 -0
  17. package/gui/dist/provider-icons/antigravity.svg +1 -0
  18. package/gui/dist/provider-icons/claude-color.svg +1 -0
  19. package/gui/dist/provider-icons/claude.svg +1 -0
  20. package/gui/dist/provider-icons/cloudflare-ai-gateway-color.svg +1 -0
  21. package/gui/dist/provider-icons/copilot-color.svg +1 -0
  22. package/gui/dist/provider-icons/copilot.svg +1 -0
  23. package/gui/dist/provider-icons/cursor-color.svg +2 -0
  24. package/gui/dist/provider-icons/cursor.svg +2 -0
  25. package/gui/dist/provider-icons/deepseek-color.svg +1 -0
  26. package/gui/dist/provider-icons/discord.svg +1 -0
  27. package/gui/dist/provider-icons/firepass-color.svg +1 -0
  28. package/gui/dist/provider-icons/fireworks-color.svg +1 -0
  29. package/gui/dist/provider-icons/gemini-color.svg +1 -0
  30. package/gui/dist/provider-icons/gemini.svg +1 -0
  31. package/gui/dist/provider-icons/github-copilot-color.svg +1 -0
  32. package/gui/dist/provider-icons/gitlab-duo-color.svg +1 -0
  33. package/gui/dist/provider-icons/grok-color.svg +1 -0
  34. package/gui/dist/provider-icons/grok.svg +1 -0
  35. package/gui/dist/provider-icons/groq-color.svg +1 -0
  36. package/gui/dist/provider-icons/huggingface-color.svg +1 -0
  37. package/gui/dist/provider-icons/kimi-color.svg +1 -0
  38. package/gui/dist/provider-icons/kiro-color.svg +15 -0
  39. package/gui/dist/provider-icons/kiro.svg +14 -0
  40. package/gui/dist/provider-icons/lm-studio-color.svg +1 -0
  41. package/gui/dist/provider-icons/mistral-color.svg +1 -0
  42. package/gui/dist/provider-icons/moonshot-color.svg +1 -0
  43. package/gui/dist/provider-icons/nvidia-color.svg +1 -0
  44. package/gui/dist/provider-icons/ollama-color.svg +1 -0
  45. package/gui/dist/provider-icons/openai.svg +1 -0
  46. package/gui/dist/provider-icons/opencode.svg +1 -0
  47. package/gui/dist/provider-icons/openrouter-color.svg +1 -0
  48. package/gui/dist/provider-icons/pi.svg +21 -0
  49. package/gui/dist/provider-icons/qianfan-color.svg +1 -0
  50. package/gui/dist/provider-icons/qwen-portal-color.svg +1 -0
  51. package/gui/dist/provider-icons/telegram.svg +1 -0
  52. package/gui/dist/provider-icons/vercel-ai-gateway-color.svg +1 -0
  53. package/gui/dist/provider-icons/vllm-color.svg +1 -0
  54. package/gui/dist/provider-icons/xiaomi-color.svg +1 -0
  55. package/package.json +102 -0
  56. package/src/AGENTS.md +28 -0
  57. package/src/adapters/anthropic-image-guard.ts +251 -0
  58. package/src/adapters/anthropic-image-normalize.ts +518 -0
  59. package/src/adapters/anthropic.ts +1003 -0
  60. package/src/adapters/azure.ts +36 -0
  61. package/src/adapters/base.ts +72 -0
  62. package/src/adapters/client-fingerprint.ts +59 -0
  63. package/src/adapters/cursor/arg-codec.ts +38 -0
  64. package/src/adapters/cursor/arg-normalize.ts +104 -0
  65. package/src/adapters/cursor/cursor-errors.ts +165 -0
  66. package/src/adapters/cursor/discovery.ts +276 -0
  67. package/src/adapters/cursor/effort-map.ts +127 -0
  68. package/src/adapters/cursor/exec-policy.ts +88 -0
  69. package/src/adapters/cursor/framing.ts +211 -0
  70. package/src/adapters/cursor/gen/agent_pb.ts +15274 -0
  71. package/src/adapters/cursor/kv-store.ts +52 -0
  72. package/src/adapters/cursor/live-models.ts +153 -0
  73. package/src/adapters/cursor/live-smoke-gate.ts +41 -0
  74. package/src/adapters/cursor/live-transport.ts +1214 -0
  75. package/src/adapters/cursor/mcp-config.ts +42 -0
  76. package/src/adapters/cursor/mcp-manager.ts +333 -0
  77. package/src/adapters/cursor/message-mapper.ts +49 -0
  78. package/src/adapters/cursor/native-exec-common.ts +55 -0
  79. package/src/adapters/cursor/native-exec-desktop.ts +184 -0
  80. package/src/adapters/cursor/native-exec-fs.ts +329 -0
  81. package/src/adapters/cursor/native-exec-mcp.ts +153 -0
  82. package/src/adapters/cursor/native-exec-network.ts +43 -0
  83. package/src/adapters/cursor/native-exec-shell.ts +548 -0
  84. package/src/adapters/cursor/native-exec-tools.ts +118 -0
  85. package/src/adapters/cursor/native-exec.ts +576 -0
  86. package/src/adapters/cursor/protobuf-events.ts +563 -0
  87. package/src/adapters/cursor/protobuf-request.ts +714 -0
  88. package/src/adapters/cursor/request-builder.ts +255 -0
  89. package/src/adapters/cursor/thread-continuity.ts +67 -0
  90. package/src/adapters/cursor/tool-definitions.ts +505 -0
  91. package/src/adapters/cursor/transport-retry.ts +132 -0
  92. package/src/adapters/cursor/transport.ts +57 -0
  93. package/src/adapters/cursor/types.ts +52 -0
  94. package/src/adapters/cursor.ts +196 -0
  95. package/src/adapters/google-antigravity-replay.ts +303 -0
  96. package/src/adapters/google-antigravity-wire.ts +108 -0
  97. package/src/adapters/google-errors.ts +85 -0
  98. package/src/adapters/google-http.ts +100 -0
  99. package/src/adapters/google-tool-schema.ts +173 -0
  100. package/src/adapters/google-truncation.ts +13 -0
  101. package/src/adapters/google-wire-compiler.ts +232 -0
  102. package/src/adapters/google.ts +758 -0
  103. package/src/adapters/identity.ts +44 -0
  104. package/src/adapters/image.ts +23 -0
  105. package/src/adapters/kiro-constants.ts +16 -0
  106. package/src/adapters/kiro-errors.ts +197 -0
  107. package/src/adapters/kiro-events.ts +179 -0
  108. package/src/adapters/kiro-images.ts +129 -0
  109. package/src/adapters/kiro-retry.ts +312 -0
  110. package/src/adapters/kiro-thinking.ts +96 -0
  111. package/src/adapters/kiro-tool-fallback.ts +36 -0
  112. package/src/adapters/kiro-tools.ts +215 -0
  113. package/src/adapters/kiro-truncation.ts +33 -0
  114. package/src/adapters/kiro-wire.ts +129 -0
  115. package/src/adapters/kiro.ts +1898 -0
  116. package/src/adapters/mimo-free.ts +263 -0
  117. package/src/adapters/openai-chat.ts +1005 -0
  118. package/src/adapters/openai-responses.ts +1137 -0
  119. package/src/adapters/run-turn-queue.ts +114 -0
  120. package/src/adapters/tool-catalog-nudge.ts +71 -0
  121. package/src/adapters/upstream-http-error.ts +48 -0
  122. package/src/bridge.ts +1619 -0
  123. package/src/chat/inbound.ts +295 -0
  124. package/src/chat/outbound.ts +765 -0
  125. package/src/claude/agents-inject.ts +243 -0
  126. package/src/claude/alias.ts +149 -0
  127. package/src/claude/auth-detect.ts +229 -0
  128. package/src/claude/auth-mode-migration.ts +32 -0
  129. package/src/claude/auth-mode.ts +62 -0
  130. package/src/claude/context-windows.ts +189 -0
  131. package/src/claude/desktop-3p-guard.ts +35 -0
  132. package/src/claude/desktop-3p-paths.ts +84 -0
  133. package/src/claude/desktop-3p.ts +381 -0
  134. package/src/claude/desktop-health.ts +26 -0
  135. package/src/claude/desktop-profile.ts +263 -0
  136. package/src/claude/gateway-cache.ts +70 -0
  137. package/src/claude/inbound-debug.ts +163 -0
  138. package/src/claude/inbound.ts +509 -0
  139. package/src/claude/model-info.ts +151 -0
  140. package/src/claude/outbound.ts +872 -0
  141. package/src/cli/access.ts +108 -0
  142. package/src/cli/account-api.ts +268 -0
  143. package/src/cli/account-auth.ts +223 -0
  144. package/src/cli/account-extended.ts +350 -0
  145. package/src/cli/account.ts +275 -0
  146. package/src/cli/agent-driven.ts +70 -0
  147. package/src/cli/agent.ts +184 -0
  148. package/src/cli/catalog-prewarm.ts +27 -0
  149. package/src/cli/claude-desktop.ts +188 -0
  150. package/src/cli/claude.ts +286 -0
  151. package/src/cli/codex-shim-autorestore.ts +45 -0
  152. package/src/cli/combo.ts +119 -0
  153. package/src/cli/config-command.ts +145 -0
  154. package/src/cli/debug.ts +228 -0
  155. package/src/cli/doctor.ts +930 -0
  156. package/src/cli/export-command.ts +187 -0
  157. package/src/cli/help.ts +354 -0
  158. package/src/cli/index.ts +1113 -0
  159. package/src/cli/init.ts +224 -0
  160. package/src/cli/integrations.ts +142 -0
  161. package/src/cli/interactive-confirm.ts +133 -0
  162. package/src/cli/internal-dispatch.ts +20 -0
  163. package/src/cli/models-runtime.ts +212 -0
  164. package/src/cli/models.ts +336 -0
  165. package/src/cli/observe.ts +117 -0
  166. package/src/cli/opencode.ts +586 -0
  167. package/src/cli/pi.ts +188 -0
  168. package/src/cli/provider-runtime.ts +162 -0
  169. package/src/cli/provider.ts +463 -0
  170. package/src/cli/runtime-api.ts +325 -0
  171. package/src/cli/star-prompt.ts +155 -0
  172. package/src/cli/status-oauth.ts +78 -0
  173. package/src/cli/status.ts +321 -0
  174. package/src/cli/sync-cloud.ts +283 -0
  175. package/src/cli/system-command.ts +112 -0
  176. package/src/cli/tray-proxy.ts +52 -0
  177. package/src/cli/v2.ts +173 -0
  178. package/src/cli.ts +10 -0
  179. package/src/clients/config-export.ts +377 -0
  180. package/src/clients/effective-status.ts +385 -0
  181. package/src/clients/probes/agy.ts +55 -0
  182. package/src/clients/probes/cc-switch.ts +110 -0
  183. package/src/clients/probes/claude.ts +90 -0
  184. package/src/clients/probes/codex.ts +125 -0
  185. package/src/clients/probes/grok.ts +29 -0
  186. package/src/clients/probes/opencode.ts +109 -0
  187. package/src/clients/probes/paseo.ts +55 -0
  188. package/src/clients/probes/pi.ts +55 -0
  189. package/src/cloud/onedrive-auth.ts +666 -0
  190. package/src/cloud/onedrive-graph.ts +108 -0
  191. package/src/cloud/settings.ts +75 -0
  192. package/src/cloud/sync.ts +212 -0
  193. package/src/cloud/types.ts +56 -0
  194. package/src/cloud/vault.ts +89 -0
  195. package/src/codex/account-id.ts +34 -0
  196. package/src/codex/account-label.ts +34 -0
  197. package/src/codex/account-lifecycle.ts +55 -0
  198. package/src/codex/account-namespace-match.ts +63 -0
  199. package/src/codex/account-namespaces.ts +149 -0
  200. package/src/codex/account-pause.ts +20 -0
  201. package/src/codex/account-runtime-state.ts +31 -0
  202. package/src/codex/account-store.ts +517 -0
  203. package/src/codex/account-usability.ts +20 -0
  204. package/src/codex/app-server-processes.ts +756 -0
  205. package/src/codex/auth-api.ts +1540 -0
  206. package/src/codex/auth-collision.ts +107 -0
  207. package/src/codex/auth-context.ts +352 -0
  208. package/src/codex/autostart-health.ts +149 -0
  209. package/src/codex/catalog/aggregation.ts +378 -0
  210. package/src/codex/catalog/bundled.ts +251 -0
  211. package/src/codex/catalog/effort.ts +355 -0
  212. package/src/codex/catalog/metadata.ts +180 -0
  213. package/src/codex/catalog/parsing.ts +456 -0
  214. package/src/codex/catalog/provider-fetch.ts +902 -0
  215. package/src/codex/catalog/sync.ts +620 -0
  216. package/src/codex/catalog.ts +12 -0
  217. package/src/codex/data/upstream-models.json +830 -0
  218. package/src/codex/exec-invocation.ts +22 -0
  219. package/src/codex/features.ts +969 -0
  220. package/src/codex/history-migration-guardian.ts +102 -0
  221. package/src/codex/history-provider.ts +776 -0
  222. package/src/codex/home.ts +206 -0
  223. package/src/codex/inject.ts +799 -0
  224. package/src/codex/injected-marker.ts +72 -0
  225. package/src/codex/journal.ts +163 -0
  226. package/src/codex/main-account-cache.ts +32 -0
  227. package/src/codex/main-account.ts +40 -0
  228. package/src/codex/model-cache.ts +227 -0
  229. package/src/codex/paths.ts +65 -0
  230. package/src/codex/plugins-doctor.ts +242 -0
  231. package/src/codex/pool-rotation.ts +225 -0
  232. package/src/codex/project-config-warnings.ts +411 -0
  233. package/src/codex/quota.ts +411 -0
  234. package/src/codex/refresh.ts +53 -0
  235. package/src/codex/routing.ts +1477 -0
  236. package/src/codex/runtime.ts +538 -0
  237. package/src/codex/shim.ts +1189 -0
  238. package/src/codex/subagent-defaults.ts +550 -0
  239. package/src/codex/subagent-model-fallback.ts +469 -0
  240. package/src/codex/sync.ts +130 -0
  241. package/src/codex/warmup.ts +192 -0
  242. package/src/codex/websocket-registry.ts +100 -0
  243. package/src/combos/failover.ts +140 -0
  244. package/src/combos/index.ts +41 -0
  245. package/src/combos/request.ts +62 -0
  246. package/src/combos/resolve.ts +232 -0
  247. package/src/combos/types.ts +326 -0
  248. package/src/config.ts +2356 -0
  249. package/src/generated/jawcode-model-metadata.ts +104 -0
  250. package/src/github/star-state.ts +203 -0
  251. package/src/grok/inject.ts +545 -0
  252. package/src/grok/status.ts +121 -0
  253. package/src/grok/sync.ts +103 -0
  254. package/src/grok/usage-hook/report.mjs +348 -0
  255. package/src/grok/usage-hook.ts +278 -0
  256. package/src/images/artifacts.ts +516 -0
  257. package/src/images/fulfill-video.ts +163 -0
  258. package/src/images/fulfill.ts +149 -0
  259. package/src/images/index.ts +4 -0
  260. package/src/images/loop.ts +829 -0
  261. package/src/images/plan.ts +133 -0
  262. package/src/images/synthetic-tool.ts +133 -0
  263. package/src/images/types.ts +41 -0
  264. package/src/images/xai-client.ts +141 -0
  265. package/src/images/xai-video-client.ts +163 -0
  266. package/src/index.ts +22 -0
  267. package/src/lib/abort.ts +146 -0
  268. package/src/lib/admin-secrets.ts +25 -0
  269. package/src/lib/admission.ts +83 -0
  270. package/src/lib/app-owned-memory-stores.ts +173 -0
  271. package/src/lib/app-owned-memory.ts +265 -0
  272. package/src/lib/bounded-body.ts +202 -0
  273. package/src/lib/bun-binary-validator.d.mts +3 -0
  274. package/src/lib/bun-binary-validator.mjs +18 -0
  275. package/src/lib/bun-runtime.ts +71 -0
  276. package/src/lib/bun-stream-caps.ts +126 -0
  277. package/src/lib/config-ownership.ts +360 -0
  278. package/src/lib/crash-guard.ts +344 -0
  279. package/src/lib/debug-log-buffer.ts +83 -0
  280. package/src/lib/debug-settings.ts +108 -0
  281. package/src/lib/debug.ts +31 -0
  282. package/src/lib/destination-policy.ts +316 -0
  283. package/src/lib/errors.ts +364 -0
  284. package/src/lib/eventstream-decoder.ts +253 -0
  285. package/src/lib/gcp-adc.ts +341 -0
  286. package/src/lib/injection-debug-log.ts +58 -0
  287. package/src/lib/open-url.ts +25 -0
  288. package/src/lib/pinned-http.ts +151 -0
  289. package/src/lib/privacy.ts +20 -0
  290. package/src/lib/process-control.ts +165 -0
  291. package/src/lib/provider-outbound.ts +170 -0
  292. package/src/lib/provider-url.ts +14 -0
  293. package/src/lib/proxy-env.ts +18 -0
  294. package/src/lib/redact.ts +105 -0
  295. package/src/lib/retry-after.ts +55 -0
  296. package/src/lib/service-secrets.ts +25 -0
  297. package/src/lib/shadow-call.ts +30 -0
  298. package/src/lib/sidecar-tracker.ts +52 -0
  299. package/src/lib/sse-decoder.ts +323 -0
  300. package/src/lib/state-store-registrations.ts +109 -0
  301. package/src/lib/state-store-sweeper.ts +184 -0
  302. package/src/lib/test-home-guard.ts +90 -0
  303. package/src/lib/token-estimate.ts +69 -0
  304. package/src/lib/translator-budget.ts +356 -0
  305. package/src/lib/upstream-retry.ts +239 -0
  306. package/src/lib/win-exec.ts +115 -0
  307. package/src/lib/win-paths.ts +68 -0
  308. package/src/lib/windows-elevation.ts +705 -0
  309. package/src/lib/windows-secret-acl.ts +514 -0
  310. package/src/lib/winsw.ts +375 -0
  311. package/src/oauth/anthropic-routing.ts +594 -0
  312. package/src/oauth/anthropic.ts +177 -0
  313. package/src/oauth/callback-server.ts +294 -0
  314. package/src/oauth/chatgpt.ts +150 -0
  315. package/src/oauth/cursor.ts +211 -0
  316. package/src/oauth/github-copilot.ts +428 -0
  317. package/src/oauth/google-antigravity.ts +230 -0
  318. package/src/oauth/health.ts +399 -0
  319. package/src/oauth/index.ts +1174 -0
  320. package/src/oauth/key-providers.ts +108 -0
  321. package/src/oauth/kimi.ts +213 -0
  322. package/src/oauth/kiro-credentials.ts +726 -0
  323. package/src/oauth/kiro.ts +577 -0
  324. package/src/oauth/local-token-detect.ts +121 -0
  325. package/src/oauth/log.ts +48 -0
  326. package/src/oauth/login-cli.ts +163 -0
  327. package/src/oauth/pkce.ts +15 -0
  328. package/src/oauth/store.ts +630 -0
  329. package/src/oauth/token-guardian.ts +303 -0
  330. package/src/oauth/types.ts +62 -0
  331. package/src/oauth/xai.ts +241 -0
  332. package/src/pi/extensions.ts +72 -0
  333. package/src/pi/home.ts +42 -0
  334. package/src/pi/index.ts +40 -0
  335. package/src/pi/models.ts +278 -0
  336. package/src/pi/packages.ts +219 -0
  337. package/src/pi/settings.ts +365 -0
  338. package/src/pi/status.ts +68 -0
  339. package/src/pi/sync.ts +75 -0
  340. package/src/providers/alibaba-region-backup.ts +75 -0
  341. package/src/providers/alibaba-region-migration.ts +156 -0
  342. package/src/providers/alibaba-region-startup.ts +36 -0
  343. package/src/providers/antigravity-models.ts +205 -0
  344. package/src/providers/api-keys.ts +140 -0
  345. package/src/providers/base-url-choices.ts +64 -0
  346. package/src/providers/context-cap.ts +65 -0
  347. package/src/providers/derive.ts +339 -0
  348. package/src/providers/free-directory.ts +184 -0
  349. package/src/providers/github-copilot-transport.ts +56 -0
  350. package/src/providers/key-failover.ts +203 -0
  351. package/src/providers/kiro-models.ts +67 -0
  352. package/src/providers/label.ts +19 -0
  353. package/src/providers/model-discovery.ts +356 -0
  354. package/src/providers/openai-sidecar.ts +175 -0
  355. package/src/providers/openai-tier-startup.ts +27 -0
  356. package/src/providers/openai-tiers.ts +301 -0
  357. package/src/providers/openai-virtual-models.ts +82 -0
  358. package/src/providers/openrouter-routing.ts +102 -0
  359. package/src/providers/provider-id-rewrite.ts +150 -0
  360. package/src/providers/quota.ts +1260 -0
  361. package/src/providers/registry.ts +1600 -0
  362. package/src/providers/slug-codec.ts +67 -0
  363. package/src/providers/xai-transport.ts +141 -0
  364. package/src/reasoning-effort.ts +135 -0
  365. package/src/responses/compaction.ts +117 -0
  366. package/src/responses/parser.ts +656 -0
  367. package/src/responses/reasoning-envelope.ts +52 -0
  368. package/src/responses/schema.ts +159 -0
  369. package/src/responses/spill-store.ts +394 -0
  370. package/src/responses/state.ts +895 -0
  371. package/src/responses/tool-groups.ts +19 -0
  372. package/src/router.ts +425 -0
  373. package/src/server/adapter-resolve.ts +80 -0
  374. package/src/server/auth-cors.ts +530 -0
  375. package/src/server/chat-completions.ts +368 -0
  376. package/src/server/claude-messages.ts +914 -0
  377. package/src/server/effort-policy.ts +172 -0
  378. package/src/server/gui-static.ts +123 -0
  379. package/src/server/image-retry.ts +42 -0
  380. package/src/server/images.ts +476 -0
  381. package/src/server/index.ts +1126 -0
  382. package/src/server/lifecycle.ts +227 -0
  383. package/src/server/live.ts +598 -0
  384. package/src/server/management/agent-settings-routes.ts +1169 -0
  385. package/src/server/management/api-access.ts +141 -0
  386. package/src/server/management/api-key-usage.ts +167 -0
  387. package/src/server/management/body.ts +35 -0
  388. package/src/server/management/clients-routes.ts +63 -0
  389. package/src/server/management/cloud-sync-routes.ts +266 -0
  390. package/src/server/management/combo-routes.ts +220 -0
  391. package/src/server/management/config-routes.ts +422 -0
  392. package/src/server/management/context.ts +31 -0
  393. package/src/server/management/logs-usage-routes.ts +707 -0
  394. package/src/server/management/model-routes.ts +525 -0
  395. package/src/server/management/oauth-account-routes.ts +563 -0
  396. package/src/server/management/provider-routes.ts +556 -0
  397. package/src/server/management/shared.ts +277 -0
  398. package/src/server/management/sidebar-routes.ts +90 -0
  399. package/src/server/management/system-restart.ts +179 -0
  400. package/src/server/management/system-routes.ts +117 -0
  401. package/src/server/management/usage-summary-cache.ts +86 -0
  402. package/src/server/management-api.ts +215 -0
  403. package/src/server/management-auth.ts +267 -0
  404. package/src/server/memory-watchdog.ts +156 -0
  405. package/src/server/port-reclaim.ts +307 -0
  406. package/src/server/ports.ts +116 -0
  407. package/src/server/proxy-liveness.ts +201 -0
  408. package/src/server/relay-eager.ts +313 -0
  409. package/src/server/relay.ts +1049 -0
  410. package/src/server/request-decompress.ts +132 -0
  411. package/src/server/request-log-conversation.ts +168 -0
  412. package/src/server/request-log.ts +1046 -0
  413. package/src/server/responses/collaboration.ts +354 -0
  414. package/src/server/responses/compact.ts +384 -0
  415. package/src/server/responses/core.ts +2758 -0
  416. package/src/server/responses/encrypted-payload.ts +308 -0
  417. package/src/server/responses/fetch-helpers.ts +157 -0
  418. package/src/server/responses/passthrough-error.ts +78 -0
  419. package/src/server/responses/terminal-guard.ts +230 -0
  420. package/src/server/responses/upstream-error.ts +48 -0
  421. package/src/server/responses-image-gen-repair.ts +132 -0
  422. package/src/server/responses-item-id-repair.ts +224 -0
  423. package/src/server/responses.ts +9 -0
  424. package/src/server/search.ts +136 -0
  425. package/src/server/sse-payload-rewrite.ts +175 -0
  426. package/src/server/startup-action-control.ts +308 -0
  427. package/src/server/startup-health-cache.ts +113 -0
  428. package/src/server/system-env.ts +413 -0
  429. package/src/server/windows-tcp-drop.ts +184 -0
  430. package/src/server/windows-tray-control.ts +41 -0
  431. package/src/server/ws-bridge.ts +471 -0
  432. package/src/service.ts +2554 -0
  433. package/src/stall-timeout.ts +20 -0
  434. package/src/storage/cleanup-job.ts +57 -0
  435. package/src/storage/cleanup.ts +3085 -0
  436. package/src/storage/policy-job.ts +457 -0
  437. package/src/storage/policy-scheduler.ts +40 -0
  438. package/src/storage/policy-worker.ts +59 -0
  439. package/src/storage/policy.ts +527 -0
  440. package/src/storage/restore-job.ts +299 -0
  441. package/src/storage/restore-worker.ts +58 -0
  442. package/src/storage/scanner.ts +238 -0
  443. package/src/storage/storage-mutation-coordinator.ts +139 -0
  444. package/src/storage/worker-lifecycle.ts +215 -0
  445. package/src/tray/assets/opencodex-tray-offline.ico +0 -0
  446. package/src/tray/assets/opencodex-tray-online.ico +0 -0
  447. package/src/tray/assets/opencodex-tray-warning.ico +0 -0
  448. package/src/tray/assets/opencodex-tray.png +0 -0
  449. package/src/tray/windows-tray.ps1 +290 -0
  450. package/src/tray/windows.ts +730 -0
  451. package/src/types.ts +1237 -0
  452. package/src/update/badge.ts +72 -0
  453. package/src/update/index.ts +407 -0
  454. package/src/update/job.ts +1520 -0
  455. package/src/update/notify.ts +257 -0
  456. package/src/update/npm-invocation.d.mts +23 -0
  457. package/src/update/npm-invocation.mjs +94 -0
  458. package/src/update/tray-update-plan.d.mts +18 -0
  459. package/src/update/tray-update-plan.mjs +38 -0
  460. package/src/usage/cost.ts +0 -0
  461. package/src/usage/debug.ts +97 -0
  462. package/src/usage/expected-prices.ts +164 -0
  463. package/src/usage/log.ts +658 -0
  464. package/src/usage/summary.ts +585 -0
  465. package/src/usage/totals.ts +14 -0
  466. package/src/vision/anthropic-describe.ts +185 -0
  467. package/src/vision/describe.ts +125 -0
  468. package/src/vision/index.ts +467 -0
  469. package/src/web-search/anthropic-executor.ts +189 -0
  470. package/src/web-search/executor.ts +105 -0
  471. package/src/web-search/format-result.ts +89 -0
  472. package/src/web-search/index.ts +196 -0
  473. package/src/web-search/loop.ts +664 -0
  474. package/src/web-search/parse.ts +220 -0
  475. package/src/web-search/progress-stream.ts +342 -0
  476. package/src/web-search/synthetic-tool.ts +47 -0
@@ -0,0 +1,3085 @@
1
+ /**
2
+ * Phase 2 archived-session cleanup (issue #42 Option A).
3
+ *
4
+ * Preview + execute for files under `archived_sessions/` only. Active `sessions/`
5
+ * are never touched. Default mode quarantines into `CODEX_HOME/.trash/<epoch>/`;
6
+ * permanent delete is opt-in.
7
+ *
8
+ * Execution is bound to a preview digest. All candidates are staged first; any FS
9
+ * Freezes the thread-ID set under the state write lock, persists a complete
10
+ * satellite-backup.json before any satellite delete commit, then mutates
11
+ * `logs_*` → `memories_*` → `goals_*` → `state_*`. Later failures restore
12
+ * satellite rows before staged files. Success never carries soft `dbWarning` /
13
+ * `failedPaths`.
14
+ */
15
+ import { createHash, randomUUID } from "node:crypto";
16
+ import {
17
+ closeSync,
18
+ existsSync,
19
+ fsyncSync,
20
+ linkSync,
21
+ mkdirSync,
22
+ openSync,
23
+ readdirSync,
24
+ readFileSync,
25
+ renameSync,
26
+ rmSync,
27
+ statSync,
28
+ unlinkSync,
29
+ writeFileSync,
30
+ writeSync,
31
+ chmodSync,
32
+ } from "node:fs";
33
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
34
+ import { Database } from "bun:sqlite";
35
+ import { resolveCodexHomeDir } from "../codex/home";
36
+ import { readThreadFieldsFromRollout } from "../codex/history-provider";
37
+ import { renameAtomicFile } from "../config";
38
+
39
+ export const ARCHIVED_SESSIONS_DIR = "archived_sessions";
40
+ export const TRASH_DIR = ".trash";
41
+
42
+ export type CleanupMode = "quarantine" | "permanent";
43
+
44
+ /** Mapped failure codes only — never embed absolute host paths. */
45
+ export type CleanupErrorCode =
46
+ | "invalid_mode"
47
+ | "invalid_digest"
48
+ | "stale_preview"
49
+ | "codex_busy"
50
+ | "storage_mutation_busy"
51
+ | "fs_failed"
52
+ | "db_reconcile_failed"
53
+ | "referenced_history"
54
+ | "pinned_thread"
55
+ | "restore_pending_overlap"
56
+ | "cleanup_failed";
57
+
58
+ export interface ArchivedCandidate {
59
+ /** Path relative to CODEX_HOME, forward-slash separated (logical `.jsonl` path). */
60
+ relPath: string;
61
+ absPath: string;
62
+ bytes: number;
63
+ mtimeMs: number;
64
+ /** All physical files for this logical rollout (`.jsonl` and/or `.jsonl.zst`). */
65
+ physicalRelPaths: string[];
66
+ /** Per-physical-file metadata bound into the preview digest. */
67
+ physicalFiles: Array<{ relPath: string; bytes: number; mtimeMs: number }>;
68
+ }
69
+
70
+ export interface CleanupPreview {
71
+ codexHome: string;
72
+ percent: number;
73
+ count: number;
74
+ bytes: number;
75
+ /** HMAC-free content digest binding execute to this exact candidate set. */
76
+ digest: string;
77
+ candidates: ArchivedCandidate[];
78
+ }
79
+
80
+ export interface CleanupManifestEntry {
81
+ relPath: string;
82
+ bytes: number;
83
+ mtimeMs: number;
84
+ physicalRelPaths: string[];
85
+ threadId?: string;
86
+ rolloutPath?: string;
87
+ archived?: number | null;
88
+ }
89
+
90
+ export interface CleanupResult {
91
+ ok: boolean;
92
+ mode: CleanupMode;
93
+ percent: number;
94
+ count: number;
95
+ bytes: number;
96
+ trashDir?: string;
97
+ error?: CleanupErrorCode;
98
+ removedPaths: string[];
99
+ }
100
+
101
+ const STATE_DB_FILE = /^state_(\d+)\.sqlite$/;
102
+ const LOGS_DB_FILE = /^logs_(\d+)\.sqlite$/;
103
+ const GOALS_DB_FILE = /^goals_(\d+)\.sqlite$/;
104
+ const MEMORIES_DB_FILE = /^memories_(\d+)\.sqlite$/;
105
+ const JSONL_SUFFIX = ".jsonl";
106
+ const ZST_SUFFIX = ".jsonl.zst";
107
+ const JOB_KIND_MEMORY_STAGE1 = "memory_stage1";
108
+ const JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL = "memory_consolidate_global";
109
+ const MEMORY_CONSOLIDATION_JOB_KEY = "global";
110
+ const DEFAULT_RETRY_REMAINING = 3;
111
+ /** Chunk size for `IN (...)` binds; spawn-edge checks bind each id twice. */
112
+ const SQLITE_ID_CHUNK = 200;
113
+
114
+ function chmodPrivatePath(path: string, mode: number): void {
115
+ try { chmodSync(path, mode); } catch { /* best-effort (e.g. Windows ACLs) */ }
116
+ }
117
+
118
+ function writePrivateFile(path: string, content: string): void {
119
+ writeFileSync(path, content, "utf8");
120
+ chmodPrivatePath(path, 0o600);
121
+ }
122
+
123
+ function chunkIds(ids: string[], chunkSize: number): string[][] {
124
+ const chunks: string[][] = [];
125
+ for (let i = 0; i < ids.length; i += chunkSize) chunks.push(ids.slice(i, i + chunkSize));
126
+ return chunks;
127
+ }
128
+
129
+ /** Create `.trash/<epoch>` exclusively; suffix on collision. */
130
+ function createExclusiveStageDir(codexHome: string, epoch: number): string {
131
+ const trashRoot = join(codexHome, TRASH_DIR);
132
+ mkdirSync(trashRoot, { recursive: true });
133
+ chmodPrivatePath(trashRoot, 0o700);
134
+ for (let attempt = 0; attempt < 100; attempt++) {
135
+ const name = attempt === 0 ? String(epoch) : `${epoch}-${attempt}`;
136
+ const stageDir = join(trashRoot, name);
137
+ try {
138
+ mkdirSync(stageDir);
139
+ chmodPrivatePath(stageDir, 0o700);
140
+ return stageDir;
141
+ } catch (error) {
142
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
143
+ throw error;
144
+ }
145
+ }
146
+ throw new Error("stage_dir_collision");
147
+ }
148
+
149
+ function isSafeArchiveFileName(name: string): boolean {
150
+ if (name.includes("/") || name.includes("\\") || name.includes("..")) return false;
151
+ return isRolloutFileName(name);
152
+ }
153
+
154
+ function clampPercent(percent: unknown): number {
155
+ if (typeof percent !== "number" || !Number.isFinite(percent)) return 0;
156
+ return Math.max(0, Math.min(100, Math.floor(percent)));
157
+ }
158
+
159
+ function toForwardSlash(p: string): string {
160
+ return p.split(sep).join("/");
161
+ }
162
+
163
+ /** Strip trailing `.zst` so plain + compressed share one logical rollout id. */
164
+ export function logicalRolloutRelPath(relPath: string): string {
165
+ const normalized = toForwardSlash(relPath);
166
+ return normalized.endsWith(ZST_SUFFIX)
167
+ ? normalized.slice(0, -".zst".length)
168
+ : normalized;
169
+ }
170
+
171
+ function isRolloutFileName(name: string): boolean {
172
+ return name.endsWith(ZST_SUFFIX) || name.endsWith(JSONL_SUFFIX);
173
+ }
174
+
175
+ /** Newest `prefix_N.sqlite` under CODEX_HOME, or null when absent. */
176
+ function newestVersionedDb(codexHome: string, pattern: RegExp): string | null {
177
+ let best: string | null = null;
178
+ let bestVersion = -1;
179
+ let names: string[] = [];
180
+ try {
181
+ names = readdirSync(codexHome);
182
+ } catch {
183
+ return null;
184
+ }
185
+ for (const name of names) {
186
+ const match = name.match(pattern);
187
+ if (!match) continue;
188
+ const version = Number(match[1]);
189
+ if (version > bestVersion) {
190
+ bestVersion = version;
191
+ best = name;
192
+ }
193
+ }
194
+ return best ? join(codexHome, best) : null;
195
+ }
196
+
197
+ function newestStateDb(codexHome: string): string | null {
198
+ return newestVersionedDb(codexHome, STATE_DB_FILE);
199
+ }
200
+
201
+ interface RuntimeDbPaths {
202
+ state: string | null;
203
+ logs: string | null;
204
+ goals: string | null;
205
+ memories: string | null;
206
+ }
207
+
208
+ function discoverRuntimeDbPaths(codexHome: string): RuntimeDbPaths {
209
+ return {
210
+ state: newestVersionedDb(codexHome, STATE_DB_FILE),
211
+ logs: newestVersionedDb(codexHome, LOGS_DB_FILE),
212
+ goals: newestVersionedDb(codexHome, GOALS_DB_FILE),
213
+ memories: newestVersionedDb(codexHome, MEMORIES_DB_FILE),
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Normalize a DB `rollout_path` to a CODEX_HOME-relative forward-slash path, then
219
+ * to the logical `.jsonl` form. Returns null when the path is not under
220
+ * `archived_sessions/` (rejects active `sessions/` and foreign paths).
221
+ */
222
+ export function normalizeArchivedRolloutPath(rolloutPath: string, codexHome: string): string | null {
223
+ const raw = toForwardSlash(rolloutPath.trim());
224
+ if (!raw) return null;
225
+ let relativePath = raw;
226
+ try {
227
+ // Prefer Node's absolute-path detection. Do not treat a colon anywhere in the
228
+ // filename (Codex ISO timestamps) as an absolute Windows path.
229
+ const looksAbsolute = isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw);
230
+ const abs = looksAbsolute ? resolve(raw) : resolve(codexHome, raw);
231
+ const homeAbs = resolve(codexHome);
232
+ const rel = toForwardSlash(relative(homeAbs, abs));
233
+ if (rel.startsWith("..") || rel === "") return null;
234
+ relativePath = rel;
235
+ } catch {
236
+ return null;
237
+ }
238
+ const logical = logicalRolloutRelPath(relativePath);
239
+ if (!logical.startsWith(`${ARCHIVED_SESSIONS_DIR}/`)) return null;
240
+ if (!logical.endsWith(JSONL_SUFFIX)) return null;
241
+ // Reject path tricks: only a single file under archived_sessions/
242
+ const rest = logical.slice(ARCHIVED_SESSIONS_DIR.length + 1);
243
+ if (!rest || rest.includes("/") || rest.includes("..")) return null;
244
+ return logical;
245
+ }
246
+
247
+ function candidateDigestLines(candidates: ArchivedCandidate[]): string[] {
248
+ return candidates
249
+ .map(c => {
250
+ const physical = [...c.physicalFiles]
251
+ .sort((a, b) => a.relPath.localeCompare(b.relPath))
252
+ .map(f => `${f.relPath}|${f.bytes}|${Math.trunc(f.mtimeMs)}`)
253
+ .join(",");
254
+ return `${c.relPath}|${c.bytes}|${Math.trunc(c.mtimeMs)}|${physical}`;
255
+ })
256
+ .sort();
257
+ }
258
+
259
+ /** Content digest of the exact previewed candidate set (paths + size + mtime). */
260
+ export function computePreviewDigest(candidates: ArchivedCandidate[], percent: number): string {
261
+ return createHash("sha256")
262
+ .update(`${clampPercent(percent)}\n${candidateDigestLines(candidates).join("\n")}`)
263
+ .digest("hex");
264
+ }
265
+
266
+ /**
267
+ * Digest bound to an explicit candidate list (not a percent selection).
268
+ * Used when reduceToBytes needs an exact count that percent rounding cannot represent.
269
+ */
270
+ export function computeExactPreviewDigest(candidates: ArchivedCandidate[]): string {
271
+ return createHash("sha256")
272
+ .update(`exact\n${candidateDigestLines(candidates).join("\n")}`)
273
+ .digest("hex");
274
+ }
275
+
276
+ /** List archived rollout groups oldest-first. Never walks `sessions/`. */
277
+ export function listArchivedCandidates(codexHome: string): ArchivedCandidate[] {
278
+ const dir = join(codexHome, ARCHIVED_SESSIONS_DIR);
279
+ let names: string[] = [];
280
+ try {
281
+ names = readdirSync(dir);
282
+ } catch {
283
+ return [];
284
+ }
285
+
286
+ type Acc = {
287
+ logicalRel: string;
288
+ files: Array<{ name: string; absPath: string; relPath: string; bytes: number; mtimeMs: number }>;
289
+ };
290
+ const groups = new Map<string, Acc>();
291
+
292
+ for (const name of names) {
293
+ if (!isSafeArchiveFileName(name)) continue;
294
+ const absPath = join(dir, name);
295
+ try {
296
+ const st = statSync(absPath);
297
+ if (!st.isFile()) continue;
298
+ const relPath = `${ARCHIVED_SESSIONS_DIR}/${name}`;
299
+ const logicalRel = logicalRolloutRelPath(relPath);
300
+ let acc = groups.get(logicalRel);
301
+ if (!acc) {
302
+ acc = { logicalRel, files: [] };
303
+ groups.set(logicalRel, acc);
304
+ }
305
+ acc.files.push({
306
+ name,
307
+ absPath,
308
+ relPath,
309
+ bytes: st.size,
310
+ mtimeMs: st.mtimeMs,
311
+ });
312
+ } catch {
313
+ /* vanished mid-scan */
314
+ }
315
+ }
316
+
317
+ const out: ArchivedCandidate[] = [];
318
+ for (const acc of groups.values()) {
319
+ // Prefer the plain `.jsonl` path as the public/logical identity when both exist.
320
+ acc.files.sort((a, b) => a.relPath.localeCompare(b.relPath));
321
+ const primary =
322
+ acc.files.find(f => f.relPath === acc.logicalRel) ??
323
+ acc.files[0]!;
324
+ out.push({
325
+ relPath: acc.logicalRel,
326
+ absPath: primary.absPath,
327
+ bytes: acc.files.reduce((sum, f) => sum + f.bytes, 0),
328
+ mtimeMs: Math.min(...acc.files.map(f => f.mtimeMs)),
329
+ physicalRelPaths: acc.files.map(f => f.relPath),
330
+ physicalFiles: acc.files.map(f => ({ relPath: f.relPath, bytes: f.bytes, mtimeMs: f.mtimeMs })),
331
+ });
332
+ }
333
+ out.sort((a, b) => a.mtimeMs - b.mtimeMs || a.relPath.localeCompare(b.relPath));
334
+ return out;
335
+ }
336
+
337
+ export function selectOldestPercent(candidates: ArchivedCandidate[], percent: number): ArchivedCandidate[] {
338
+ const pct = clampPercent(percent);
339
+ if (pct <= 0 || candidates.length === 0) return [];
340
+ if (pct >= 100) return [...candidates];
341
+ const n = percentSelectionTargetCount(candidates.length, pct);
342
+ return candidates.slice(0, n);
343
+ }
344
+
345
+ /** Count implied by percent selection over the full candidate list. */
346
+ export function percentSelectionTargetCount(totalCount: number, percent: number): number {
347
+ const pct = clampPercent(percent);
348
+ if (pct <= 0 || totalCount === 0) return 0;
349
+ if (pct >= 100) return totalCount;
350
+ return Math.max(1, Math.floor((totalCount * pct) / 100));
351
+ }
352
+
353
+ function candidateOverlapsPendingRestore(
354
+ candidate: ArchivedCandidate,
355
+ pendingDestRels: ReadonlySet<string>,
356
+ ): boolean {
357
+ if (pendingDestRels.size === 0) return false;
358
+ for (const rel of candidate.physicalRelPaths) {
359
+ if (pendingDestRels.has(rel)) return true;
360
+ }
361
+ return pendingDestRels.has(candidate.relPath);
362
+ }
363
+
364
+ /** Drop cleanup candidates whose physical paths overlap an in-progress restore. */
365
+ export function filterCandidatesExcludingPendingRestore(
366
+ candidates: ArchivedCandidate[],
367
+ codexHome: string = resolveCodexHomeDir(),
368
+ ): ArchivedCandidate[] {
369
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
370
+ if (pendingDestRels.size === 0) return candidates;
371
+ return candidates.filter(c => !candidateOverlapsPendingRestore(c, pendingDestRels));
372
+ }
373
+
374
+ /**
375
+ * Oldest-first percent selection that skips pending-restore destinations without
376
+ * consuming the percent budget, backfilling with the next oldest safe candidates.
377
+ */
378
+ export function selectOldestPercentSkippingPendingRestore(
379
+ candidates: ArchivedCandidate[],
380
+ percent: number,
381
+ codexHome: string = resolveCodexHomeDir(),
382
+ ): ArchivedCandidate[] {
383
+ const target = percentSelectionTargetCount(candidates.length, percent);
384
+ if (target === 0) return [];
385
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
386
+ const out: ArchivedCandidate[] = [];
387
+ for (const c of candidates) {
388
+ if (candidateOverlapsPendingRestore(c, pendingDestRels)) continue;
389
+ out.push(c);
390
+ if (out.length >= target) break;
391
+ }
392
+ return out;
393
+ }
394
+
395
+ /**
396
+ * Reduce archived total toward `reduceToBytes` using oldest safe candidates only.
397
+ * Pending-restore destinations are skipped and do not count toward bytes freed.
398
+ */
399
+ export function selectReduceToBytesSkippingPendingRestore(
400
+ candidates: ArchivedCandidate[],
401
+ reduceToBytes: number,
402
+ codexHome: string = resolveCodexHomeDir(),
403
+ ): ArchivedCandidate[] {
404
+ if (!Number.isFinite(reduceToBytes) || reduceToBytes < 0) return [];
405
+ const total = candidates.reduce((sum, c) => sum + c.bytes, 0);
406
+ if (total <= reduceToBytes) return [];
407
+ const need = total - reduceToBytes;
408
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
409
+ const out: ArchivedCandidate[] = [];
410
+ let freed = 0;
411
+ for (const c of candidates) {
412
+ if (candidateOverlapsPendingRestore(c, pendingDestRels)) continue;
413
+ out.push(c);
414
+ freed += c.bytes;
415
+ if (freed >= need) break;
416
+ }
417
+ return out;
418
+ }
419
+
420
+ /** Accepted destination paths from every valid in-progress restore marker under `.trash`. */
421
+ export function collectRestorePendingAcceptedDestRels(codexHome: string): Set<string> {
422
+ const out = new Set<string>();
423
+ const trashRoot = join(codexHome, TRASH_DIR);
424
+ if (!existsSync(trashRoot)) return out;
425
+ for (const name of readdirSync(trashRoot)) {
426
+ if (!TRASH_EPOCH_DIR.test(name)) continue;
427
+ const read = readRestorePending(join(trashRoot, name));
428
+ if (read.status !== "valid") continue;
429
+ for (const rel of read.state.acceptedDestRels) out.add(rel);
430
+ }
431
+ return out;
432
+ }
433
+
434
+ /**
435
+ * Normalized rollout paths of pinned threads. Pinned threads are never
436
+ * cleanup candidates: a pin is the user's explicit "keep this" signal and
437
+ * deleting its rollout would be permanent task-data loss (#858).
438
+ *
439
+ * Selection-time use is advisory: on any DB problem this returns an empty
440
+ * set, and the write-locked re-check inside reconcileDeletedThreads stays
441
+ * the fail-closed gate. Older schemas without `is_pinned` keep prior
442
+ * behavior.
443
+ */
444
+ function collectPinnedArchivedRolloutPaths(codexHome: string): Set<string> {
445
+ const statePath = discoverRuntimeDbPaths(codexHome).state;
446
+ if (!statePath || !existsSync(statePath)) return new Set();
447
+ let db: Database | undefined;
448
+ try {
449
+ db = new Database(statePath, { readonly: true });
450
+ if (!tableExists(db, "threads") || !columnExists(db, "threads", "is_pinned")) {
451
+ return new Set();
452
+ }
453
+ const rows = db.query<{ rollout_path: string }, []>(
454
+ `SELECT rollout_path FROM threads WHERE is_pinned = 1`,
455
+ ).all();
456
+ const out = new Set<string>();
457
+ for (const row of rows) {
458
+ const normalized = normalizeArchivedRolloutPath(row.rollout_path, codexHome);
459
+ if (normalized) out.add(normalized);
460
+ }
461
+ return out;
462
+ } catch {
463
+ return new Set();
464
+ } finally {
465
+ try { db?.close(); } catch { /* */ }
466
+ }
467
+ }
468
+
469
+ /** Drop candidates whose rollout belongs to a pinned thread (#858). */
470
+ export function filterCandidatesExcludingPinned(
471
+ candidates: ArchivedCandidate[],
472
+ codexHome: string,
473
+ ): ArchivedCandidate[] {
474
+ const pinned = collectPinnedArchivedRolloutPaths(codexHome);
475
+ if (pinned.size === 0) return candidates;
476
+ return candidates.filter(c => !pinned.has(c.relPath));
477
+ }
478
+
479
+ export function previewArchivedCleanup(
480
+ percent: number,
481
+ codexHome: string = resolveCodexHomeDir(),
482
+ ): CleanupPreview {
483
+ const all = listArchivedCandidates(codexHome);
484
+ const safe = selectOldestPercentSkippingPendingRestore(
485
+ filterCandidatesExcludingPinned(all, codexHome),
486
+ percent,
487
+ codexHome,
488
+ );
489
+ const pct = clampPercent(percent);
490
+ return {
491
+ codexHome,
492
+ percent: pct,
493
+ count: safe.length,
494
+ bytes: safe.reduce((sum, c) => sum + c.bytes, 0),
495
+ digest: computePreviewDigest(safe, pct),
496
+ candidates: safe,
497
+ };
498
+ }
499
+
500
+ /** Preview bound to an explicit candidate set (exact digest, percent left at 0). */
501
+ export function previewExactArchivedCleanup(
502
+ candidates: ArchivedCandidate[],
503
+ codexHome: string = resolveCodexHomeDir(),
504
+ ): CleanupPreview {
505
+ const safe = filterCandidatesExcludingPinned(
506
+ filterCandidatesExcludingPendingRestore(candidates, codexHome),
507
+ codexHome,
508
+ );
509
+ return {
510
+ codexHome,
511
+ percent: 0,
512
+ count: safe.length,
513
+ bytes: safe.reduce((sum, c) => sum + c.bytes, 0),
514
+ digest: computeExactPreviewDigest(safe),
515
+ candidates: safe,
516
+ };
517
+ }
518
+
519
+ /**
520
+ * Resolve an exact candidate list from current archive state.
521
+ * Returns null when any requested path is missing or drifted (caller maps to stale_preview).
522
+ */
523
+ export function resolveExactArchivedCandidates(
524
+ candidateRelPaths: string[],
525
+ codexHome: string = resolveCodexHomeDir(),
526
+ ): ArchivedCandidate[] | null {
527
+ if (!Array.isArray(candidateRelPaths) || candidateRelPaths.length === 0) return [];
528
+ const all = listArchivedCandidates(codexHome);
529
+ const byRel = new Map(all.map(c => [c.relPath, c]));
530
+ const selected: ArchivedCandidate[] = [];
531
+ for (const rel of candidateRelPaths) {
532
+ const hit = byRel.get(rel);
533
+ if (!hit) return null;
534
+ selected.push(hit);
535
+ }
536
+ return selected;
537
+ }
538
+
539
+ function openDbWritable(dbPath: string, busyTimeoutMs = 100): Database {
540
+ const db = new Database(dbPath);
541
+ try {
542
+ // bun:sqlite exposes a binding-level timeout; set both so Windows lock waits
543
+ // honor the caller's budget (pragma alone has been flaky under CI contention).
544
+ (db as Database & { timeout?: number }).timeout = busyTimeoutMs;
545
+ } catch {
546
+ /* older bindings */
547
+ }
548
+ try {
549
+ db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
550
+ } catch {
551
+ /* older sqlite */
552
+ }
553
+ try {
554
+ db.exec("PRAGMA foreign_keys = ON");
555
+ } catch {
556
+ /* ignore */
557
+ }
558
+ return db;
559
+ }
560
+
561
+ function isBusyError(error: unknown): boolean {
562
+ const msg = error instanceof Error ? error.message : String(error);
563
+ const code = (error as { code?: string })?.code ?? "";
564
+ return (
565
+ code === "SQLITE_BUSY" ||
566
+ code === "SQLITE_LOCKED" ||
567
+ /SQLITE_BUSY|SQLITE_LOCKED|database is locked|database table is locked/i.test(msg)
568
+ );
569
+ }
570
+
571
+ function mapDbError(error: unknown): CleanupErrorCode {
572
+ if (isBusyError(error)) return "codex_busy";
573
+ return "db_reconcile_failed";
574
+ }
575
+
576
+ /** Probe a single DB with BEGIN IMMEDIATE; missing path is a no-op success. */
577
+ function probeDbWritable(
578
+ path: string | null,
579
+ busyTimeoutMs: number,
580
+ ): { ok: true } | { ok: false; error: CleanupErrorCode } {
581
+ if (!path || !existsSync(path)) return { ok: true };
582
+ let db: Database | undefined;
583
+ try {
584
+ db = openDbWritable(path, busyTimeoutMs);
585
+ db.exec("BEGIN IMMEDIATE");
586
+ db.exec("ROLLBACK");
587
+ return { ok: true };
588
+ } catch (error) {
589
+ if (isBusyError(error)) return { ok: false, error: "codex_busy" };
590
+ return { ok: false, error: "db_reconcile_failed" };
591
+ } finally {
592
+ try { db?.close(); } catch { /* */ }
593
+ }
594
+ }
595
+
596
+ /**
597
+ * True when every present Codex runtime DB can be written (BEGIN IMMEDIATE).
598
+ * Busy / corrupt stores abort cleanup before any filesystem mutation.
599
+ */
600
+ export function probeStateDbWritable(
601
+ codexHome: string,
602
+ busyTimeoutMs = 100,
603
+ ): { ok: true; path: string } | { ok: false; error: CleanupErrorCode } {
604
+ const paths = discoverRuntimeDbPaths(codexHome);
605
+ for (const path of [paths.state, paths.logs, paths.goals, paths.memories]) {
606
+ const probed = probeDbWritable(path, busyTimeoutMs);
607
+ if (!probed.ok) return probed;
608
+ }
609
+ return { ok: true, path: paths.state ?? "" };
610
+ }
611
+
612
+ interface ThreadSnapshot {
613
+ id: string;
614
+ rollout_path: string;
615
+ archived: number | null;
616
+ history_mode?: string | null;
617
+ is_pinned?: number | null;
618
+ }
619
+
620
+ /**
621
+ * Load archived threads matching the candidate set.
622
+ * Optional columns are detected via PRAGMA; missing `threads` / query failures throw
623
+ * so callers map to `db_reconcile_failed` / `codex_busy` instead of treating them as empty.
624
+ */
625
+ function loadMatchingThreads(db: Database, candidates: ArchivedCandidate[], codexHome: string): ThreadSnapshot[] {
626
+ if (!tableExists(db, "threads")) {
627
+ throw new Error("missing_threads_table");
628
+ }
629
+ const logicalSet = new Set(candidates.map(c => c.relPath));
630
+ const hasArchived = columnExists(db, "threads", "archived");
631
+ const hasHistoryMode = columnExists(db, "threads", "history_mode");
632
+ const hasIsPinned = columnExists(db, "threads", "is_pinned");
633
+ const selectCols = ["id", "rollout_path"];
634
+ if (hasArchived) selectCols.push("archived");
635
+ if (hasHistoryMode) selectCols.push("history_mode");
636
+ if (hasIsPinned) selectCols.push("is_pinned");
637
+ const rows = db.query<
638
+ { id: string; rollout_path: string; archived?: number | null; history_mode?: string | null; is_pinned?: number | null },
639
+ []
640
+ >(`SELECT ${selectCols.join(", ")} FROM threads`).all();
641
+
642
+ return rows
643
+ .filter(row => {
644
+ // When the archived column is present, only archived=1 rows may be deleted.
645
+ if (hasArchived && Number(row.archived ?? 0) !== 1) {
646
+ return false;
647
+ }
648
+ const normalized = normalizeArchivedRolloutPath(row.rollout_path, codexHome);
649
+ return normalized !== null && logicalSet.has(normalized);
650
+ })
651
+ .map(row => ({
652
+ id: row.id,
653
+ rollout_path: row.rollout_path,
654
+ archived: hasArchived ? (row.archived ?? null) : null,
655
+ history_mode: hasHistoryMode ? (row.history_mode ?? null) : null,
656
+ is_pinned: hasIsPinned ? (row.is_pinned ?? null) : null,
657
+ }));
658
+ }
659
+
660
+ /**
661
+ * True when any matched thread is still linked to a thread outside the delete set
662
+ * (spawn edges) or uses paginated history that other live threads may depend on via fork.
663
+ * Throws real DB errors (busy/corruption) so callers can refuse cleanup.
664
+ */
665
+ function findReferencedHistory(
666
+ db: Database,
667
+ threads: ThreadSnapshot[],
668
+ ): boolean {
669
+ if (threads.length === 0) return false;
670
+ const ids = threads.map(t => t.id);
671
+ const idSet = new Set(ids);
672
+
673
+ // Paginated history keeps durable projections tied to the rollout — refuse cleanup.
674
+ if (threads.some(t => (t.history_mode ?? "").toLowerCase() === "paginated")) {
675
+ return true;
676
+ }
677
+
678
+ // Spawn edges that cross the delete boundary keep history reachable.
679
+ if (tableExists(db, "thread_spawn_edges")) {
680
+ for (const chunk of chunkIds(ids, SQLITE_ID_CHUNK)) {
681
+ const placeholders = chunk.map(() => "?").join(",");
682
+ const edges = db.query<{ parent_thread_id: string; child_thread_id: string }, string[]>(
683
+ `SELECT parent_thread_id, child_thread_id FROM thread_spawn_edges
684
+ WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
685
+ ).all(...chunk, ...chunk);
686
+ for (const edge of edges) {
687
+ if (!idSet.has(edge.parent_thread_id) || !idSet.has(edge.child_thread_id)) {
688
+ return true;
689
+ }
690
+ }
691
+ }
692
+ }
693
+
694
+ // Other threads that list one of ours as forked_from / parent (when columns exist).
695
+ for (const column of ["forked_from_id", "parent_thread_id", "source_thread_id"] as const) {
696
+ if (!columnExists(db, "threads", column)) continue;
697
+ for (const chunk of chunkIds(ids, SQLITE_ID_CHUNK * 2)) {
698
+ const placeholders = chunk.map(() => "?").join(",");
699
+ const rows = db.query<{ id: string }, string[]>(
700
+ `SELECT id FROM threads WHERE ${column} IN (${placeholders})`,
701
+ ).all(...chunk);
702
+ if (rows.some(r => !idSet.has(r.id))) return true;
703
+ }
704
+ }
705
+
706
+ return false;
707
+ }
708
+
709
+ function tableExists(db: Database, name: string): boolean {
710
+ const row = db.query<{ name: string }, [string]>(
711
+ `SELECT name FROM sqlite_master WHERE type='table' AND name=?`,
712
+ ).get(name);
713
+ return Boolean(row);
714
+ }
715
+
716
+ function columnExists(db: Database, table: string, column: string): boolean {
717
+ if (!tableExists(db, table)) return false;
718
+ // `table` is only ever a hardcoded identifier already verified via sqlite_master.
719
+ const rows = db.query<{ name: string }, []>(
720
+ `PRAGMA table_info("${table.replaceAll('"', '""')}")`,
721
+ ).all();
722
+ return rows.some(r => r.name === column);
723
+ }
724
+
725
+ function deleteThreadsAndDependents(db: Database, threadIds: string[]): void {
726
+ if (threadIds.length === 0) return;
727
+
728
+ // Upstream deletes dynamic tools before spawn edges before threads.
729
+ if (tableExists(db, "thread_dynamic_tools")) {
730
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
731
+ const placeholders = chunk.map(() => "?").join(",");
732
+ db.run(`DELETE FROM thread_dynamic_tools WHERE thread_id IN (${placeholders})`, chunk);
733
+ }
734
+ }
735
+
736
+ if (tableExists(db, "thread_spawn_edges")) {
737
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK)) {
738
+ const placeholders = chunk.map(() => "?").join(",");
739
+ db.run(
740
+ `DELETE FROM thread_spawn_edges WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
741
+ [...chunk, ...chunk],
742
+ );
743
+ }
744
+ }
745
+
746
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
747
+ const placeholders = chunk.map(() => "?").join(",");
748
+ db.run(`DELETE FROM threads WHERE id IN (${placeholders})`, chunk);
749
+ }
750
+ }
751
+
752
+ interface ReconcileOk {
753
+ ok: true;
754
+ threads: ThreadSnapshot[];
755
+ }
756
+ interface ReconcileErr {
757
+ ok: false;
758
+ error: CleanupErrorCode;
759
+ /** True when satellite rows were mutated and could not all be restored. */
760
+ satelliteRestoreFailed?: boolean;
761
+ }
762
+
763
+ type SqlRow = Record<string, string | number | bigint | null | Uint8Array>;
764
+
765
+ interface SatelliteBackup {
766
+ threadIds: string[];
767
+ /** Full `threads` row images (SELECT *) captured under the state write lock. */
768
+ threads?: SqlRow[];
769
+ dynamicTools?: SqlRow[];
770
+ spawnEdges?: SqlRow[];
771
+ logs?: { path: string; rows: SqlRow[] };
772
+ memories?: {
773
+ path: string;
774
+ stage1: SqlRow[];
775
+ stage1Jobs: SqlRow[];
776
+ consolidateJob: SqlRow | null;
777
+ consolidateTouched: boolean;
778
+ /** Row image after deleteMemoriesInTx; set before memories commit (in-memory only). */
779
+ consolidatePostImage?: SqlRow | null;
780
+ };
781
+ goals?: {
782
+ path: string;
783
+ goals: SqlRow[];
784
+ deferrals: SqlRow[];
785
+ };
786
+ }
787
+
788
+ type SatelliteBackupRead =
789
+ | { status: "missing" }
790
+ | { status: "ok"; backup: SatelliteBackup }
791
+ | { status: "invalid" };
792
+
793
+ interface ReconcileTestHooks {
794
+ /** Runs at the top of reconcileDeletedThreads, before the write lock is taken. */
795
+ beforeReconcileLock?: () => void;
796
+ failAfterLogsMutation?: boolean;
797
+ failAfterMemoriesMutation?: boolean;
798
+ failAfterGoalsMutation?: boolean;
799
+ failBeforeStateCommit?: boolean;
800
+ failSatelliteRestore?: boolean;
801
+ failSatelliteBackupWrite?: boolean;
802
+ /**
803
+ * Fail a satellite-backup.json *replacement* after the temp is durable but before
804
+ * rename — exercises crash-safety of the post-memories rewrite without truncating
805
+ * the last valid backup.
806
+ */
807
+ failSatelliteBackupReplace?: boolean;
808
+ /** Runs after satellite deletes are committed, before state thread deletion. */
809
+ afterSatelliteMutations?: () => void;
810
+ }
811
+
812
+ const SATELLITE_BACKUP_FILE = "satellite-backup.json";
813
+ /** Marks an incomplete restore so retries can accept dest files and resume metadata. */
814
+ const RESTORE_PENDING_FILE = "restore-pending.json";
815
+ let _satelliteBackupSeq = 0;
816
+
817
+ type StagedFile = { from: string; to: string; relPath: string };
818
+
819
+ interface RestorePendingSections {
820
+ state: boolean;
821
+ logs: boolean;
822
+ memories: boolean;
823
+ goals: boolean;
824
+ }
825
+
826
+ interface RestorePendingState {
827
+ version: 1;
828
+ filesRestored: true;
829
+ /**
830
+ * Planned CODEX_HOME-relative destinations for this restore attempt.
831
+ * Written before moves so a mid-loop failure can still accept placed dests
832
+ * on resume while finishing files that remain staged.
833
+ */
834
+ acceptedDestRels: string[];
835
+ /** Sections that still need reconciliation on retry. */
836
+ pending: RestorePendingSections;
837
+ }
838
+
839
+ function quoteIdent(name: string): string {
840
+ return `"${name.replaceAll('"', '""')}"`;
841
+ }
842
+
843
+ function selectRows(db: Database, sql: string, params: Array<string | number>): SqlRow[] {
844
+ return db.query<SqlRow, Array<string | number>>(sql).all(...params) as SqlRow[];
845
+ }
846
+
847
+ function tableColumnNames(db: Database, table: string): Set<string> {
848
+ if (!tableExists(db, table)) return new Set();
849
+ const rows = db.query<{ name: string }, []>(
850
+ `PRAGMA table_info("${table.replaceAll('"', '""')}")`,
851
+ ).all();
852
+ return new Set(rows.map(r => r.name));
853
+ }
854
+
855
+ /** Insert rows with ON CONFLICT DO NOTHING; returns only rows that were newly inserted. */
856
+ function insertRowsConflictIgnore(db: Database, table: string, rows: SqlRow[]): SqlRow[] {
857
+ const inserted: SqlRow[] = [];
858
+ if (rows.length === 0) return inserted;
859
+ const allowed = tableColumnNames(db, table);
860
+ for (const row of rows) {
861
+ const cols = Object.keys(row).filter(c => allowed.has(c));
862
+ if (cols.length === 0) continue;
863
+ const result = db.run(
864
+ `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${cols.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`,
865
+ cols.map(c => row[c] as string | number | bigint | null | Uint8Array),
866
+ );
867
+ if (result.changes > 0) inserted.push(row);
868
+ }
869
+ return inserted;
870
+ }
871
+
872
+ /** Snapshot state-DB dependents that cleanup deletes with the thread rows. */
873
+ function snapshotStateDependents(
874
+ db: Database,
875
+ threadIds: string[],
876
+ ): Pick<SatelliteBackup, "threads" | "dynamicTools" | "spawnEdges"> {
877
+ const out: Pick<SatelliteBackup, "threads" | "dynamicTools" | "spawnEdges"> = {};
878
+ if (threadIds.length === 0 || !tableExists(db, "threads")) return out;
879
+
880
+ const threads: SqlRow[] = [];
881
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
882
+ const placeholders = chunk.map(() => "?").join(",");
883
+ threads.push(...selectRows(db, `SELECT * FROM threads WHERE id IN (${placeholders})`, chunk));
884
+ }
885
+ out.threads = threads;
886
+
887
+ if (tableExists(db, "thread_dynamic_tools")) {
888
+ const dynamicTools: SqlRow[] = [];
889
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
890
+ const placeholders = chunk.map(() => "?").join(",");
891
+ dynamicTools.push(...selectRows(
892
+ db,
893
+ `SELECT * FROM thread_dynamic_tools WHERE thread_id IN (${placeholders})`,
894
+ chunk,
895
+ ));
896
+ }
897
+ out.dynamicTools = dynamicTools;
898
+ }
899
+
900
+ if (tableExists(db, "thread_spawn_edges")) {
901
+ const spawnEdges: SqlRow[] = [];
902
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK)) {
903
+ const placeholders = chunk.map(() => "?").join(",");
904
+ spawnEdges.push(...selectRows(
905
+ db,
906
+ `SELECT * FROM thread_spawn_edges
907
+ WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
908
+ [...chunk, ...chunk],
909
+ ));
910
+ }
911
+ out.spawnEdges = spawnEdges;
912
+ }
913
+
914
+ return out;
915
+ }
916
+
917
+ /** Remap serialized absolute DB paths onto the newest DBs under the current Codex home. */
918
+ function remapSatelliteBackupPaths(
919
+ backup: SatelliteBackup,
920
+ paths: RuntimeDbPaths,
921
+ ): { ok: true; backup: SatelliteBackup } | { ok: false } {
922
+ const next: SatelliteBackup = {
923
+ threadIds: backup.threadIds,
924
+ ...(backup.threads ? { threads: backup.threads } : {}),
925
+ ...(backup.dynamicTools ? { dynamicTools: backup.dynamicTools } : {}),
926
+ ...(backup.spawnEdges ? { spawnEdges: backup.spawnEdges } : {}),
927
+ };
928
+ if (backup.logs) {
929
+ if (!paths.logs) return { ok: false };
930
+ next.logs = { ...backup.logs, path: paths.logs };
931
+ }
932
+ if (backup.memories) {
933
+ if (!paths.memories) return { ok: false };
934
+ next.memories = { ...backup.memories, path: paths.memories };
935
+ }
936
+ if (backup.goals) {
937
+ if (!paths.goals) return { ok: false };
938
+ next.goals = { ...backup.goals, path: paths.goals };
939
+ }
940
+ return { ok: true, backup: next };
941
+ }
942
+
943
+ /**
944
+ * Same-volume move that never replaces an existing destination.
945
+ *
946
+ * `existsSync` + `renameSync` is TOCTOU: a live file created between the check
947
+ * and rename can be overwritten (Windows rename replaces files). Hard-link then
948
+ * unlink fails with EEXIST if `to` appears, which is what trash → archived_sessions
949
+ * restore needs. Callers under the same `CODEX_HOME` volume should not hit EXDEV.
950
+ */
951
+ function renameNoReplace(from: string, to: string): void {
952
+ try {
953
+ linkSync(from, to);
954
+ } catch (error) {
955
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
956
+ // Hard links unavailable (rare FS) — refuse rather than clobber via rename.
957
+ if (code === "EXDEV" || code === "EPERM" || code === "ENOTSUP" || code === "EINVAL") {
958
+ throw Object.assign(new Error("rename_no_replace_unsupported"), { code, cause: error });
959
+ }
960
+ throw error;
961
+ }
962
+ try {
963
+ unlinkSync(from);
964
+ } catch (error) {
965
+ // Roll back the hard link so we do not leave the file at both paths.
966
+ try { unlinkSync(to); } catch { /* best-effort */ }
967
+ throw error;
968
+ }
969
+ }
970
+
971
+ function isExistError(error: unknown): boolean {
972
+ return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
973
+ }
974
+
975
+ function updateRowFromSnapshot(
976
+ db: Database,
977
+ table: string,
978
+ row: SqlRow,
979
+ pkCols: string[],
980
+ ): void {
981
+ const cols = Object.keys(row).filter(c => !pkCols.includes(c));
982
+ if (cols.length === 0) return;
983
+ const sets = cols.map(c => `${quoteIdent(c)} = ?`).join(", ");
984
+ const where = pkCols.map(c => `${quoteIdent(c)} = ?`).join(" AND ");
985
+ db.run(
986
+ `UPDATE ${quoteIdent(table)} SET ${sets} WHERE ${where}`,
987
+ [
988
+ ...cols.map(c => row[c] as string | number | bigint | null | Uint8Array),
989
+ ...pkCols.map(c => row[c] as string | number | bigint | null | Uint8Array),
990
+ ],
991
+ );
992
+ }
993
+
994
+ function normalizeSqlValue(
995
+ v: string | number | bigint | null | Uint8Array | undefined,
996
+ ): string {
997
+ if (v === null || v === undefined) return "";
998
+ if (typeof v === "bigint") return v.toString();
999
+ if (v instanceof Uint8Array) return Buffer.from(v).toString("base64");
1000
+ return String(v);
1001
+ }
1002
+
1003
+ function sqlRowEqual(a: SqlRow, b: SqlRow): boolean {
1004
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
1005
+ for (const key of keys) {
1006
+ if (normalizeSqlValue(a[key]) !== normalizeSqlValue(b[key])) return false;
1007
+ }
1008
+ return true;
1009
+ }
1010
+
1011
+ function readConsolidateGlobalJob(db: Database): SqlRow | null {
1012
+ if (!tableExists(db, "jobs")) return null;
1013
+ return db.query<SqlRow, [string, string]>(
1014
+ "SELECT * FROM jobs WHERE kind = ? AND job_key = ?",
1015
+ ).get(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL, MEMORY_CONSOLIDATION_JOB_KEY) as SqlRow | null;
1016
+ }
1017
+
1018
+ /** Revert delete-time enqueue only when the row still matches cleanup's post-delete image. */
1019
+ function restoreConsolidateGlobalJob(
1020
+ db: Database,
1021
+ snapshot: SqlRow | null,
1022
+ postImage: SqlRow | null | undefined,
1023
+ ): void {
1024
+ if (!postImage) return;
1025
+ const current = readConsolidateGlobalJob(db);
1026
+ if (!current) {
1027
+ if (snapshot) insertRowsConflictIgnore(db, "jobs", [snapshot]);
1028
+ return;
1029
+ }
1030
+ if (!sqlRowEqual(current, postImage)) return;
1031
+ if (snapshot) {
1032
+ updateRowFromSnapshot(db, "jobs", snapshot, ["kind", "job_key"]);
1033
+ } else {
1034
+ db.run(
1035
+ "DELETE FROM jobs WHERE kind = ? AND job_key = ?",
1036
+ [JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL, MEMORY_CONSOLIDATION_JOB_KEY],
1037
+ );
1038
+ }
1039
+ }
1040
+
1041
+ /**
1042
+ * Best-effort directory fsync so a preceding rename is durable on crash.
1043
+ * Unsupported on some Windows setups — never treat failure as fatal.
1044
+ */
1045
+ function fsyncDirectoryBestEffort(dirPath: string): void {
1046
+ let fd: number | undefined;
1047
+ try {
1048
+ fd = openSync(dirPath, "r");
1049
+ fsyncSync(fd);
1050
+ } catch {
1051
+ /* best-effort */
1052
+ } finally {
1053
+ if (fd !== undefined) {
1054
+ try { closeSync(fd); } catch { /* */ }
1055
+ }
1056
+ }
1057
+ }
1058
+
1059
+ /**
1060
+ * Atomically replace satellite-backup.json: private temp in the stage, full write + fsync,
1061
+ * rename (with Windows sharing-violation retries), then best-effort directory fsync.
1062
+ * An interrupted update never truncates the last valid backup that was written before a
1063
+ * satellite DB commit.
1064
+ */
1065
+ function writeSatelliteBackup(
1066
+ stageDir: string,
1067
+ backup: SatelliteBackup,
1068
+ options?: { failWrite?: boolean; failReplaceBeforeRename?: boolean },
1069
+ ): void {
1070
+ if (options?.failWrite) throw new Error("test_fail_satellite_backup_write");
1071
+ const dest = join(stageDir, SATELLITE_BACKUP_FILE);
1072
+ const replacing = existsSync(dest);
1073
+ const tmp = join(stageDir, `${SATELLITE_BACKUP_FILE}.${process.pid}.${++_satelliteBackupSeq}.tmp`);
1074
+ const payload = Buffer.from(JSON.stringify(backup), "utf8");
1075
+ const fd = openSync(tmp, "w", 0o600);
1076
+ try {
1077
+ let offset = 0;
1078
+ while (offset < payload.length) {
1079
+ offset += writeSync(fd, payload, offset, payload.length - offset, null);
1080
+ }
1081
+ fsyncSync(fd);
1082
+ } catch (error) {
1083
+ try { closeSync(fd); } catch { /* */ }
1084
+ try { unlinkSync(tmp); } catch { /* */ }
1085
+ throw error;
1086
+ }
1087
+ closeSync(fd);
1088
+ chmodPrivatePath(tmp, 0o600);
1089
+ if (options?.failReplaceBeforeRename && replacing) {
1090
+ try { unlinkSync(tmp); } catch { /* */ }
1091
+ throw new Error("test_fail_satellite_backup_replace");
1092
+ }
1093
+ try {
1094
+ renameAtomicFile(tmp, dest);
1095
+ } catch (error) {
1096
+ try { unlinkSync(tmp); } catch { /* */ }
1097
+ throw error;
1098
+ }
1099
+ chmodPrivatePath(dest, 0o600);
1100
+ fsyncDirectoryBestEffort(stageDir);
1101
+ }
1102
+
1103
+ function clearSatelliteBackup(stageDir: string): void {
1104
+ try { unlinkSync(join(stageDir, SATELLITE_BACKUP_FILE)); } catch { /* */ }
1105
+ }
1106
+
1107
+ interface SatelliteWriteLock {
1108
+ path: string;
1109
+ db: Database;
1110
+ }
1111
+
1112
+ interface SatelliteWriteLocks {
1113
+ logs?: SatelliteWriteLock;
1114
+ memories?: SatelliteWriteLock;
1115
+ goals?: SatelliteWriteLock;
1116
+ }
1117
+
1118
+ /** Deterministic order: logs → memories → goals. Each present DB gets BEGIN IMMEDIATE. */
1119
+ function beginSatelliteWriteLocks(
1120
+ paths: RuntimeDbPaths,
1121
+ busyTimeoutMs: number,
1122
+ only?: Partial<Record<"logs" | "memories" | "goals", boolean>>,
1123
+ ): SatelliteWriteLocks {
1124
+ const locks: SatelliteWriteLocks = {};
1125
+ const order: Array<{ key: "logs" | "memories" | "goals"; path: string | null }> = [
1126
+ { key: "logs", path: paths.logs },
1127
+ { key: "memories", path: paths.memories },
1128
+ { key: "goals", path: paths.goals },
1129
+ ];
1130
+ try {
1131
+ for (const { key, path } of order) {
1132
+ if (only && !only[key]) continue;
1133
+ if (!path || !existsSync(path)) continue;
1134
+ const db = openDbWritable(path, busyTimeoutMs);
1135
+ try {
1136
+ db.exec("BEGIN IMMEDIATE");
1137
+ locks[key] = { path, db };
1138
+ } catch (error) {
1139
+ try { db.close(); } catch { /* */ }
1140
+ throw error;
1141
+ }
1142
+ }
1143
+ return locks;
1144
+ } catch (error) {
1145
+ rollbackAllSatelliteLocks(locks);
1146
+ throw error;
1147
+ }
1148
+ }
1149
+
1150
+ function rollbackSatelliteLock(lock: SatelliteWriteLock | undefined): void {
1151
+ if (!lock) return;
1152
+ try { lock.db.exec("ROLLBACK"); } catch { /* */ }
1153
+ try { lock.db.close(); } catch { /* */ }
1154
+ }
1155
+
1156
+ function rollbackAllSatelliteLocks(locks: SatelliteWriteLocks): void {
1157
+ rollbackSatelliteLock(locks.logs);
1158
+ rollbackSatelliteLock(locks.memories);
1159
+ rollbackSatelliteLock(locks.goals);
1160
+ locks.logs = undefined;
1161
+ locks.memories = undefined;
1162
+ locks.goals = undefined;
1163
+ }
1164
+
1165
+ function commitSatelliteLock(lock: SatelliteWriteLock | undefined): void {
1166
+ if (!lock) return;
1167
+ lock.db.exec("COMMIT");
1168
+ lock.db.close();
1169
+ }
1170
+
1171
+ function snapshotLogsInTx(
1172
+ db: Database,
1173
+ path: string,
1174
+ threadIds: string[],
1175
+ ): SatelliteBackup["logs"] {
1176
+ if (threadIds.length === 0) return undefined;
1177
+ if (!tableExists(db, "logs")) throw new Error("missing_logs_table");
1178
+ const rows: SqlRow[] = [];
1179
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
1180
+ const placeholders = chunk.map(() => "?").join(",");
1181
+ rows.push(...selectRows(db, `SELECT * FROM logs WHERE thread_id IN (${placeholders})`, chunk));
1182
+ }
1183
+ return { path, rows };
1184
+ }
1185
+
1186
+ function snapshotMemoriesInTx(
1187
+ db: Database,
1188
+ path: string,
1189
+ threadIds: string[],
1190
+ ): SatelliteBackup["memories"] {
1191
+ if (threadIds.length === 0) return undefined;
1192
+ if (!tableExists(db, "stage1_outputs")) throw new Error("missing_stage1_outputs_table");
1193
+ const stage1: SqlRow[] = [];
1194
+ let stage1Jobs: SqlRow[] = [];
1195
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
1196
+ const placeholders = chunk.map(() => "?").join(",");
1197
+ stage1.push(...selectRows(
1198
+ db,
1199
+ `SELECT * FROM stage1_outputs WHERE thread_id IN (${placeholders})`,
1200
+ chunk,
1201
+ ));
1202
+ if (tableExists(db, "jobs")) {
1203
+ stage1Jobs.push(...selectRows(
1204
+ db,
1205
+ `SELECT * FROM jobs WHERE kind = ? AND job_key IN (${placeholders})`,
1206
+ [JOB_KIND_MEMORY_STAGE1, ...chunk],
1207
+ ));
1208
+ }
1209
+ }
1210
+ let consolidateJob: SqlRow | null = null;
1211
+ let selectedForPhase2 = 0;
1212
+ if (columnExists(db, "stage1_outputs", "selected_for_phase2")) {
1213
+ selectedForPhase2 = stage1.filter(r => Number(r.selected_for_phase2 ?? 0) !== 0).length;
1214
+ }
1215
+ if (tableExists(db, "jobs")) {
1216
+ consolidateJob = readConsolidateGlobalJob(db);
1217
+ }
1218
+ return {
1219
+ path,
1220
+ stage1,
1221
+ stage1Jobs,
1222
+ consolidateJob,
1223
+ consolidateTouched: selectedForPhase2 > 0,
1224
+ };
1225
+ }
1226
+
1227
+ function snapshotGoalsInTx(
1228
+ db: Database,
1229
+ path: string,
1230
+ threadIds: string[],
1231
+ ): SatelliteBackup["goals"] {
1232
+ if (threadIds.length === 0) return undefined;
1233
+ if (!tableExists(db, "thread_goals")) throw new Error("missing_thread_goals_table");
1234
+ const goals: SqlRow[] = [];
1235
+ let deferrals: SqlRow[] = [];
1236
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
1237
+ const placeholders = chunk.map(() => "?").join(",");
1238
+ goals.push(...selectRows(
1239
+ db,
1240
+ `SELECT * FROM thread_goals WHERE thread_id IN (${placeholders})`,
1241
+ chunk,
1242
+ ));
1243
+ if (tableExists(db, "thread_goal_continuation_deferrals")) {
1244
+ deferrals.push(...selectRows(
1245
+ db,
1246
+ `SELECT * FROM thread_goal_continuation_deferrals WHERE thread_id IN (${placeholders})`,
1247
+ chunk,
1248
+ ));
1249
+ }
1250
+ }
1251
+ return { path, goals, deferrals };
1252
+ }
1253
+
1254
+ /** Snapshot every present satellite under its write lock (rows stable until commit). */
1255
+ function snapshotSatelliteBackupInLocks(
1256
+ locks: SatelliteWriteLocks,
1257
+ threadIds: string[],
1258
+ ): SatelliteBackup {
1259
+ const backup: SatelliteBackup = { threadIds };
1260
+ if (locks.logs) {
1261
+ backup.logs = snapshotLogsInTx(locks.logs.db, locks.logs.path, threadIds);
1262
+ }
1263
+ if (locks.memories) {
1264
+ backup.memories = snapshotMemoriesInTx(locks.memories.db, locks.memories.path, threadIds);
1265
+ }
1266
+ if (locks.goals) {
1267
+ backup.goals = snapshotGoalsInTx(locks.goals.db, locks.goals.path, threadIds);
1268
+ }
1269
+ return backup;
1270
+ }
1271
+
1272
+ function deleteLogsInTx(db: Database, rows: SqlRow[]): void {
1273
+ if (rows.length === 0) return;
1274
+ if (!tableExists(db, "logs")) throw new Error("missing_logs_table");
1275
+ const ids = rows.map(r => r.id).filter(id => id !== null && id !== undefined);
1276
+ if (ids.length === 0) return;
1277
+ for (const chunk of chunkIds(ids as string[], SQLITE_ID_CHUNK * 2)) {
1278
+ const placeholders = chunk.map(() => "?").join(",");
1279
+ db.run(`DELETE FROM logs WHERE id IN (${placeholders})`, chunk as Array<string | number>);
1280
+ }
1281
+ }
1282
+
1283
+ function deleteMemoriesInTx(
1284
+ db: Database,
1285
+ section: NonNullable<SatelliteBackup["memories"]>,
1286
+ ): void {
1287
+ if (!tableExists(db, "stage1_outputs")) throw new Error("missing_stage1_outputs_table");
1288
+ const stage1Ids = section.stage1.map(r => String(r.thread_id));
1289
+ for (const chunk of chunkIds(stage1Ids, SQLITE_ID_CHUNK * 2)) {
1290
+ if (chunk.length === 0) continue;
1291
+ const placeholders = chunk.map(() => "?").join(",");
1292
+ db.run(`DELETE FROM stage1_outputs WHERE thread_id IN (${placeholders})`, chunk);
1293
+ }
1294
+ if (tableExists(db, "jobs")) {
1295
+ const jobKeys = section.stage1Jobs.map(r => String(r.job_key));
1296
+ for (const chunk of chunkIds(jobKeys, SQLITE_ID_CHUNK * 2)) {
1297
+ if (chunk.length === 0) continue;
1298
+ const placeholders = chunk.map(() => "?").join(",");
1299
+ db.run(
1300
+ `DELETE FROM jobs WHERE kind = ? AND job_key IN (${placeholders})`,
1301
+ [JOB_KIND_MEMORY_STAGE1, ...chunk],
1302
+ );
1303
+ }
1304
+ if (section.consolidateTouched) {
1305
+ const now = Math.floor(Date.now() / 1000);
1306
+ db.run(
1307
+ `INSERT INTO jobs (
1308
+ kind, job_key, status, worker_id, ownership_token, started_at, finished_at,
1309
+ lease_until, retry_at, retry_remaining, last_error, input_watermark, last_success_watermark
1310
+ ) VALUES (?, ?, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, ?, NULL, ?, 0)
1311
+ ON CONFLICT(kind, job_key) DO UPDATE SET
1312
+ status = CASE WHEN jobs.status = 'running' THEN 'running' ELSE 'pending' END,
1313
+ retry_at = CASE WHEN jobs.status = 'running' THEN jobs.retry_at ELSE NULL END,
1314
+ retry_remaining = max(jobs.retry_remaining, excluded.retry_remaining),
1315
+ input_watermark = CASE
1316
+ WHEN excluded.input_watermark > COALESCE(jobs.input_watermark, 0)
1317
+ THEN excluded.input_watermark
1318
+ ELSE COALESCE(jobs.input_watermark, 0) + 1
1319
+ END`,
1320
+ [JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL, MEMORY_CONSOLIDATION_JOB_KEY, DEFAULT_RETRY_REMAINING, now],
1321
+ );
1322
+ }
1323
+ }
1324
+ }
1325
+
1326
+ function deleteGoalsInTx(
1327
+ db: Database,
1328
+ section: NonNullable<SatelliteBackup["goals"]>,
1329
+ ): void {
1330
+ if (!tableExists(db, "thread_goals")) throw new Error("missing_thread_goals_table");
1331
+ const deferralIds = section.deferrals.map(r => String(r.thread_id));
1332
+ if (tableExists(db, "thread_goal_continuation_deferrals")) {
1333
+ for (const chunk of chunkIds(deferralIds, SQLITE_ID_CHUNK * 2)) {
1334
+ if (chunk.length === 0) continue;
1335
+ const placeholders = chunk.map(() => "?").join(",");
1336
+ db.run(
1337
+ `DELETE FROM thread_goal_continuation_deferrals WHERE thread_id IN (${placeholders})`,
1338
+ chunk,
1339
+ );
1340
+ }
1341
+ }
1342
+ const goalIds = section.goals.map(r => String(r.thread_id));
1343
+ for (const chunk of chunkIds(goalIds, SQLITE_ID_CHUNK * 2)) {
1344
+ if (chunk.length === 0) continue;
1345
+ const placeholders = chunk.map(() => "?").join(",");
1346
+ db.run(`DELETE FROM thread_goals WHERE thread_id IN (${placeholders})`, chunk);
1347
+ }
1348
+ }
1349
+
1350
+ /** Delete snapshotted primary-key rows and commit each satellite write transaction. */
1351
+ function deleteAndCommitSatellites(
1352
+ locks: SatelliteWriteLocks,
1353
+ backup: SatelliteBackup,
1354
+ stageDir: string,
1355
+ hooks?: ReconcileTestHooks,
1356
+ ): void {
1357
+ try {
1358
+ if (locks.logs && backup.logs) {
1359
+ deleteLogsInTx(locks.logs.db, backup.logs.rows);
1360
+ commitSatelliteLock(locks.logs);
1361
+ locks.logs = undefined;
1362
+ if (hooks?.failAfterLogsMutation) throw new Error("test_fail_after_logs");
1363
+ }
1364
+ if (locks.memories && backup.memories) {
1365
+ deleteMemoriesInTx(locks.memories.db, backup.memories);
1366
+ if (backup.memories.consolidateTouched) {
1367
+ // Capture under the write lock, but persist only after COMMIT+close.
1368
+ // Holding BEGIN IMMEDIATE across a durable backup rewrite lets Windows CI
1369
+ // disk/AV latency stall the lock long enough for concurrent reopen hooks
1370
+ // (and bun's default 5s test timeout) to hang — see PR #558 windows-latest.
1371
+ backup.memories.consolidatePostImage = readConsolidateGlobalJob(locks.memories.db);
1372
+ }
1373
+ commitSatelliteLock(locks.memories);
1374
+ locks.memories = undefined;
1375
+ if (backup.memories.consolidateTouched) {
1376
+ writeSatelliteBackup(stageDir, backup, {
1377
+ failReplaceBeforeRename: hooks?.failSatelliteBackupReplace,
1378
+ });
1379
+ }
1380
+ if (hooks?.failAfterMemoriesMutation) throw new Error("test_fail_after_memories");
1381
+ }
1382
+ if (locks.goals && backup.goals) {
1383
+ deleteGoalsInTx(locks.goals.db, backup.goals);
1384
+ commitSatelliteLock(locks.goals);
1385
+ locks.goals = undefined;
1386
+ if (hooks?.failAfterGoalsMutation) throw new Error("test_fail_after_goals");
1387
+ }
1388
+ } catch (error) {
1389
+ rollbackAllSatelliteLocks(locks);
1390
+ throw error;
1391
+ }
1392
+ }
1393
+
1394
+ /** Restore only snapshotted rows; concurrent inserts/updates after commit stay intact. */
1395
+ function restoreSatelliteBackup(
1396
+ backup: SatelliteBackup,
1397
+ busyTimeoutMs: number,
1398
+ failRestore = false,
1399
+ ): boolean {
1400
+ if (failRestore) return false;
1401
+ try {
1402
+ if (backup.logs) {
1403
+ const restored = withWritableDb(backup.logs.path, busyTimeoutMs, db => {
1404
+ if (!tableExists(db, "logs")) throw new Error("missing_logs_table");
1405
+ insertRowsConflictIgnore(db, "logs", backup.logs!.rows);
1406
+ });
1407
+ if (!restored.ok) return false;
1408
+ }
1409
+ if (backup.memories) {
1410
+ const mem = backup.memories;
1411
+ const restored = withWritableDb(mem.path, busyTimeoutMs, db => {
1412
+ if (!tableExists(db, "stage1_outputs")) throw new Error("missing_stage1_outputs_table");
1413
+ insertRowsConflictIgnore(db, "stage1_outputs", mem.stage1);
1414
+ if (tableExists(db, "jobs")) {
1415
+ insertRowsConflictIgnore(db, "jobs", mem.stage1Jobs);
1416
+ if (mem.consolidateTouched) {
1417
+ restoreConsolidateGlobalJob(db, mem.consolidateJob, mem.consolidatePostImage);
1418
+ }
1419
+ }
1420
+ });
1421
+ if (!restored.ok) return false;
1422
+ }
1423
+ if (backup.goals) {
1424
+ const g = backup.goals;
1425
+ const restored = withWritableDb(g.path, busyTimeoutMs, db => {
1426
+ if (!tableExists(db, "thread_goals")) throw new Error("missing_thread_goals_table");
1427
+ insertRowsConflictIgnore(db, "thread_goals", g.goals);
1428
+ if (tableExists(db, "thread_goal_continuation_deferrals")) {
1429
+ insertRowsConflictIgnore(db, "thread_goal_continuation_deferrals", g.deferrals);
1430
+ }
1431
+ });
1432
+ if (!restored.ok) return false;
1433
+ }
1434
+ return true;
1435
+ } catch {
1436
+ return false;
1437
+ }
1438
+ }
1439
+
1440
+ function withWritableDb(
1441
+ path: string,
1442
+ busyTimeoutMs: number,
1443
+ body: (db: Database) => void,
1444
+ ): { ok: true } | ReconcileErr {
1445
+ let db: Database | undefined;
1446
+ try {
1447
+ db = openDbWritable(path, busyTimeoutMs);
1448
+ db.exec("BEGIN IMMEDIATE");
1449
+ try {
1450
+ body(db);
1451
+ db.exec("COMMIT");
1452
+ return { ok: true };
1453
+ } catch (error) {
1454
+ try { db.exec("ROLLBACK"); } catch { /* */ }
1455
+ throw error;
1456
+ }
1457
+ } catch (error) {
1458
+ return { ok: false, error: mapDbError(error) };
1459
+ } finally {
1460
+ try { db?.close(); } catch { /* */ }
1461
+ }
1462
+ }
1463
+
1464
+ /** Load matching archived threads and refuse referenced history — no deletes yet. */
1465
+ function loadThreadsForCleanup(
1466
+ stateDbPath: string,
1467
+ candidates: ArchivedCandidate[],
1468
+ codexHome: string,
1469
+ busyTimeoutMs: number,
1470
+ ): ReconcileOk | ReconcileErr {
1471
+ if (!stateDbPath || !existsSync(stateDbPath)) return { ok: true, threads: [] };
1472
+ let db: Database | undefined;
1473
+ try {
1474
+ db = openDbWritable(stateDbPath, busyTimeoutMs);
1475
+ const threads = loadMatchingThreads(db, candidates, codexHome);
1476
+ if (threads.some(t => Number(t.is_pinned ?? 0) === 1)) {
1477
+ return { ok: false, error: "pinned_thread" };
1478
+ }
1479
+ if (findReferencedHistory(db, threads)) {
1480
+ return { ok: false, error: "referenced_history" };
1481
+ }
1482
+ return { ok: true, threads };
1483
+ } catch (error) {
1484
+ return { ok: false, error: mapDbError(error) };
1485
+ } finally {
1486
+ try { db?.close(); } catch { /* */ }
1487
+ }
1488
+ }
1489
+
1490
+ /**
1491
+ * Reconcile all Codex per-thread stores for the matched archived candidates.
1492
+ *
1493
+ * Freezes the thread-ID set under the state write lock, persists a complete
1494
+ * satellite backup, then mutates satellites (logs → memories → goals). Any later
1495
+ * failure restores satellite rows before the caller restores staged files.
1496
+ */
1497
+ function reconcileDeletedThreads(
1498
+ paths: RuntimeDbPaths,
1499
+ candidates: ArchivedCandidate[],
1500
+ codexHome: string,
1501
+ busyTimeoutMs: number,
1502
+ stageDir: string,
1503
+ hooks?: ReconcileTestHooks,
1504
+ ): ReconcileOk | ReconcileErr {
1505
+ if (!paths.state || !existsSync(paths.state)) return { ok: true, threads: [] };
1506
+
1507
+ if (hooks?.beforeReconcileLock) hooks.beforeReconcileLock();
1508
+
1509
+ let stateDb: Database | undefined;
1510
+ let backup: SatelliteBackup | undefined;
1511
+ let satellitesMutated = false;
1512
+ let satelliteLocks: SatelliteWriteLocks | undefined;
1513
+
1514
+ const failWithRestore = (error: CleanupErrorCode, mapped?: CleanupErrorCode): ReconcileErr => {
1515
+ const code = mapped ?? error;
1516
+ let satelliteRestoreFailed = false;
1517
+ if (satellitesMutated && backup) {
1518
+ satelliteRestoreFailed = !restoreSatelliteBackup(
1519
+ backup,
1520
+ busyTimeoutMs,
1521
+ Boolean(hooks?.failSatelliteRestore),
1522
+ );
1523
+ // Keep on-disk backup + manifest when restore cannot complete.
1524
+ if (!satelliteRestoreFailed) clearSatelliteBackup(stageDir);
1525
+ } else {
1526
+ clearSatelliteBackup(stageDir);
1527
+ }
1528
+ return {
1529
+ ok: false,
1530
+ error: code,
1531
+ ...(satelliteRestoreFailed ? { satelliteRestoreFailed: true } : {}),
1532
+ };
1533
+ };
1534
+
1535
+ try {
1536
+ stateDb = openDbWritable(paths.state, busyTimeoutMs);
1537
+ stateDb.exec("BEGIN IMMEDIATE");
1538
+
1539
+ // Freeze the exact delete set under the write lock before any satellite mutation.
1540
+ const threads = loadMatchingThreads(stateDb, candidates, codexHome);
1541
+ // A pin applied after selection must stop the delete, even though the
1542
+ // staged files are already in trash staging — the caller restores them.
1543
+ if (threads.some(t => Number(t.is_pinned ?? 0) === 1)) {
1544
+ stateDb.exec("ROLLBACK");
1545
+ return { ok: false, error: "pinned_thread" };
1546
+ }
1547
+ if (findReferencedHistory(stateDb, threads)) {
1548
+ stateDb.exec("ROLLBACK");
1549
+ return { ok: false, error: "referenced_history" };
1550
+ }
1551
+ const threadIds = threads.map(t => t.id);
1552
+
1553
+ satelliteLocks = beginSatelliteWriteLocks(paths, busyTimeoutMs);
1554
+ try {
1555
+ backup = snapshotSatelliteBackupInLocks(satelliteLocks, threadIds);
1556
+ const stateDeps = snapshotStateDependents(stateDb, threadIds);
1557
+ backup.threads = stateDeps.threads;
1558
+ backup.dynamicTools = stateDeps.dynamicTools;
1559
+ backup.spawnEdges = stateDeps.spawnEdges;
1560
+ try {
1561
+ writeSatelliteBackup(stageDir, backup, {
1562
+ failWrite: hooks?.failSatelliteBackupWrite,
1563
+ });
1564
+ } catch {
1565
+ rollbackAllSatelliteLocks(satelliteLocks);
1566
+ stateDb.exec("ROLLBACK");
1567
+ clearSatelliteBackup(stageDir);
1568
+ return { ok: false, error: "fs_failed" };
1569
+ }
1570
+
1571
+ const hasSatelliteWork = Boolean(backup.logs || backup.memories || backup.goals);
1572
+ if (hasSatelliteWork) {
1573
+ satellitesMutated = true;
1574
+ deleteAndCommitSatellites(satelliteLocks, backup, stageDir, hooks);
1575
+ } else {
1576
+ rollbackAllSatelliteLocks(satelliteLocks);
1577
+ }
1578
+ satelliteLocks = undefined;
1579
+
1580
+ if (hooks?.afterSatelliteMutations) hooks.afterSatelliteMutations();
1581
+
1582
+ // Re-check under the same lock before committing state deletes.
1583
+ if (findReferencedHistory(stateDb, threads)) {
1584
+ stateDb.exec("ROLLBACK");
1585
+ return failWithRestore("referenced_history");
1586
+ }
1587
+ deleteThreadsAndDependents(stateDb, threadIds);
1588
+ if (hooks?.failBeforeStateCommit) throw new Error("test_fail_before_state_commit");
1589
+ stateDb.exec("COMMIT");
1590
+ // Keep satellite-backup.json for quarantine restore; permanent purge removes the stage.
1591
+ return { ok: true, threads };
1592
+ } catch (error) {
1593
+ if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
1594
+ throw error;
1595
+ }
1596
+ } catch (error) {
1597
+ try { stateDb?.exec("ROLLBACK"); } catch { /* */ }
1598
+ return failWithRestore("db_reconcile_failed", mapDbError(error));
1599
+ } finally {
1600
+ try { stateDb?.close(); } catch { /* */ }
1601
+ }
1602
+ }
1603
+
1604
+ function absFromRel(codexHome: string, relPath: string): string {
1605
+ if (relPath.includes("..") || isAbsolute(relPath) || /^[A-Za-z]:[\\/]/.test(relPath)) {
1606
+ throw new Error("invalid_rel_path");
1607
+ }
1608
+ const abs = resolve(codexHome, ...relPath.split("/"));
1609
+ const homeAbs = resolve(codexHome);
1610
+ const rel = toForwardSlash(relative(homeAbs, abs));
1611
+ if (!rel || rel.startsWith("..")) throw new Error("path_escape");
1612
+ return abs;
1613
+ }
1614
+
1615
+ function stageCandidates(
1616
+ codexHome: string,
1617
+ candidates: ArchivedCandidate[],
1618
+ stageDir: string,
1619
+ opts?: { blockDestBasenames?: Set<string> },
1620
+ ): { ok: true; staged: StagedFile[] } | { ok: false; staged: StagedFile[] } {
1621
+ const staged: StagedFile[] = [];
1622
+ const usedBasenames = new Set<string>();
1623
+ try {
1624
+ mkdirSync(stageDir, { recursive: true });
1625
+ for (const candidate of candidates) {
1626
+ for (const rel of candidate.physicalRelPaths) {
1627
+ const from = absFromRel(codexHome, rel);
1628
+ const base = basename(rel);
1629
+ // archived_sessions/ is flat today; refuse collisions so a future nested walk
1630
+ // cannot silently overwrite another staged file.
1631
+ if (usedBasenames.has(base)) {
1632
+ throw new Error("stage_basename_collision");
1633
+ }
1634
+ usedBasenames.add(base);
1635
+ const to = join(stageDir, base);
1636
+ if (opts?.blockDestBasenames?.has(base)) {
1637
+ mkdirSync(to, { recursive: true });
1638
+ }
1639
+ renameSync(from, to);
1640
+ staged.push({ from, to, relPath: rel });
1641
+ }
1642
+ }
1643
+ return { ok: true, staged };
1644
+ } catch {
1645
+ return { ok: false, staged };
1646
+ }
1647
+ }
1648
+
1649
+ /**
1650
+ * Rename staged files back to their originals.
1651
+ * Returns whether every staged file was restored. Unrestored entries stay in `remaining`.
1652
+ */
1653
+ function rollbackStaged(
1654
+ staged: StagedFile[],
1655
+ opts?: { failBasenames?: Set<string> },
1656
+ ): { restored: boolean; remaining: StagedFile[] } {
1657
+ const remaining: StagedFile[] = [];
1658
+ for (let i = staged.length - 1; i >= 0; i--) {
1659
+ const item = staged[i]!;
1660
+ const base = basename(item.to);
1661
+ if (opts?.failBasenames?.has(base)) {
1662
+ remaining.push(item);
1663
+ continue;
1664
+ }
1665
+ try {
1666
+ if (existsSync(item.to) && !existsSync(item.from)) {
1667
+ renameSync(item.to, item.from);
1668
+ } else if (existsSync(item.to)) {
1669
+ // Destination occupied — cannot restore without clobbering.
1670
+ remaining.push(item);
1671
+ }
1672
+ } catch {
1673
+ remaining.push(item);
1674
+ }
1675
+ }
1676
+ return { restored: remaining.length === 0, remaining };
1677
+ }
1678
+
1679
+ function purgeStaged(
1680
+ staged: StagedFile[],
1681
+ opts?: { failBasenames?: Set<string> },
1682
+ ): { purged: StagedFile[]; remaining: StagedFile[] } {
1683
+ const purged: StagedFile[] = [];
1684
+ const remaining: StagedFile[] = [];
1685
+ for (const item of staged) {
1686
+ const base = basename(item.to);
1687
+ if (opts?.failBasenames?.has(base)) {
1688
+ remaining.push(item);
1689
+ continue;
1690
+ }
1691
+ try {
1692
+ unlinkSync(item.to);
1693
+ purged.push(item);
1694
+ } catch {
1695
+ remaining.push(item);
1696
+ }
1697
+ }
1698
+ return { purged, remaining };
1699
+ }
1700
+
1701
+ /** Remove stageDir only when it contains no unrestored staged files. */
1702
+ function removeStageIfEmpty(stageDir: string, remaining: StagedFile[]): void {
1703
+ if (remaining.length > 0) return;
1704
+ try { rmSync(stageDir, { recursive: true, force: true }); } catch { /* */ }
1705
+ }
1706
+
1707
+ function removeEmptyTrashRoot(codexHome: string): void {
1708
+ try {
1709
+ const trashRoot = join(codexHome, TRASH_DIR);
1710
+ if (existsSync(trashRoot) && readdirSync(trashRoot).length === 0) {
1711
+ rmSync(trashRoot, { recursive: true, force: true });
1712
+ }
1713
+ } catch { /* */ }
1714
+ }
1715
+
1716
+ function trashRelPath(codexHome: string, stageDir: string): string {
1717
+ return toForwardSlash(relative(codexHome, stageDir) || stageDir);
1718
+ }
1719
+
1720
+ export interface ExecuteCleanupOptions {
1721
+ percent: number;
1722
+ mode: CleanupMode;
1723
+ /** Required digest from preview; rejects when the candidate set drifted. */
1724
+ digest: string;
1725
+ /**
1726
+ * Optional exact candidate set (logical relPaths). When set, selection bypasses
1727
+ * percent rounding and the digest must match `computeExactPreviewDigest`.
1728
+ */
1729
+ candidateRelPaths?: string[];
1730
+ codexHome?: string;
1731
+ /** Test-only: shrink busy_timeout so lock tests fail fast. */
1732
+ busyTimeoutMs?: number;
1733
+ now?: number;
1734
+ /** Test-only failure injection for atomicity regressions. */
1735
+ _test?: {
1736
+ failManifestWrite?: boolean;
1737
+ failPurgeBasenames?: string[];
1738
+ failRollbackBasenames?: string[];
1739
+ blockStageDestBasenames?: string[];
1740
+ failAfterLogsMutation?: boolean;
1741
+ failAfterMemoriesMutation?: boolean;
1742
+ failAfterGoalsMutation?: boolean;
1743
+ failBeforeStateCommit?: boolean;
1744
+ failSatelliteRestore?: boolean;
1745
+ failSatelliteBackupWrite?: boolean;
1746
+ failSatelliteBackupReplace?: boolean;
1747
+ afterSatelliteMutations?: () => void;
1748
+ beforeReconcileLock?: () => void;
1749
+ };
1750
+ }
1751
+
1752
+ /** Serializable cleanup test hooks allowed on the management API wire. */
1753
+ export type CleanupWireTestHooks = Omit<
1754
+ NonNullable<ExecuteCleanupOptions["_test"]>,
1755
+ "afterSatelliteMutations" | "beforeReconcileLock"
1756
+ >;
1757
+
1758
+ function isStringArray(v: unknown): v is string[] {
1759
+ return Array.isArray(v) && v.every(e => typeof e === "string");
1760
+ }
1761
+
1762
+ /** Pick only allowlisted serializable hooks; drops function hooks (afterSatelliteMutations, beforeReconcileLock) and unknown keys. */
1763
+ export function pickWireCleanupTestHooks(raw: unknown): CleanupWireTestHooks | undefined {
1764
+ if (!raw || typeof raw !== "object") return undefined;
1765
+ const o = raw as Record<string, unknown>;
1766
+ const out: CleanupWireTestHooks = {};
1767
+ if (typeof o.failManifestWrite === "boolean") out.failManifestWrite = o.failManifestWrite;
1768
+ if (isStringArray(o.failPurgeBasenames)) out.failPurgeBasenames = o.failPurgeBasenames;
1769
+ if (isStringArray(o.failRollbackBasenames)) out.failRollbackBasenames = o.failRollbackBasenames;
1770
+ if (isStringArray(o.blockStageDestBasenames)) out.blockStageDestBasenames = o.blockStageDestBasenames;
1771
+ if (typeof o.failAfterLogsMutation === "boolean") out.failAfterLogsMutation = o.failAfterLogsMutation;
1772
+ if (typeof o.failAfterMemoriesMutation === "boolean") out.failAfterMemoriesMutation = o.failAfterMemoriesMutation;
1773
+ if (typeof o.failAfterGoalsMutation === "boolean") out.failAfterGoalsMutation = o.failAfterGoalsMutation;
1774
+ if (typeof o.failBeforeStateCommit === "boolean") out.failBeforeStateCommit = o.failBeforeStateCommit;
1775
+ if (typeof o.failSatelliteRestore === "boolean") out.failSatelliteRestore = o.failSatelliteRestore;
1776
+ if (typeof o.failSatelliteBackupWrite === "boolean") out.failSatelliteBackupWrite = o.failSatelliteBackupWrite;
1777
+ if (typeof o.failSatelliteBackupReplace === "boolean") out.failSatelliteBackupReplace = o.failSatelliteBackupReplace;
1778
+ return Object.keys(out).length > 0 ? out : undefined;
1779
+ }
1780
+
1781
+ function fail(
1782
+ mode: CleanupMode,
1783
+ percent: number,
1784
+ error: CleanupErrorCode,
1785
+ extra?: { trashDir?: string },
1786
+ ): CleanupResult {
1787
+ return {
1788
+ ok: false,
1789
+ mode,
1790
+ percent,
1791
+ count: 0,
1792
+ bytes: 0,
1793
+ removedPaths: [],
1794
+ error,
1795
+ ...(extra?.trashDir ? { trashDir: extra.trashDir } : {}),
1796
+ };
1797
+ }
1798
+
1799
+ /**
1800
+ * Execute archived cleanup bound to a preview digest.
1801
+ * Stages every physical file, writes the recovery manifest, then commits DB deletes.
1802
+ * Rollback never deletes a stage directory that still holds unrestored files.
1803
+ */
1804
+ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupResult {
1805
+ const codexHome = options.codexHome ?? resolveCodexHomeDir();
1806
+ const mode = options.mode;
1807
+ const percent = clampPercent(options.percent);
1808
+ const busyTimeoutMs = options.busyTimeoutMs ?? 100;
1809
+ const failRollback = new Set(options._test?.failRollbackBasenames ?? []);
1810
+ const failPurge = new Set(options._test?.failPurgeBasenames ?? []);
1811
+ const blockStageDest = new Set(options._test?.blockStageDestBasenames ?? []);
1812
+
1813
+ if (mode !== "quarantine" && mode !== "permanent") {
1814
+ return fail(mode, percent, "invalid_mode");
1815
+ }
1816
+ if (typeof options.digest !== "string" || !/^[a-f0-9]{64}$/i.test(options.digest)) {
1817
+ return fail(mode, percent, "invalid_digest");
1818
+ }
1819
+
1820
+ let preview: CleanupPreview;
1821
+ let unfilteredSelected: ArchivedCandidate[];
1822
+ if (options.candidateRelPaths !== undefined) {
1823
+ const selected = resolveExactArchivedCandidates(options.candidateRelPaths, codexHome);
1824
+ if (selected === null) {
1825
+ return fail(mode, percent, "stale_preview");
1826
+ }
1827
+ unfilteredSelected = selected;
1828
+ preview = previewExactArchivedCleanup(selected, codexHome);
1829
+ } else {
1830
+ const all = listArchivedCandidates(codexHome);
1831
+ unfilteredSelected = selectOldestPercent(all, percent);
1832
+ preview = previewArchivedCleanup(percent, codexHome);
1833
+ }
1834
+ if (preview.digest.toLowerCase() !== options.digest.toLowerCase()) {
1835
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
1836
+ const blocked = unfilteredSelected.filter(c => candidateOverlapsPendingRestore(c, pendingDestRels));
1837
+ const unfilteredDigest = options.candidateRelPaths !== undefined
1838
+ ? computeExactPreviewDigest(unfilteredSelected)
1839
+ : computePreviewDigest(unfilteredSelected, percent);
1840
+ if (
1841
+ unfilteredDigest.toLowerCase() === options.digest.toLowerCase()
1842
+ && blocked.length > 0
1843
+ ) {
1844
+ return fail(mode, percent, "restore_pending_overlap");
1845
+ }
1846
+ return fail(mode, percent, "stale_preview");
1847
+ }
1848
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
1849
+ if (preview.candidates.some(c => candidateOverlapsPendingRestore(c, pendingDestRels))) {
1850
+ return fail(mode, percent, "restore_pending_overlap");
1851
+ }
1852
+
1853
+ if (preview.candidates.length === 0) {
1854
+ return {
1855
+ ok: true,
1856
+ mode,
1857
+ percent,
1858
+ count: 0,
1859
+ bytes: 0,
1860
+ removedPaths: [],
1861
+ };
1862
+ }
1863
+
1864
+ const paths = discoverRuntimeDbPaths(codexHome);
1865
+ const probe = probeStateDbWritable(codexHome, busyTimeoutMs);
1866
+ if (!probe.ok) {
1867
+ return fail(mode, percent, probe.error);
1868
+ }
1869
+
1870
+ // Preflight referenced-history / matching while DB is free, before any rename.
1871
+ const loaded = loadThreadsForCleanup(paths.state ?? "", preview.candidates, codexHome, busyTimeoutMs);
1872
+ if (!loaded.ok) {
1873
+ return fail(mode, percent, loaded.error);
1874
+ }
1875
+
1876
+ const epoch = options.now ?? Date.now();
1877
+ let stageDir: string;
1878
+ try {
1879
+ stageDir = createExclusiveStageDir(codexHome, epoch);
1880
+ } catch {
1881
+ return fail(mode, percent, "fs_failed");
1882
+ }
1883
+ const trashDir = trashRelPath(codexHome, stageDir);
1884
+
1885
+ const threadByRelPath = new Map<string, ThreadSnapshot>();
1886
+ for (const thread of loaded.threads) {
1887
+ const normalized = normalizeArchivedRolloutPath(thread.rollout_path, codexHome);
1888
+ if (normalized) threadByRelPath.set(normalized, thread);
1889
+ }
1890
+ const manifestEntries: CleanupManifestEntry[] = preview.candidates.map(candidate => {
1891
+ const thread = threadByRelPath.get(candidate.relPath);
1892
+ return {
1893
+ relPath: candidate.relPath,
1894
+ bytes: candidate.bytes,
1895
+ mtimeMs: candidate.mtimeMs,
1896
+ physicalRelPaths: candidate.physicalRelPaths,
1897
+ ...(thread
1898
+ ? { threadId: thread.id, rolloutPath: thread.rollout_path, archived: thread.archived }
1899
+ : {}),
1900
+ };
1901
+ });
1902
+
1903
+ const writeManifest = (extra: Record<string, unknown> = {}) => {
1904
+ writePrivateFile(
1905
+ join(stageDir, "manifest.json"),
1906
+ JSON.stringify({
1907
+ quarantinedAt: epoch,
1908
+ mode,
1909
+ percent,
1910
+ digest: preview.digest,
1911
+ entries: manifestEntries,
1912
+ ...extra,
1913
+ }, null, 2),
1914
+ );
1915
+ };
1916
+
1917
+ // Journal staged paths before the first rename so a crash mid-stage is recoverable.
1918
+ try {
1919
+ if (options._test?.failManifestWrite) {
1920
+ throw new Error("test_fail_manifest_write");
1921
+ }
1922
+ writeManifest({ staging: true });
1923
+ } catch {
1924
+ removeStageIfEmpty(stageDir, []);
1925
+ return fail(mode, percent, "fs_failed");
1926
+ }
1927
+
1928
+ const stageResult = stageCandidates(codexHome, preview.candidates, stageDir, {
1929
+ blockDestBasenames: blockStageDest.size > 0 ? blockStageDest : undefined,
1930
+ });
1931
+ if (!stageResult.ok) {
1932
+ const rolled = rollbackStaged(stageResult.staged, { failBasenames: failRollback });
1933
+ removeStageIfEmpty(stageDir, rolled.remaining);
1934
+ return fail(mode, percent, "fs_failed", rolled.restored ? undefined : { trashDir });
1935
+ }
1936
+
1937
+ // Final manifest before DB deletion so a mid-flight crash still has recovery metadata.
1938
+ try {
1939
+ writeManifest();
1940
+ } catch {
1941
+ const rolled = rollbackStaged(stageResult.staged, { failBasenames: failRollback });
1942
+ removeStageIfEmpty(stageDir, rolled.remaining);
1943
+ return fail(mode, percent, "fs_failed", rolled.restored ? undefined : { trashDir });
1944
+ }
1945
+
1946
+ const deleted = reconcileDeletedThreads(
1947
+ paths,
1948
+ preview.candidates,
1949
+ codexHome,
1950
+ busyTimeoutMs,
1951
+ stageDir,
1952
+ options._test,
1953
+ );
1954
+ if (!deleted.ok) {
1955
+ const rolled = rollbackStaged(stageResult.staged, { failBasenames: failRollback });
1956
+ // Keep the stage (and recovery manifest) when files or satellite DB rows remain unrestored.
1957
+ const keepTrash = Boolean(deleted.satelliteRestoreFailed) || !rolled.restored;
1958
+ if (!keepTrash) {
1959
+ removeStageIfEmpty(stageDir, rolled.remaining);
1960
+ removeEmptyTrashRoot(codexHome);
1961
+ }
1962
+ return fail(mode, percent, deleted.error, keepTrash ? { trashDir } : undefined);
1963
+ }
1964
+
1965
+ const removedPaths = preview.candidates.map(c => c.relPath);
1966
+ const bytes = preview.candidates.reduce((sum, c) => sum + c.bytes, 0);
1967
+
1968
+ if (mode === "quarantine") {
1969
+ return {
1970
+ ok: true,
1971
+ mode,
1972
+ percent,
1973
+ count: removedPaths.length,
1974
+ bytes,
1975
+ trashDir,
1976
+ removedPaths,
1977
+ };
1978
+ }
1979
+
1980
+ // Permanent: purge staged files only after a successful DB commit.
1981
+ const purge = purgeStaged(stageResult.staged, { failBasenames: failPurge });
1982
+ if (purge.remaining.length > 0) {
1983
+ // Overwrite the pre-commit manifest so recovery reflects what actually survived.
1984
+ const survivingRelPaths = new Set(purge.remaining.map(item => item.relPath));
1985
+ try {
1986
+ writePrivateFile(
1987
+ join(stageDir, "manifest.json"),
1988
+ JSON.stringify({
1989
+ quarantinedAt: epoch,
1990
+ mode: "permanent",
1991
+ percent,
1992
+ digest: preview.digest,
1993
+ purgeIncomplete: true,
1994
+ purgedRelPaths: purge.purged.map(item => item.relPath),
1995
+ entries: manifestEntries
1996
+ .map(entry => ({
1997
+ ...entry,
1998
+ physicalRelPaths: entry.physicalRelPaths.filter(rel => survivingRelPaths.has(rel)),
1999
+ }))
2000
+ .filter(entry => entry.physicalRelPaths.length > 0),
2001
+ }, null, 2),
2002
+ );
2003
+ } catch { /* best-effort: the pre-commit manifest is still on disk */ }
2004
+ return {
2005
+ ok: false,
2006
+ mode,
2007
+ percent,
2008
+ count: 0,
2009
+ bytes: 0,
2010
+ trashDir,
2011
+ removedPaths: [],
2012
+ error: "fs_failed",
2013
+ };
2014
+ }
2015
+
2016
+ try { rmSync(stageDir, { recursive: true, force: true }); } catch { /* empty dir */ }
2017
+ // Drop an empty `.trash` root so permanent cleanup leaves no quarantine tree behind.
2018
+ removeEmptyTrashRoot(codexHome);
2019
+
2020
+ return {
2021
+ ok: true,
2022
+ mode,
2023
+ percent,
2024
+ count: removedPaths.length,
2025
+ bytes,
2026
+ removedPaths,
2027
+ };
2028
+ }
2029
+
2030
+ // ---------------------------------------------------------------------------
2031
+ // Phase 2.1 — quarantine list + restore
2032
+ // ---------------------------------------------------------------------------
2033
+
2034
+ export type RestoreErrorCode =
2035
+ | "invalid_trash"
2036
+ | "missing_trash"
2037
+ | "codex_busy"
2038
+ | "storage_mutation_busy"
2039
+ | "fs_failed"
2040
+ | "db_reconcile_failed"
2041
+ | "dest_exists"
2042
+ | "restore_failed"
2043
+ | "restore_worker_timeout"
2044
+ | "restore_worker_aborted"
2045
+ | "restore_worker_failed";
2046
+
2047
+ export interface TrashEntrySummary {
2048
+ /** CODEX_HOME-relative path, e.g. `.trash/1700000000000`. */
2049
+ id: string;
2050
+ /** Epoch directory name (may include collision suffix, e.g. `1700-1`). */
2051
+ epoch: string;
2052
+ fileCount: number;
2053
+ bytes: number;
2054
+ quarantinedAt?: number;
2055
+ mode?: CleanupMode;
2056
+ }
2057
+
2058
+ export interface RestoreResult {
2059
+ ok: boolean;
2060
+ trashDir?: string;
2061
+ count: number;
2062
+ bytes: number;
2063
+ restoredPaths: string[];
2064
+ error?: RestoreErrorCode;
2065
+ /** Optional operator-facing detail when the error code alone is insufficient. */
2066
+ message?: string;
2067
+ }
2068
+
2069
+ interface TrashManifest {
2070
+ quarantinedAt?: number;
2071
+ mode?: CleanupMode;
2072
+ entries?: CleanupManifestEntry[];
2073
+ }
2074
+
2075
+ /** Epoch dir names: digits, optionally `-N` from createExclusiveStageDir collision. */
2076
+ const TRASH_EPOCH_DIR = /^(\d+)(-\d+)?$/;
2077
+
2078
+ /**
2079
+ * Parse a trash `manifest.json` atomically.
2080
+ *
2081
+ * Any missing `entries` array, or any malformed entry / `physicalRelPaths` value /
2082
+ * required field, rejects the **entire** manifest (returns null). Individual bad
2083
+ * entries are never filtered out so a partial parse cannot silently drop evidence.
2084
+ */
2085
+ function parseTrashManifest(raw: string): TrashManifest | null {
2086
+ try {
2087
+ const parsed = JSON.parse(raw) as unknown;
2088
+ if (!parsed || typeof parsed !== "object") return null;
2089
+ const o = parsed as Record<string, unknown>;
2090
+ if (!Array.isArray(o.entries)) return null;
2091
+
2092
+ const entries: CleanupManifestEntry[] = [];
2093
+ for (const e of o.entries) {
2094
+ if (!e || typeof e !== "object" || Array.isArray(e)) return null;
2095
+ const entry = e as Record<string, unknown>;
2096
+ if (typeof entry.relPath !== "string" || entry.relPath.length === 0) return null;
2097
+ if (typeof entry.bytes !== "number" || !Number.isFinite(entry.bytes)) return null;
2098
+ if (typeof entry.mtimeMs !== "number" || !Number.isFinite(entry.mtimeMs)) return null;
2099
+ if (!Array.isArray(entry.physicalRelPaths) || entry.physicalRelPaths.length === 0) return null;
2100
+ const physical: string[] = [];
2101
+ for (const p of entry.physicalRelPaths) {
2102
+ // Do not strip bad elements — one malformed path invalidates the whole manifest.
2103
+ if (typeof p !== "string" || p.length === 0) return null;
2104
+ physical.push(p);
2105
+ }
2106
+ if ("threadId" in entry && typeof entry.threadId !== "string") return null;
2107
+ if ("rolloutPath" in entry && typeof entry.rolloutPath !== "string") return null;
2108
+ if (
2109
+ "archived" in entry
2110
+ && entry.archived !== null
2111
+ && typeof entry.archived !== "number"
2112
+ ) {
2113
+ return null;
2114
+ }
2115
+ entries.push({
2116
+ relPath: entry.relPath,
2117
+ bytes: entry.bytes,
2118
+ mtimeMs: entry.mtimeMs,
2119
+ physicalRelPaths: physical,
2120
+ ...(typeof entry.threadId === "string" ? { threadId: entry.threadId } : {}),
2121
+ ...(typeof entry.rolloutPath === "string" ? { rolloutPath: entry.rolloutPath } : {}),
2122
+ ...(entry.archived === null || typeof entry.archived === "number"
2123
+ ? { archived: entry.archived as number | null }
2124
+ : {}),
2125
+ });
2126
+ }
2127
+
2128
+ const out: TrashManifest = { entries };
2129
+ if (typeof o.quarantinedAt === "number" && Number.isFinite(o.quarantinedAt)) {
2130
+ out.quarantinedAt = o.quarantinedAt;
2131
+ }
2132
+ if (o.mode === "quarantine" || o.mode === "permanent") out.mode = o.mode;
2133
+ return out;
2134
+ } catch {
2135
+ return null;
2136
+ }
2137
+ }
2138
+
2139
+ /**
2140
+ * Validate a trash entry id as a single `.trash/<epoch>` segment under CODEX_HOME.
2141
+ * Returns the absolute stage directory, or null when the id is unsafe / missing.
2142
+ */
2143
+ export function resolveTrashStageDir(
2144
+ trashId: string,
2145
+ codexHome: string,
2146
+ ): { ok: true; stageDir: string; id: string } | { ok: false; error: RestoreErrorCode } {
2147
+ const normalized = toForwardSlash(trashId.trim()).replace(/\/+$/, "");
2148
+ if (!normalized.startsWith(`${TRASH_DIR}/`)) return { ok: false, error: "invalid_trash" };
2149
+ const rest = normalized.slice(TRASH_DIR.length + 1);
2150
+ if (!rest || rest.includes("/") || rest.includes("\\") || rest.includes("..")) {
2151
+ return { ok: false, error: "invalid_trash" };
2152
+ }
2153
+ if (!TRASH_EPOCH_DIR.test(rest)) return { ok: false, error: "invalid_trash" };
2154
+ let stageDir: string;
2155
+ try {
2156
+ stageDir = absFromRel(codexHome, `${TRASH_DIR}/${rest}`);
2157
+ } catch {
2158
+ return { ok: false, error: "invalid_trash" };
2159
+ }
2160
+ if (!existsSync(stageDir)) return { ok: false, error: "missing_trash" };
2161
+ try {
2162
+ if (!statSync(stageDir).isDirectory()) return { ok: false, error: "invalid_trash" };
2163
+ } catch {
2164
+ return { ok: false, error: "missing_trash" };
2165
+ }
2166
+ return { ok: true, stageDir, id: `${TRASH_DIR}/${rest}` };
2167
+ }
2168
+
2169
+ function sumTrashEntryBytes(stageDir: string, manifest: TrashManifest | null): {
2170
+ fileCount: number;
2171
+ bytes: number;
2172
+ } {
2173
+ let fileCount = 0;
2174
+ let bytes = 0;
2175
+ let names: string[] = [];
2176
+ try {
2177
+ names = readdirSync(stageDir);
2178
+ } catch {
2179
+ return { fileCount: 0, bytes: 0 };
2180
+ }
2181
+ for (const name of names) {
2182
+ if (
2183
+ name === "manifest.json"
2184
+ || name === SATELLITE_BACKUP_FILE
2185
+ || name === RESTORE_PENDING_FILE
2186
+ ) {
2187
+ continue;
2188
+ }
2189
+ if (!isRolloutFileName(name)) continue;
2190
+ try {
2191
+ const st = statSync(join(stageDir, name));
2192
+ if (!st.isFile()) continue;
2193
+ fileCount += 1;
2194
+ bytes += st.size;
2195
+ } catch { /* */ }
2196
+ }
2197
+ // Prefer live FS counts; fall back to manifest totals when the stage is empty of rollouts.
2198
+ if (fileCount === 0 && manifest?.entries?.length) {
2199
+ fileCount = manifest.entries.reduce((n, e) => n + Math.max(1, e.physicalRelPaths.length), 0);
2200
+ bytes = manifest.entries.reduce((n, e) => n + (e.bytes || 0), 0);
2201
+ }
2202
+ return { fileCount, bytes };
2203
+ }
2204
+
2205
+ /** List quarantine entries under `CODEX_HOME/.trash/` (relative ids only). */
2206
+ export function listTrashEntries(
2207
+ codexHome: string = resolveCodexHomeDir(),
2208
+ ): TrashEntrySummary[] {
2209
+ const trashRoot = join(codexHome, TRASH_DIR);
2210
+ let names: string[] = [];
2211
+ try {
2212
+ names = readdirSync(trashRoot);
2213
+ } catch {
2214
+ return [];
2215
+ }
2216
+ const out: TrashEntrySummary[] = [];
2217
+ for (const name of names) {
2218
+ if (!TRASH_EPOCH_DIR.test(name)) continue;
2219
+ const stageDir = join(trashRoot, name);
2220
+ try {
2221
+ if (!statSync(stageDir).isDirectory()) continue;
2222
+ } catch {
2223
+ continue;
2224
+ }
2225
+ let manifest: TrashManifest | null = null;
2226
+ try {
2227
+ manifest = parseTrashManifest(readFileSync(join(stageDir, "manifest.json"), "utf8"));
2228
+ } catch {
2229
+ manifest = null;
2230
+ }
2231
+ const { fileCount, bytes } = sumTrashEntryBytes(stageDir, manifest);
2232
+ // Skip empty collision placeholders left behind without a manifest or rollouts.
2233
+ if (fileCount === 0 && !manifest?.entries?.length) {
2234
+ try {
2235
+ if (!existsSync(join(stageDir, "manifest.json"))) continue;
2236
+ } catch {
2237
+ continue;
2238
+ }
2239
+ }
2240
+ out.push({
2241
+ id: `${TRASH_DIR}/${name}`,
2242
+ epoch: name,
2243
+ fileCount,
2244
+ bytes,
2245
+ ...(manifest?.quarantinedAt !== undefined ? { quarantinedAt: manifest.quarantinedAt } : {}),
2246
+ ...(manifest?.mode ? { mode: manifest.mode } : {}),
2247
+ });
2248
+ }
2249
+ out.sort((a, b) => {
2250
+ const aq = a.quarantinedAt ?? (Number(a.epoch.split("-")[0]) || 0);
2251
+ const bq = b.quarantinedAt ?? (Number(b.epoch.split("-")[0]) || 0);
2252
+ return bq - aq || b.epoch.localeCompare(a.epoch);
2253
+ });
2254
+ return out;
2255
+ }
2256
+
2257
+ function readSatelliteBackupFile(stageDir: string): SatelliteBackupRead {
2258
+ const path = join(stageDir, SATELLITE_BACKUP_FILE);
2259
+ if (!existsSync(path)) return { status: "missing" };
2260
+ try {
2261
+ const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
2262
+ if (!raw || typeof raw !== "object") return { status: "invalid" };
2263
+ const o = raw as SatelliteBackup;
2264
+ if (!Array.isArray(o.threadIds)) return { status: "invalid" };
2265
+ return { status: "ok", backup: o };
2266
+ } catch {
2267
+ // File exists but is truncated / malformed — distinct from a missing backup.
2268
+ return { status: "invalid" };
2269
+ }
2270
+ }
2271
+
2272
+ function isSqlRowArray(value: unknown): value is SqlRow[] {
2273
+ return Array.isArray(value) && value.every(row => row && typeof row === "object" && !Array.isArray(row));
2274
+ }
2275
+
2276
+ /** True when a snapshotted thread row covers every NOT NULL column on the live schema. */
2277
+ function threadSnapshotCoversRequiredColumns(row: SqlRow, requiredCols: string[]): boolean {
2278
+ for (const col of requiredCols) {
2279
+ if (!(col in row) || row[col] === undefined) return false;
2280
+ }
2281
+ return true;
2282
+ }
2283
+
2284
+ function requiredThreadColumnNames(db: Database): string[] {
2285
+ if (!tableExists(db, "threads")) return [];
2286
+ const rows = db.query<{ name: string; notnull: number }, []>(
2287
+ `PRAGMA table_info("threads")`,
2288
+ ).all();
2289
+ return rows.filter(r => r.notnull === 1).map(r => r.name);
2290
+ }
2291
+
2292
+ /**
2293
+ * Build a production-shaped thread row for schemas that predate full satellite snapshots.
2294
+ * Prefer `readThreadFieldsFromRollout` (canonical history/session_meta path); fall back to
2295
+ * the sparse manifest fields only when the live schema does not require model/source/message.
2296
+ */
2297
+ function reconstructThreadRowFromRollout(
2298
+ entry: CleanupManifestEntry,
2299
+ rolloutAbsPath: string,
2300
+ allowedCols: Set<string>,
2301
+ requiredCols: string[],
2302
+ ): SqlRow | null {
2303
+ if (typeof entry.threadId !== "string" || typeof entry.rolloutPath !== "string") return null;
2304
+
2305
+ const fields = readThreadFieldsFromRollout(rolloutAbsPath);
2306
+ const row: SqlRow = {
2307
+ id: entry.threadId,
2308
+ rollout_path: entry.rolloutPath,
2309
+ };
2310
+
2311
+ if (fields) {
2312
+ // Prefer manifest thread id (binding) but keep rollout-derived listing fields.
2313
+ if (allowedCols.has("model_provider")) row.model_provider = fields.modelProvider;
2314
+ if (allowedCols.has("source")) row.source = fields.source;
2315
+ if (allowedCols.has("first_user_message")) row.first_user_message = fields.firstUserMessage;
2316
+ if (allowedCols.has("has_user_event")) row.has_user_event = fields.hasUserEvent;
2317
+ if (allowedCols.has("cwd") && fields.cwd !== undefined) row.cwd = fields.cwd;
2318
+ if (allowedCols.has("history_mode") && fields.historyMode !== undefined) {
2319
+ row.history_mode = fields.historyMode;
2320
+ }
2321
+ if (allowedCols.has("cli_version") && fields.cliVersion !== undefined) {
2322
+ row.cli_version = fields.cliVersion;
2323
+ }
2324
+ }
2325
+
2326
+ if (allowedCols.has("archived")) {
2327
+ row.archived = entry.archived ?? 1;
2328
+ }
2329
+ if (allowedCols.has("archived_at")) {
2330
+ row.archived_at = null;
2331
+ }
2332
+
2333
+ // Fill remaining NOT NULL columns with safe empties when the rollout lacked them
2334
+ // (e.g. fixture rollouts without a user turn still need first_user_message = '').
2335
+ for (const col of requiredCols) {
2336
+ if (row[col] !== undefined) continue;
2337
+ if (col === "id" || col === "rollout_path") continue;
2338
+ if (col === "model_provider") row[col] = "openai";
2339
+ else if (col === "source") row[col] = "cli";
2340
+ else if (col === "first_user_message") row[col] = "";
2341
+ else if (col === "has_user_event") row[col] = 0;
2342
+ else if (col === "archived") row[col] = entry.archived ?? 1;
2343
+ else return null; // unknown required column we cannot invent
2344
+ }
2345
+
2346
+ // If the schema requires listing fields, refuse when the rollout was unreadable.
2347
+ const needsSessionMeta = requiredCols.some(
2348
+ c => c === "model_provider" || c === "source" || c === "first_user_message",
2349
+ );
2350
+ if (needsSessionMeta && !fields) return null;
2351
+
2352
+ return row;
2353
+ }
2354
+
2355
+ type RestorePendingRead =
2356
+ | { status: "missing" }
2357
+ | { status: "valid"; state: RestorePendingState }
2358
+ | { status: "invalid" };
2359
+
2360
+ let _restorePendingSeq = 0;
2361
+
2362
+ function parseRestorePendingState(raw: unknown): RestorePendingState | null {
2363
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
2364
+ const o = raw as Record<string, unknown>;
2365
+ if (o.version !== 1 || o.filesRestored !== true) return null;
2366
+ if (!Array.isArray(o.acceptedDestRels)) return null;
2367
+ const acceptedDestRels = o.acceptedDestRels.filter((r): r is string => typeof r === "string");
2368
+ if (acceptedDestRels.length !== o.acceptedDestRels.length) return null;
2369
+ const pendingRaw = o.pending;
2370
+ if (!pendingRaw || typeof pendingRaw !== "object" || Array.isArray(pendingRaw)) return null;
2371
+ const p = pendingRaw as Record<string, unknown>;
2372
+ if (
2373
+ typeof p.state !== "boolean"
2374
+ || typeof p.logs !== "boolean"
2375
+ || typeof p.memories !== "boolean"
2376
+ || typeof p.goals !== "boolean"
2377
+ ) {
2378
+ return null;
2379
+ }
2380
+ return {
2381
+ version: 1,
2382
+ filesRestored: true,
2383
+ acceptedDestRels,
2384
+ pending: {
2385
+ state: p.state,
2386
+ logs: p.logs,
2387
+ memories: p.memories,
2388
+ goals: p.goals,
2389
+ },
2390
+ };
2391
+ }
2392
+
2393
+ /**
2394
+ * Distinguish a missing marker from a present-but-malformed one. An invalid marker
2395
+ * must never be treated as a fresh restore (that would ignore already-moved files).
2396
+ */
2397
+ function readRestorePending(stageDir: string): RestorePendingRead {
2398
+ const path = join(stageDir, RESTORE_PENDING_FILE);
2399
+ if (!existsSync(path)) return { status: "missing" };
2400
+ try {
2401
+ const state = parseRestorePendingState(JSON.parse(readFileSync(path, "utf8")) as unknown);
2402
+ if (!state) return { status: "invalid" };
2403
+ return { status: "valid", state };
2404
+ } catch {
2405
+ return { status: "invalid" };
2406
+ }
2407
+ }
2408
+
2409
+ /**
2410
+ * Atomically replace restore-pending.json: private temp in the stage, fsync, then rename.
2411
+ * An interrupted update leaves the previous valid marker intact.
2412
+ */
2413
+ function writeRestorePending(
2414
+ stageDir: string,
2415
+ state: RestorePendingState,
2416
+ options?: { failBeforeRename?: boolean; failWrite?: boolean },
2417
+ ): void {
2418
+ if (options?.failWrite) throw new Error("test_fail_pending_write");
2419
+ const dest = join(stageDir, RESTORE_PENDING_FILE);
2420
+ const tmp = join(stageDir, `${RESTORE_PENDING_FILE}.${process.pid}.${++_restorePendingSeq}.tmp`);
2421
+ const payload = JSON.stringify(state);
2422
+ const fd = openSync(tmp, "w", 0o600);
2423
+ try {
2424
+ writeSync(fd, payload, null, "utf8");
2425
+ fsyncSync(fd);
2426
+ } catch (error) {
2427
+ try { closeSync(fd); } catch { /* */ }
2428
+ try { unlinkSync(tmp); } catch { /* */ }
2429
+ throw error;
2430
+ }
2431
+ closeSync(fd);
2432
+ chmodPrivatePath(tmp, 0o600);
2433
+ if (options?.failBeforeRename) {
2434
+ try { unlinkSync(tmp); } catch { /* */ }
2435
+ throw new Error("test_fail_pending_rename");
2436
+ }
2437
+ try {
2438
+ renameSync(tmp, dest);
2439
+ } catch (error) {
2440
+ try { unlinkSync(tmp); } catch { /* */ }
2441
+ throw error;
2442
+ }
2443
+ }
2444
+
2445
+ function restoreThreadsFromManifest(
2446
+ stateDbPath: string | null,
2447
+ entries: CleanupManifestEntry[],
2448
+ backup: SatelliteBackup | null,
2449
+ busyTimeoutMs: number,
2450
+ codexHome: string,
2451
+ ): { ok: true } | ReconcileErr {
2452
+ const manifestThreadIds = entries
2453
+ .map(e => e.threadId)
2454
+ .filter((id): id is string => typeof id === "string");
2455
+ const backupThreadIds = backup?.threadIds ?? [];
2456
+ const needsThreads = manifestThreadIds.length > 0
2457
+ || backupThreadIds.length > 0
2458
+ || Boolean(backup?.threads?.length);
2459
+
2460
+ if (needsThreads && (!stateDbPath || !existsSync(stateDbPath))) {
2461
+ return { ok: false, error: "db_reconcile_failed" };
2462
+ }
2463
+ if (!stateDbPath || !existsSync(stateDbPath)) {
2464
+ return { ok: true };
2465
+ }
2466
+
2467
+ const result = withWritableDb(stateDbPath, busyTimeoutMs, db => {
2468
+ if (!tableExists(db, "threads")) throw new Error("missing_threads_table");
2469
+
2470
+ const requiredCols = requiredThreadColumnNames(db);
2471
+ const allowedCols = tableColumnNames(db, "threads");
2472
+ const snapshotThreads = backup?.threads && isSqlRowArray(backup.threads)
2473
+ ? backup.threads
2474
+ : [];
2475
+ const completeSnapshots = snapshotThreads.filter(row =>
2476
+ threadSnapshotCoversRequiredColumns(row, requiredCols),
2477
+ );
2478
+ const coveredIds = new Set(
2479
+ completeSnapshots
2480
+ .map(r => r.id)
2481
+ .filter((id): id is string => typeof id === "string"),
2482
+ );
2483
+
2484
+ // Legacy Phase-2 quarantine (no / incomplete satellite thread snapshots): reconstruct
2485
+ // every required column from the restored rollout via the history-provider session path.
2486
+ const toReconstruct = entries.filter(
2487
+ e => typeof e.threadId === "string"
2488
+ && typeof e.rolloutPath === "string"
2489
+ && !coveredIds.has(e.threadId!),
2490
+ );
2491
+ const reconstructed: SqlRow[] = [];
2492
+ for (const entry of toReconstruct) {
2493
+ let abs: string | undefined;
2494
+ try {
2495
+ abs = absFromRel(codexHome, entry.rolloutPath!);
2496
+ } catch {
2497
+ abs = undefined;
2498
+ }
2499
+ // Legacy compressed-only quarantine: manifest rolloutPath is often the logical
2500
+ // `.jsonl` name while the only restored physical file is `.jsonl.zst`.
2501
+ if (!abs || !existsSync(abs)) {
2502
+ for (const rel of entry.physicalRelPaths) {
2503
+ try {
2504
+ const candidate = absFromRel(codexHome, rel);
2505
+ if (existsSync(candidate)) {
2506
+ abs = candidate;
2507
+ break;
2508
+ }
2509
+ } catch {
2510
+ /* try next physical path */
2511
+ }
2512
+ }
2513
+ }
2514
+ if (!abs) throw new Error("missing_rollout_for_thread");
2515
+ // Prefer a plain .jsonl sibling when present; otherwise readThreadFieldsFromRollout
2516
+ // decompresses a lone .jsonl.zst in memory (bounded) for legacy quarantine restores.
2517
+ if (abs.endsWith(ZST_SUFFIX)) {
2518
+ const plain = abs.slice(0, -".zst".length);
2519
+ if (existsSync(plain)) abs = plain;
2520
+ }
2521
+ const row = reconstructThreadRowFromRollout(entry, abs, allowedCols, requiredCols);
2522
+ if (!row) throw new Error("thread_reconstruct_failed");
2523
+ reconstructed.push(row);
2524
+ }
2525
+
2526
+ if (completeSnapshots.length > 0) {
2527
+ insertRowsConflictIgnore(db, "threads", completeSnapshots);
2528
+ }
2529
+ if (reconstructed.length > 0) {
2530
+ insertRowsConflictIgnore(db, "threads", reconstructed);
2531
+ }
2532
+
2533
+ if (backup?.dynamicTools && isSqlRowArray(backup.dynamicTools) && tableExists(db, "thread_dynamic_tools")) {
2534
+ insertRowsConflictIgnore(db, "thread_dynamic_tools", backup.dynamicTools);
2535
+ }
2536
+ if (backup?.spawnEdges && isSqlRowArray(backup.spawnEdges) && tableExists(db, "thread_spawn_edges")) {
2537
+ insertRowsConflictIgnore(db, "thread_spawn_edges", backup.spawnEdges);
2538
+ }
2539
+ });
2540
+ if (!result.ok) return result;
2541
+ return { ok: true };
2542
+ }
2543
+
2544
+ function isSafeArchivedPhysicalRel(rel: string): boolean {
2545
+ const normalized = toForwardSlash(rel);
2546
+ if (!normalized.startsWith(`${ARCHIVED_SESSIONS_DIR}/`)) return false;
2547
+ if (normalized.includes("..")) return false;
2548
+ const rest = normalized.slice(ARCHIVED_SESSIONS_DIR.length + 1);
2549
+ if (!rest || rest.includes("/")) return false;
2550
+ return isRolloutFileName(rest);
2551
+ }
2552
+
2553
+ /** Test-only failure injection for restore atomicity regressions. */
2554
+ export interface RestoreTestHooks {
2555
+ /** After state threads/dependents commit, before satellite commits. */
2556
+ failAfterStateCommit?: boolean;
2557
+ /** After the first satellite DB commit (logs → memories → goals). */
2558
+ failAfterFirstSatelliteCommit?: boolean;
2559
+ /** When the leftover staged-rollout completeness gate runs. */
2560
+ failAtLeftoverStageGate?: boolean;
2561
+ /** Fail the initial restore-pending.json write (before any file moves). */
2562
+ failInitialPendingWrite?: boolean;
2563
+ /** Fail a later pending update after the temp is written but before rename. */
2564
+ failPendingWriteBeforeRename?: boolean;
2565
+ /** Crash immediately after file moves (marker already durable). */
2566
+ failAfterFileMoves?: boolean;
2567
+ /**
2568
+ * After this many successful rollout moves in the current attempt, throw.
2569
+ * Exercises mid-loop failure with some dests placed and others still staged.
2570
+ */
2571
+ failAfterMoveCount?: number;
2572
+ /** Fail renaming the completed stage to a non-listable tombstone dir. */
2573
+ failStageTombstoneRename?: boolean;
2574
+ /** After tombstone rename, skip best-effort tombstone delete (orphan is OK). */
2575
+ failTombstoneDelete?: boolean;
2576
+ /**
2577
+ * Test-only: spin-wait this many ms after rollout file moves, before DB
2578
+ * reconcile, so cleanup can race an in-flight restore.
2579
+ */
2580
+ holdAfterFileMovesMs?: number;
2581
+ }
2582
+
2583
+ /**
2584
+ * Resume must not clear owed satellite work when the matching backup section is
2585
+ * absent — fail closed per section instead.
2586
+ */
2587
+ function failClosedSatelliteResume(
2588
+ priorPending: RestorePendingState,
2589
+ satelliteBackup: SatelliteBackup | null,
2590
+ ): RestoreErrorCode | null {
2591
+ const owed = priorPending.pending;
2592
+ if (!owed.logs && !owed.memories && !owed.goals) return null;
2593
+ if (!satelliteBackup) return "db_reconcile_failed";
2594
+ if (owed.logs && !satelliteBackup.logs) return "db_reconcile_failed";
2595
+ if (owed.memories && !satelliteBackup.memories) return "db_reconcile_failed";
2596
+ if (owed.goals && !satelliteBackup.goals) return "db_reconcile_failed";
2597
+ return null;
2598
+ }
2599
+
2600
+ /**
2601
+ * Successful restore finalization: rename the stage to a tombstone name that
2602
+ * `listTrashEntries` ignores, then delete the tombstone best-effort. A failed
2603
+ * rename leaves the original stage (and all evidence) intact for retry.
2604
+ */
2605
+ function finalizeRestoredStage(
2606
+ stageDir: string,
2607
+ codexHome: string,
2608
+ hooks?: Pick<RestoreTestHooks, "failStageTombstoneRename" | "failTombstoneDelete">,
2609
+ ): boolean {
2610
+ const trashRoot = join(codexHome, TRASH_DIR);
2611
+ const epoch = basename(stageDir);
2612
+ const tombstoneName = `.tombstone-${epoch}-${randomUUID()}`;
2613
+ const tombstonePath = join(trashRoot, tombstoneName);
2614
+ try {
2615
+ if (hooks?.failStageTombstoneRename) throw new Error("test_fail_stage_tombstone_rename");
2616
+ renameSync(stageDir, tombstonePath);
2617
+ } catch {
2618
+ return false;
2619
+ }
2620
+ if (!hooks?.failTombstoneDelete) {
2621
+ try { rmSync(tombstonePath, { recursive: true, force: true }); } catch { /* best-effort */ }
2622
+ }
2623
+ return true;
2624
+ }
2625
+
2626
+ /**
2627
+ * Restore one quarantine entry: move JSONL back, re-insert threads (+ satellites
2628
+ * when satellite-backup.json is present), then remove the trash directory.
2629
+ *
2630
+ * Late failures after files have moved never compensate metadata or restage.
2631
+ * Instead they persist `restore-pending.json` (accepted dest paths + which
2632
+ * state/logs/memories/goals sections still need work) atomically *before* any
2633
+ * rollout move, then update it after each section so a retry can accept existing
2634
+ * destinations and resume only missing metadata.
2635
+ */
2636
+ export function restoreTrashEntry(
2637
+ trashId: string,
2638
+ options?: {
2639
+ codexHome?: string;
2640
+ busyTimeoutMs?: number;
2641
+ _test?: RestoreTestHooks;
2642
+ },
2643
+ ): RestoreResult {
2644
+ const codexHome = options?.codexHome ?? resolveCodexHomeDir();
2645
+ const busyTimeoutMs = options?.busyTimeoutMs ?? 100;
2646
+ const hooks = options?._test;
2647
+
2648
+ const resolved = resolveTrashStageDir(trashId, codexHome);
2649
+ if (!resolved.ok) {
2650
+ return { ok: false, count: 0, bytes: 0, restoredPaths: [], error: resolved.error };
2651
+ }
2652
+ const { stageDir, id } = resolved;
2653
+
2654
+ let manifestRaw: string;
2655
+ try {
2656
+ manifestRaw = readFileSync(join(stageDir, "manifest.json"), "utf8");
2657
+ } catch {
2658
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
2659
+ }
2660
+ const manifest = parseTrashManifest(manifestRaw);
2661
+ if (!manifest?.entries?.length) {
2662
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
2663
+ }
2664
+
2665
+ const pendingRead = readRestorePending(stageDir);
2666
+ if (pendingRead.status === "invalid") {
2667
+ // Malformed marker means an incomplete restore may already have moved files;
2668
+ // never treat it as a fresh restore.
2669
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "fs_failed" };
2670
+ }
2671
+ const priorPending = pendingRead.status === "valid" ? pendingRead.state : null;
2672
+ const acceptedDest = new Set(priorPending?.acceptedDestRels ?? []);
2673
+
2674
+ // Partial permanent purges may leave only a subset of physical files on disk —
2675
+ // trim to survivors rather than failing the whole entry for a purged twin.
2676
+ // Resume also treats already-restored accepted destinations as survivors.
2677
+ const entries: CleanupManifestEntry[] = [];
2678
+ for (const entry of manifest.entries) {
2679
+ if (!entry.physicalRelPaths.every(isSafeArchivedPhysicalRel)) {
2680
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
2681
+ }
2682
+ const surviving = entry.physicalRelPaths.filter(rel => {
2683
+ if (existsSync(join(stageDir, basename(rel)))) return true;
2684
+ if (!acceptedDest.has(rel)) return false;
2685
+ try {
2686
+ return existsSync(absFromRel(codexHome, rel));
2687
+ } catch {
2688
+ return false;
2689
+ }
2690
+ });
2691
+ if (surviving.length === 0) {
2692
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "fs_failed" };
2693
+ }
2694
+ entries.push({ ...entry, physicalRelPaths: surviving });
2695
+ }
2696
+
2697
+ const paths = discoverRuntimeDbPaths(codexHome);
2698
+ const backupRead = readSatelliteBackupFile(stageDir);
2699
+ if (backupRead.status === "invalid") {
2700
+ return {
2701
+ ok: false,
2702
+ trashDir: id,
2703
+ count: 0,
2704
+ bytes: 0,
2705
+ restoredPaths: [],
2706
+ error: "db_reconcile_failed",
2707
+ };
2708
+ }
2709
+
2710
+ let satelliteBackup: SatelliteBackup | null = null;
2711
+ if (backupRead.status === "ok") {
2712
+ const remapped = remapSatelliteBackupPaths(backupRead.backup, paths);
2713
+ if (!remapped.ok) {
2714
+ return {
2715
+ ok: false,
2716
+ trashDir: id,
2717
+ count: 0,
2718
+ bytes: 0,
2719
+ restoredPaths: [],
2720
+ error: "db_reconcile_failed",
2721
+ };
2722
+ }
2723
+ satelliteBackup = remapped.backup;
2724
+ }
2725
+
2726
+ if (priorPending) {
2727
+ const resumeErr = failClosedSatelliteResume(priorPending, satelliteBackup);
2728
+ if (resumeErr) {
2729
+ return {
2730
+ ok: false,
2731
+ trashDir: id,
2732
+ count: 0,
2733
+ bytes: 0,
2734
+ restoredPaths: [],
2735
+ error: resumeErr,
2736
+ };
2737
+ }
2738
+ }
2739
+
2740
+ const pendingSections: RestorePendingSections = {
2741
+ state: priorPending ? priorPending.pending.state : true,
2742
+ logs: priorPending ? priorPending.pending.logs : Boolean(satelliteBackup?.logs),
2743
+ memories: priorPending ? priorPending.pending.memories : Boolean(satelliteBackup?.memories),
2744
+ goals: priorPending ? priorPending.pending.goals : Boolean(satelliteBackup?.goals),
2745
+ };
2746
+
2747
+ const needAnySatellite = pendingSections.logs || pendingSections.memories || pendingSections.goals;
2748
+ if (pendingSections.state) {
2749
+ const needsThreads = entries.some(e => typeof e.threadId === "string")
2750
+ || Boolean(satelliteBackup?.threadIds?.length)
2751
+ || Boolean(satelliteBackup?.threads?.length);
2752
+ if (needsThreads && (!paths.state || !existsSync(paths.state))) {
2753
+ return {
2754
+ ok: false,
2755
+ trashDir: id,
2756
+ count: 0,
2757
+ bytes: 0,
2758
+ restoredPaths: [],
2759
+ error: "db_reconcile_failed",
2760
+ };
2761
+ }
2762
+ const probe = probeStateDbWritable(codexHome, busyTimeoutMs);
2763
+ if (!probe.ok) {
2764
+ return {
2765
+ ok: false,
2766
+ trashDir: id,
2767
+ count: 0,
2768
+ bytes: 0,
2769
+ restoredPaths: [],
2770
+ error: probe.error === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
2771
+ };
2772
+ }
2773
+ }
2774
+
2775
+ // Acquire only the satellite locks still needed so a busy DB for an already-
2776
+ // finished section cannot block resume. Locks happen before moves on a fresh
2777
+ // attempt so failure stays retryable (nothing has left the stage yet).
2778
+ let satelliteLocks: SatelliteWriteLocks | undefined;
2779
+ if (needAnySatellite) {
2780
+ try {
2781
+ satelliteLocks = beginSatelliteWriteLocks(paths, busyTimeoutMs, {
2782
+ logs: pendingSections.logs,
2783
+ memories: pendingSections.memories,
2784
+ goals: pendingSections.goals,
2785
+ });
2786
+ } catch (error) {
2787
+ return {
2788
+ ok: false,
2789
+ trashDir: id,
2790
+ count: 0,
2791
+ bytes: 0,
2792
+ restoredPaths: [],
2793
+ error: mapDbError(error) === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
2794
+ };
2795
+ }
2796
+ }
2797
+
2798
+ const failBeforeMoves = (error: RestoreErrorCode): RestoreResult => {
2799
+ if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
2800
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error };
2801
+ };
2802
+
2803
+ // Plan renames: staged basename → original archived_sessions path.
2804
+ // Resume accepts destinations already restored by this incomplete attempt.
2805
+ const alreadyMoved: StagedFile[] = [];
2806
+ const toMove: StagedFile[] = [];
2807
+ for (const entry of entries) {
2808
+ for (const rel of entry.physicalRelPaths) {
2809
+ const base = basename(rel);
2810
+ const from = join(stageDir, base);
2811
+ let to: string;
2812
+ try {
2813
+ to = absFromRel(codexHome, rel);
2814
+ } catch {
2815
+ return failBeforeMoves("invalid_trash");
2816
+ }
2817
+ const fromExists = existsSync(from);
2818
+ const toExists = existsSync(to);
2819
+ if (toExists && acceptedDest.has(rel) && !fromExists) {
2820
+ alreadyMoved.push({ from, to, relPath: rel });
2821
+ continue;
2822
+ }
2823
+ if (toExists) {
2824
+ return failBeforeMoves("dest_exists");
2825
+ }
2826
+ if (!fromExists) {
2827
+ return failBeforeMoves("fs_failed");
2828
+ }
2829
+ toMove.push({ from, to, relPath: rel });
2830
+ }
2831
+ }
2832
+
2833
+ const planned = [...alreadyMoved, ...toMove];
2834
+ const restoredPaths = [...new Set(entries.map(e => e.relPath))];
2835
+ const bytes = entries.reduce((sum, e) => sum + (e.bytes || 0), 0);
2836
+ const partialCounts = { count: restoredPaths.length, bytes, restoredPaths };
2837
+
2838
+ let pendingWriteCount = 0;
2839
+ const persistPending = (): void => {
2840
+ pendingWriteCount += 1;
2841
+ const isInitial = pendingWriteCount === 1;
2842
+ writeRestorePending(
2843
+ stageDir,
2844
+ {
2845
+ version: 1,
2846
+ filesRestored: true,
2847
+ acceptedDestRels: planned.map(m => m.relPath),
2848
+ pending: { ...pendingSections },
2849
+ },
2850
+ {
2851
+ failWrite: Boolean(isInitial && hooks?.failInitialPendingWrite),
2852
+ failBeforeRename: Boolean(!isInitial && hooks?.failPendingWriteBeforeRename),
2853
+ },
2854
+ );
2855
+ };
2856
+
2857
+ // Durable resume marker before any rollout leaves the stage. Crash after a
2858
+ // later move can still accept destinations from this marker.
2859
+ try {
2860
+ persistPending();
2861
+ } catch {
2862
+ return failBeforeMoves("fs_failed");
2863
+ }
2864
+
2865
+ const newlyMoved: StagedFile[] = [];
2866
+ try {
2867
+ mkdirSync(join(codexHome, ARCHIVED_SESSIONS_DIR), { recursive: true });
2868
+ for (const item of toMove) {
2869
+ // Atomic no-replace (.trash ↔ archived_sessions). Mid-loop failure keeps
2870
+ // already-placed dests and the durable planned acceptedDestRels marker.
2871
+ renameNoReplace(item.from, item.to);
2872
+ newlyMoved.push(item);
2873
+ if (
2874
+ hooks?.failAfterMoveCount !== undefined
2875
+ && newlyMoved.length >= hooks.failAfterMoveCount
2876
+ ) {
2877
+ throw new Error("test_fail_after_move_count");
2878
+ }
2879
+ }
2880
+ } catch (error) {
2881
+ // Marker was written before any move. Never reverse successful renames or
2882
+ // drop/narrow acceptedDestRels — resume must accept placed dests and finish
2883
+ // the remaining staged files.
2884
+ if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
2885
+ const placed = [...alreadyMoved, ...newlyMoved];
2886
+ const placedPhysical = new Set(placed.map(m => m.relPath));
2887
+ const partialEntries = entries.filter(e =>
2888
+ e.physicalRelPaths.every(rel => placedPhysical.has(rel)),
2889
+ );
2890
+ const midMoveRestored = [...new Set(partialEntries.map(e => e.relPath))];
2891
+ return {
2892
+ ok: false,
2893
+ trashDir: id,
2894
+ count: midMoveRestored.length,
2895
+ bytes: partialEntries.reduce((sum, e) => sum + (e.bytes || 0), 0),
2896
+ restoredPaths: midMoveRestored,
2897
+ error: isExistError(error) ? "dest_exists" : "fs_failed",
2898
+ };
2899
+ }
2900
+
2901
+ const moved = [...alreadyMoved, ...newlyMoved];
2902
+
2903
+ /**
2904
+ * Never compensate DBs or restage files after moves. Keep restored files,
2905
+ * persist which sections remain, and return accurate partial counts.
2906
+ */
2907
+ const abortAfterMoves = (error: RestoreErrorCode): RestoreResult => {
2908
+ if (satelliteLocks) {
2909
+ rollbackAllSatelliteLocks(satelliteLocks);
2910
+ satelliteLocks = undefined;
2911
+ }
2912
+ try {
2913
+ persistPending();
2914
+ } catch {
2915
+ /* best-effort — files already restored; prior atomic marker remains */
2916
+ }
2917
+ return { ok: false, trashDir: id, ...partialCounts, error };
2918
+ };
2919
+
2920
+ if (hooks?.holdAfterFileMovesMs !== undefined) {
2921
+ const holdMs = Math.max(0, Math.floor(hooks.holdAfterFileMovesMs));
2922
+ if (holdMs > 0) {
2923
+ const deadline = Date.now() + holdMs;
2924
+ while (Date.now() < deadline) { /* test-only spin wait */ }
2925
+ }
2926
+ }
2927
+
2928
+ if (hooks?.failAfterFileMoves) {
2929
+ return abortAfterMoves("fs_failed");
2930
+ }
2931
+
2932
+ if (pendingSections.state) {
2933
+ const threadsRestored = restoreThreadsFromManifest(
2934
+ paths.state,
2935
+ entries,
2936
+ satelliteBackup,
2937
+ busyTimeoutMs,
2938
+ codexHome,
2939
+ );
2940
+ if (!threadsRestored.ok) {
2941
+ return abortAfterMoves(
2942
+ threadsRestored.error === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
2943
+ );
2944
+ }
2945
+ pendingSections.state = false;
2946
+ try {
2947
+ persistPending();
2948
+ } catch {
2949
+ return abortAfterMoves("fs_failed");
2950
+ }
2951
+ }
2952
+
2953
+ if (hooks?.failAfterStateCommit) {
2954
+ return abortAfterMoves("db_reconcile_failed");
2955
+ }
2956
+
2957
+ if (satelliteLocks && satelliteBackup) {
2958
+ const locks = satelliteLocks;
2959
+ try {
2960
+ // Commit one satellite DB at a time; uncommitted txs roll back via
2961
+ // rollbackAllSatelliteLocks. Completed sections are cleared in pending.
2962
+ if (pendingSections.logs && satelliteBackup.logs) {
2963
+ if (!locks.logs) throw new Error("missing_logs_lock");
2964
+ if (!tableExists(locks.logs.db, "logs")) throw new Error("missing_logs_table");
2965
+ insertRowsConflictIgnore(locks.logs.db, "logs", satelliteBackup.logs.rows);
2966
+ commitSatelliteLock(locks.logs);
2967
+ locks.logs = undefined;
2968
+ pendingSections.logs = false;
2969
+ persistPending();
2970
+ if (hooks?.failAfterFirstSatelliteCommit) {
2971
+ throw new Error("test_fail_after_first_satellite");
2972
+ }
2973
+ }
2974
+ if (pendingSections.memories && satelliteBackup.memories) {
2975
+ if (!locks.memories) throw new Error("missing_memories_lock");
2976
+ const mem = satelliteBackup.memories;
2977
+ if (!tableExists(locks.memories.db, "stage1_outputs")) {
2978
+ throw new Error("missing_stage1_outputs_table");
2979
+ }
2980
+ insertRowsConflictIgnore(locks.memories.db, "stage1_outputs", mem.stage1);
2981
+ if (tableExists(locks.memories.db, "jobs")) {
2982
+ insertRowsConflictIgnore(locks.memories.db, "jobs", mem.stage1Jobs);
2983
+ if (mem.consolidateTouched) {
2984
+ restoreConsolidateGlobalJob(
2985
+ locks.memories.db,
2986
+ mem.consolidateJob,
2987
+ mem.consolidatePostImage,
2988
+ );
2989
+ }
2990
+ }
2991
+ commitSatelliteLock(locks.memories);
2992
+ locks.memories = undefined;
2993
+ pendingSections.memories = false;
2994
+ persistPending();
2995
+ if (hooks?.failAfterFirstSatelliteCommit && !satelliteBackup.logs) {
2996
+ throw new Error("test_fail_after_first_satellite");
2997
+ }
2998
+ }
2999
+ if (pendingSections.goals && satelliteBackup.goals) {
3000
+ if (!locks.goals) throw new Error("missing_goals_lock");
3001
+ const g = satelliteBackup.goals;
3002
+ if (!tableExists(locks.goals.db, "thread_goals")) {
3003
+ throw new Error("missing_thread_goals_table");
3004
+ }
3005
+ insertRowsConflictIgnore(locks.goals.db, "thread_goals", g.goals);
3006
+ if (tableExists(locks.goals.db, "thread_goal_continuation_deferrals")) {
3007
+ insertRowsConflictIgnore(
3008
+ locks.goals.db,
3009
+ "thread_goal_continuation_deferrals",
3010
+ g.deferrals,
3011
+ );
3012
+ }
3013
+ commitSatelliteLock(locks.goals);
3014
+ locks.goals = undefined;
3015
+ pendingSections.goals = false;
3016
+ persistPending();
3017
+ if (
3018
+ hooks?.failAfterFirstSatelliteCommit
3019
+ && !satelliteBackup.logs
3020
+ && !satelliteBackup.memories
3021
+ ) {
3022
+ throw new Error("test_fail_after_first_satellite");
3023
+ }
3024
+ }
3025
+ // Close any locks acquired for DBs that had no pending work / backup rows.
3026
+ rollbackAllSatelliteLocks(locks);
3027
+ satelliteLocks = undefined;
3028
+ } catch (error) {
3029
+ return abortAfterMoves(
3030
+ mapDbError(error) === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
3031
+ );
3032
+ }
3033
+ }
3034
+
3035
+ if (
3036
+ pendingSections.state
3037
+ || pendingSections.logs
3038
+ || pendingSections.memories
3039
+ || pendingSections.goals
3040
+ ) {
3041
+ return abortAfterMoves("db_reconcile_failed");
3042
+ }
3043
+
3044
+ // Completeness gate: every planned file must sit at its restored path, and the stage
3045
+ // must hold no leftover rollout files, before we destroy the quarantine evidence.
3046
+ for (const item of moved) {
3047
+ if (!existsSync(item.to) || existsSync(item.from)) {
3048
+ return abortAfterMoves("fs_failed");
3049
+ }
3050
+ }
3051
+ try {
3052
+ if (hooks?.failAtLeftoverStageGate) {
3053
+ return abortAfterMoves("fs_failed");
3054
+ }
3055
+ for (const name of readdirSync(stageDir)) {
3056
+ if (
3057
+ name === "manifest.json"
3058
+ || name === SATELLITE_BACKUP_FILE
3059
+ || name === RESTORE_PENDING_FILE
3060
+ ) {
3061
+ continue;
3062
+ }
3063
+ if (!isRolloutFileName(name)) continue;
3064
+ return abortAfterMoves("fs_failed");
3065
+ }
3066
+ } catch {
3067
+ return abortAfterMoves("fs_failed");
3068
+ }
3069
+
3070
+ if (!finalizeRestoredStage(stageDir, codexHome, hooks)) {
3071
+ return {
3072
+ ok: false,
3073
+ trashDir: id,
3074
+ ...partialCounts,
3075
+ error: "fs_failed",
3076
+ };
3077
+ }
3078
+ removeEmptyTrashRoot(codexHome);
3079
+
3080
+ return {
3081
+ ok: true,
3082
+ trashDir: id,
3083
+ ...partialCounts,
3084
+ };
3085
+ }