@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922

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 (374) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +8 -1
  13. package/src/adapters/anthropic-image-codec.ts +16 -2
  14. package/src/adapters/anthropic-image-normalize.ts +49 -2
  15. package/src/adapters/anthropic.ts +20 -1
  16. package/src/adapters/base.ts +23 -0
  17. package/src/adapters/coding-agent/protocol.ts +36 -6
  18. package/src/adapters/coding-agent/turn.ts +32 -4
  19. package/src/adapters/command-code.ts +52 -4
  20. package/src/adapters/cursor/catalog.ts +51 -7
  21. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  22. package/src/adapters/cursor/discovery.ts +11 -8
  23. package/src/adapters/cursor/live-transport.ts +26 -9
  24. package/src/adapters/cursor/protobuf-request.ts +6 -3
  25. package/src/adapters/cursor/request-builder.ts +20 -4
  26. package/src/adapters/cursor/transport.ts +19 -0
  27. package/src/adapters/cursor.ts +14 -3
  28. package/src/adapters/declaration-carrier.ts +45 -0
  29. package/src/adapters/devin/cloud-direct/chat.ts +3 -1
  30. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
  31. package/src/adapters/devin.ts +125 -36
  32. package/src/adapters/google-antigravity-replay.ts +1 -1
  33. package/src/adapters/google-antigravity-wire.ts +34 -7
  34. package/src/adapters/google-errors.ts +7 -1
  35. package/src/adapters/google-http.ts +49 -10
  36. package/src/adapters/google-tool-schema.ts +595 -31
  37. package/src/adapters/google-wire-compiler.ts +93 -10
  38. package/src/adapters/google-wire-shape.ts +461 -0
  39. package/src/adapters/google.ts +66 -11
  40. package/src/adapters/image.ts +4 -1
  41. package/src/adapters/input-media-guard.ts +21 -9
  42. package/src/adapters/kiro/usage.ts +3 -2
  43. package/src/adapters/kiro-tool-fallback.ts +1 -1
  44. package/src/adapters/ollama-native.ts +6 -0
  45. package/src/adapters/openai-chat/developer-role.ts +61 -0
  46. package/src/adapters/openai-chat/messages.ts +46 -27
  47. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  48. package/src/adapters/openai-chat/passthrough.ts +33 -9
  49. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  50. package/src/adapters/openai-chat-images.ts +3 -1
  51. package/src/adapters/openai-chat.ts +23 -58
  52. package/src/adapters/openai-responses/image-gen.ts +8 -6
  53. package/src/adapters/openai-responses/passthrough.ts +17 -3
  54. package/src/adapters/openai-responses/reasoning.ts +7 -0
  55. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  56. package/src/adapters/registry.ts +3 -2
  57. package/src/adapters/run-turn-queue.ts +178 -29
  58. package/src/adapters/xai-web-search.ts +16 -1
  59. package/src/bridge/errors.ts +8 -2
  60. package/src/bridge/response-json.ts +9 -1
  61. package/src/bridge/sse.ts +13 -151
  62. package/src/chat/inbound.ts +141 -5
  63. package/src/claude/desktop-3p.ts +7 -1
  64. package/src/claude/desktop-first-party.ts +183 -0
  65. package/src/claude/desktop-gateway-state.ts +41 -0
  66. package/src/claude/inbound-content-options.ts +6 -0
  67. package/src/claude/inbound.ts +32 -6
  68. package/src/claude/intercept/connect-proxy.ts +179 -0
  69. package/src/claude/intercept/listener.ts +122 -0
  70. package/src/claude/intercept/local-ca.ts +298 -0
  71. package/src/claude/intercept/runtime.ts +98 -0
  72. package/src/claude/intercept/settings.ts +189 -0
  73. package/src/cli/access.ts +87 -0
  74. package/src/cli/account-auth.ts +19 -0
  75. package/src/cli/account-extended.ts +4 -4
  76. package/src/cli/capabilities.ts +31 -0
  77. package/src/cli/claude-desktop.ts +206 -16
  78. package/src/cli/codex-shim-autorestore.ts +3 -0
  79. package/src/cli/companion.ts +56 -0
  80. package/src/cli/dispatch.ts +46 -7
  81. package/src/cli/doctor.ts +28 -9
  82. package/src/cli/ensure-desired-integrations.ts +43 -5
  83. package/src/cli/help.ts +7 -9
  84. package/src/cli/hub.ts +3 -2
  85. package/src/cli/index.ts +203 -59
  86. package/src/cli/init.ts +8 -0
  87. package/src/cli/integrations.ts +7 -1
  88. package/src/cli/opencode.ts +2 -2
  89. package/src/cli/provider.ts +13 -1
  90. package/src/cli/registry.ts +41 -2
  91. package/src/cli/resolve.ts +230 -0
  92. package/src/cli/root.ts +24 -1
  93. package/src/cli/start-ownership-publication.ts +56 -0
  94. package/src/cli/status-probes.ts +2 -18
  95. package/src/cli/status.ts +62 -0
  96. package/src/cli/stop-report.ts +143 -0
  97. package/src/cli/uninstall-plan.ts +9 -0
  98. package/src/client/machine-api.ts +2 -2
  99. package/src/client/machine-listener.ts +4 -7
  100. package/src/client/runtime.ts +26 -2
  101. package/src/clients/aside-profiles.ts +4 -0
  102. package/src/clients/config-export/zcode-store.ts +157 -0
  103. package/src/clients/config-export.ts +36 -0
  104. package/src/codex/account-store.ts +65 -0
  105. package/src/codex/app-server-processes.ts +72 -40
  106. package/src/codex/auth-api/account-list.ts +19 -11
  107. package/src/codex/auth-api/login-flow.ts +6 -1
  108. package/src/codex/auth-api/pool-quota-probe.ts +30 -7
  109. package/src/codex/autostart-health.ts +28 -0
  110. package/src/codex/catalog/build-entries.ts +2 -2
  111. package/src/codex/catalog/effort.ts +3 -3
  112. package/src/codex/catalog/gather-capture.ts +21 -2
  113. package/src/codex/catalog/model-hints.ts +29 -28
  114. package/src/codex/catalog/parsing.ts +7 -0
  115. package/src/codex/catalog/provider-models.ts +43 -17
  116. package/src/codex/catalog/retained-sync.ts +24 -28
  117. package/src/codex/catalog/routed-gather.ts +19 -0
  118. package/src/codex/context-compat.ts +5 -2
  119. package/src/codex/convergence.ts +2 -2
  120. package/src/codex/desired-state.ts +4 -1
  121. package/src/codex/history-job.ts +6 -6
  122. package/src/codex/history-provider.ts +31 -166
  123. package/src/codex/history-rollout-read.ts +174 -0
  124. package/src/codex/inject/config-toml.ts +41 -6
  125. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  126. package/src/codex/inject.ts +18 -15
  127. package/src/codex/injected-marker.ts +18 -0
  128. package/src/codex/internal/catalog-writer.ts +33 -1
  129. package/src/codex/main-account.ts +6 -0
  130. package/src/codex/model-cache.ts +99 -6
  131. package/src/codex/model-entitlement-admission.ts +59 -0
  132. package/src/codex/model-entitlements.ts +116 -54
  133. package/src/codex/native-main-admission.ts +83 -0
  134. package/src/codex/observed-model-denials.ts +101 -8
  135. package/src/codex/prompt-text-probe.ts +9 -6
  136. package/src/codex/routing/health-store.ts +39 -0
  137. package/src/codex/routing/selection.ts +37 -1
  138. package/src/codex/routing.ts +12 -42
  139. package/src/codex/shim-templates.ts +29 -3
  140. package/src/codex/shim.ts +1 -1
  141. package/src/codex/subagent-model-fallback.ts +22 -4
  142. package/src/combos/failover.ts +3 -0
  143. package/src/companion/settings.ts +132 -0
  144. package/src/config/admitted-identity.ts +222 -0
  145. package/src/config/atomic-write.ts +117 -5
  146. package/src/config/diagnostics.ts +22 -1
  147. package/src/config/feature-flags.ts +5 -0
  148. package/src/config/load-degrade.ts +52 -7
  149. package/src/config/process-state.ts +1 -1
  150. package/src/config/proxy-env.ts +8 -2
  151. package/src/config/schema/compaction-triggers.ts +11 -0
  152. package/src/config/schema/config-schema.ts +31 -1
  153. package/src/config/schema/leaf-validators.ts +59 -0
  154. package/src/config.ts +3 -3
  155. package/src/generated/compatibility-version.json +604 -280
  156. package/src/grok/reset-coupons.ts +38 -19
  157. package/src/images/loop.ts +6 -1
  158. package/src/integrations/aside-profile-context.ts +37 -3
  159. package/src/integrations/aside-profile-journal.ts +68 -3
  160. package/src/integrations/aside-profiles.ts +128 -3
  161. package/src/integrations/config-io.ts +44 -10
  162. package/src/integrations/merge.ts +120 -13
  163. package/src/integrations/mutation-plan.ts +921 -0
  164. package/src/integrations/registry.ts +38 -0
  165. package/src/integrations/state.ts +78 -45
  166. package/src/integrations/target.ts +208 -0
  167. package/src/integrations/writer.ts +134 -110
  168. package/src/lab/conformance/fixture-provider.ts +5 -0
  169. package/src/lab/live/transport.ts +4 -0
  170. package/src/lab/live/types.ts +5 -0
  171. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  172. package/src/lib/admin-secrets.ts +9 -1
  173. package/src/lib/browser-launch-notice.ts +59 -0
  174. package/src/lib/bun-runtime.ts +6 -2
  175. package/src/lib/debug-log-buffer.ts +6 -1
  176. package/src/lib/debug.ts +63 -0
  177. package/src/lib/errors.ts +79 -0
  178. package/src/lib/http-response-semantics.ts +57 -0
  179. package/src/lib/lab-live-pinned-sender.ts +26 -12
  180. package/src/lib/open-url.ts +51 -7
  181. package/src/lib/package-tree-integrity.ts +2 -1
  182. package/src/lib/package-version.ts +8 -0
  183. package/src/lib/pinned-http.ts +142 -2
  184. package/src/lib/plain-data.ts +103 -0
  185. package/src/lib/process-control.ts +13 -5
  186. package/src/lib/provider-egress.ts +310 -0
  187. package/src/lib/provider-outbound.ts +109 -16
  188. package/src/lib/proxy-env.ts +82 -7
  189. package/src/lib/request-execution-budget.ts +72 -0
  190. package/src/lib/request-failure-attribution.ts +183 -0
  191. package/src/lib/request-failure-model.ts +236 -0
  192. package/src/lib/request-resend-gate.ts +138 -0
  193. package/src/lib/socks5-fetch.ts +136 -26
  194. package/src/lib/spend-ledger-owner.ts +364 -0
  195. package/src/lib/spend-reservation-ledger.ts +218 -27
  196. package/src/lib/standalone.ts +16 -0
  197. package/src/lib/upstream-retry.ts +167 -16
  198. package/src/lib/windows-system-proxy.ts +16 -11
  199. package/src/lib/winsw.ts +2 -2
  200. package/src/oauth/callback-server.ts +4 -3
  201. package/src/oauth/generic-account-failover.ts +1 -0
  202. package/src/oauth/health.ts +12 -1
  203. package/src/oauth/index.ts +27 -108
  204. package/src/oauth/login-cli.ts +80 -29
  205. package/src/oauth/login-flow-state.ts +127 -0
  206. package/src/providers/api-key-resolve.ts +133 -0
  207. package/src/providers/api-key-selection.ts +5 -1
  208. package/src/providers/derive.ts +34 -17
  209. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  210. package/src/providers/key-failover.ts +97 -19
  211. package/src/providers/key-store.ts +34 -110
  212. package/src/providers/model-rename-fields.ts +147 -0
  213. package/src/providers/model-rename-migration.ts +179 -38
  214. package/src/providers/model-rename-startup.ts +7 -5
  215. package/src/providers/openai-virtual-models.ts +42 -2
  216. package/src/providers/quota/antigravity.ts +22 -2
  217. package/src/providers/quota/vendor-probes-key.ts +38 -23
  218. package/src/providers/reasoning-metadata.ts +43 -18
  219. package/src/providers/registry/entries-core.ts +47 -20
  220. package/src/providers/registry/entries-extended.ts +63 -4
  221. package/src/providers/registry/model-ids.ts +168 -0
  222. package/src/providers/registry/model-seeds.ts +56 -10
  223. package/src/providers/registry/types.ts +2 -0
  224. package/src/providers/resolved-model-policy-merge.ts +167 -0
  225. package/src/providers/resolved-model-policy.ts +406 -0
  226. package/src/providers/stale-vision-classification-migration.ts +137 -0
  227. package/src/providers/xai-transport.ts +12 -1
  228. package/src/reasoning-effort.ts +8 -0
  229. package/src/responses/apply-patch-envelope.ts +0 -12
  230. package/src/responses/freeform-wrapper-scan.ts +279 -0
  231. package/src/responses/function-call-compat.ts +38 -1
  232. package/src/responses/inline-document.ts +65 -0
  233. package/src/responses/input-media.ts +42 -8
  234. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  235. package/src/responses/muse-tool-name-alias.ts +19 -0
  236. package/src/responses/parser-content.ts +8 -2
  237. package/src/responses/parser-tools.ts +3 -0
  238. package/src/responses/parser.ts +3 -1
  239. package/src/responses/progressive-freeform-input.ts +130 -0
  240. package/src/responses/reasoning-envelope.ts +30 -0
  241. package/src/responses/schema.ts +3 -0
  242. package/src/responses/state.ts +5 -12
  243. package/src/responses/tool-name-aliases.ts +15 -1
  244. package/src/router.ts +108 -117
  245. package/src/routing/compatibility/behavior.ts +9 -0
  246. package/src/routing/compatibility/subject.ts +16 -1
  247. package/src/server/adapter-resolve.ts +9 -0
  248. package/src/server/admission-model-scope.ts +219 -0
  249. package/src/server/audio-live.ts +9 -3
  250. package/src/server/audio-upstream.ts +18 -0
  251. package/src/server/auth-cors.ts +29 -0
  252. package/src/server/chat-completions.ts +60 -4
  253. package/src/server/chat-native.ts +19 -4
  254. package/src/server/claude-messages.ts +61 -20
  255. package/src/server/effort-row.ts +11 -3
  256. package/src/server/grok-responses-control-frame.ts +160 -1
  257. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  258. package/src/server/gui-freshness.ts +103 -0
  259. package/src/server/gui-static.ts +7 -9
  260. package/src/server/images.ts +59 -6
  261. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  262. package/src/server/index/serve-options.ts +142 -39
  263. package/src/server/index/spend-ledger-lifecycle.ts +92 -0
  264. package/src/server/index/startup-warnings.ts +24 -0
  265. package/src/server/index/websocket-handler.ts +6 -1
  266. package/src/server/index.ts +34 -38
  267. package/src/server/lifecycle.ts +4 -4
  268. package/src/server/live-call-bindings.ts +6 -0
  269. package/src/server/live.ts +88 -3
  270. package/src/server/management/agent-settings-routes.ts +121 -36
  271. package/src/server/management/aside-profile-routes.ts +266 -7
  272. package/src/server/management/companion-routes.ts +77 -0
  273. package/src/server/management/config-routes.ts +18 -2
  274. package/src/server/management/context.ts +3 -0
  275. package/src/server/management/integration-routes.ts +287 -5
  276. package/src/server/management/logs-usage-routes.ts +19 -0
  277. package/src/server/management/metrics-routes.ts +20 -0
  278. package/src/server/management/model-rows.ts +224 -12
  279. package/src/server/management/native-integration-routes.ts +103 -6
  280. package/src/server/management/oauth-account-routes.ts +45 -7
  281. package/src/server/management/route-registry.ts +20 -0
  282. package/src/server/management/shared.ts +28 -4
  283. package/src/server/management/system-restart.ts +7 -2
  284. package/src/server/management/system-routes.ts +2 -0
  285. package/src/server/management/usage-aggregate-cache.ts +4 -0
  286. package/src/server/management/usage-timeline-routes.ts +44 -0
  287. package/src/server/management-api.ts +10 -9
  288. package/src/server/management-auth.ts +15 -1
  289. package/src/server/proxy-liveness.ts +75 -0
  290. package/src/server/readiness.ts +29 -10
  291. package/src/server/relay-eager.ts +24 -2
  292. package/src/server/relay.ts +138 -12
  293. package/src/server/request-log-failure-attribution.ts +99 -0
  294. package/src/server/request-log.ts +169 -2
  295. package/src/server/request-metrics.ts +298 -0
  296. package/src/server/responses/adapter-continuation.ts +3 -3
  297. package/src/server/responses/adapter-dispatch.ts +11 -6
  298. package/src/server/responses/codex-ws-wire.ts +34 -8
  299. package/src/server/responses/combo-stream-preflight.ts +168 -6
  300. package/src/server/responses/compact.ts +43 -10
  301. package/src/server/responses/compaction-routing.ts +111 -0
  302. package/src/server/responses/core-codex-account.ts +8 -3
  303. package/src/server/responses/core-combo.ts +7 -7
  304. package/src/server/responses/core-normalize.ts +6 -12
  305. package/src/server/responses/core-opaque-recovery.ts +91 -0
  306. package/src/server/responses/core-options.ts +4 -0
  307. package/src/server/responses/encrypted-payload.ts +20 -2
  308. package/src/server/responses/fetch-helpers.ts +124 -8
  309. package/src/server/responses/input-admission.ts +10 -0
  310. package/src/server/responses/passthrough-delivery.ts +45 -15
  311. package/src/server/responses/passthrough-dispatch.ts +215 -44
  312. package/src/server/responses/passthrough-error.ts +27 -8
  313. package/src/server/responses/policy-fallback.ts +5 -13
  314. package/src/server/responses/request-prepare.ts +109 -18
  315. package/src/server/responses/request-send-budget.ts +17 -1
  316. package/src/server/responses/request-sidecar-auth.ts +1 -1
  317. package/src/server/responses/request-transport.ts +26 -6
  318. package/src/server/responses/reset-replay.ts +108 -0
  319. package/src/server/responses/run-turn-execution.ts +25 -3
  320. package/src/server/responses/sidecar-execution.ts +17 -2
  321. package/src/server/responses/ws-upstream.ts +14 -27
  322. package/src/server/responses-custom-tool-repair.ts +27 -54
  323. package/src/server/responses-request-tool-scope.ts +214 -0
  324. package/src/server/responses-undeclared-tool-guard.ts +35 -2
  325. package/src/server/search.ts +25 -1
  326. package/src/server/sse-payload-rewrite.ts +1 -1
  327. package/src/server/usage-ledger-retention.ts +73 -0
  328. package/src/service/cli.ts +48 -2
  329. package/src/service/health.ts +3 -2
  330. package/src/service/install-state-contract.d.mts +27 -0
  331. package/src/service/install-state-contract.mjs +34 -0
  332. package/src/service/launchd.ts +1 -1
  333. package/src/service/orchestration.ts +2 -4
  334. package/src/service/ownership-compatibility.ts +164 -0
  335. package/src/service/ownership-mutation-lease.d.mts +32 -0
  336. package/src/service/ownership-mutation-lease.mjs +211 -0
  337. package/src/service/repair.ts +45 -1
  338. package/src/service/state-lock.ts +269 -0
  339. package/src/service/state-record.d.mts +36 -0
  340. package/src/service/state-record.mjs +138 -0
  341. package/src/service/state.ts +582 -68
  342. package/src/service/windows-taskxml.ts +11 -10
  343. package/src/service.ts +7 -3
  344. package/src/tray/windows-tray.ps1 +156 -4
  345. package/src/types/config.ts +48 -3
  346. package/src/types/provider.ts +91 -0
  347. package/src/types/request.ts +30 -2
  348. package/src/types/tools.ts +33 -0
  349. package/src/types.ts +4 -0
  350. package/src/update/index.ts +207 -63
  351. package/src/update/job.ts +9 -5
  352. package/src/update/ownership-transaction.ts +47 -0
  353. package/src/update/restart-ownership.ts +54 -0
  354. package/src/update/runtime-ownership.d.mts +40 -0
  355. package/src/update/runtime-ownership.mjs +122 -0
  356. package/src/usage/attempt-delivery.ts +198 -0
  357. package/src/usage/cache-diagnostic.ts +305 -0
  358. package/src/usage/failure-fingerprint.ts +118 -0
  359. package/src/usage/failure-projection-cache.ts +174 -0
  360. package/src/usage/failure-projection.ts +174 -0
  361. package/src/usage/ledger-retention.ts +165 -0
  362. package/src/usage/log.ts +126 -79
  363. package/src/usage/request-outcome.ts +150 -0
  364. package/src/usage/retention-contract.ts +28 -0
  365. package/src/usage/summary.ts +2 -2
  366. package/src/usage/telemetry-contract.ts +237 -0
  367. package/src/usage/timeline.ts +236 -0
  368. package/src/vision/eligibility.ts +88 -9
  369. package/src/vision/plan.ts +34 -10
  370. package/src/web-search/alpha-search.ts +21 -1
  371. package/src/web-search/executor.ts +41 -2
  372. package/src/web-search/loop.ts +6 -1
  373. package/gui/dist/assets/index-C5IebErG.js +0 -136
  374. package/gui/dist/assets/index-OESInAjC.css +0 -1
@@ -0,0 +1,118 @@
1
+ /**
2
+ * A versioned, content-free identity for one kind of request failure (#3748).
3
+ *
4
+ * #3748 proposed a second SQLite store under src/telemetry/ holding a free-text `signature`
5
+ * masked by regular expressions. Both halves are replaced here. The store is replaced by a
6
+ * projection rebuilt from usage.jsonl, and the masked signature is replaced by a tuple of closed
7
+ * roster members -- because a regular expression can only assert that it removed what it matched,
8
+ * while a tuple whose every slot is a member of a frozen list has nothing to remove.
9
+ *
10
+ * That is the whole design: the input type cannot express a provider alias, a model, an account,
11
+ * an error message, a prompt, a request id or a timestamp, so no amount of upstream text can
12
+ * reach a fingerprint.
13
+ */
14
+ import { createHash } from "node:crypto";
15
+ import type { RequestFailureCause } from "../lib/request-failure-model";
16
+ import type { RequestCloseReason, RequestTerminalStatus } from "./request-outcome";
17
+ import type { PersistedUsageEntry } from "./log";
18
+
19
+ /**
20
+ * The tuple layout and its meaning are one contract.
21
+ *
22
+ * Adding, removing, reordering or reinterpreting a position changes what a stored fingerprint
23
+ * means, so any of those requires incrementing this. Tests import it rather than writing `1`,
24
+ * so a bump cannot be silently contradicted by a test that still expects the old prefix.
25
+ */
26
+ export const FAILURE_FINGERPRINT_VERSION = 1 as const;
27
+
28
+ /**
29
+ * The status reduced to its class.
30
+ *
31
+ * The exact code is not in the tuple: a 502 and a 503 that both failed for `upstream-fault` are
32
+ * one problem to an operator, and keeping the code would split every group by whichever number
33
+ * an origin happened to send.
34
+ */
35
+ export const FAILURE_STATUS_CLASSES = Object.freeze([
36
+ "1xx", "2xx", "3xx", "4xx", "5xx", "unknown",
37
+ ] as const);
38
+
39
+ export type FailureStatusClass = typeof FAILURE_STATUS_CLASSES[number];
40
+
41
+ export type FailureFingerprint = `v${typeof FAILURE_FINGERPRINT_VERSION}:${string}`;
42
+
43
+ /**
44
+ * Everything a fingerprint is allowed to read, and nothing else.
45
+ *
46
+ * Every slot is either a member of a frozen roster or `null`. `providerClass` is the one field
47
+ * that starts life as free text: the durable `provider` is a name the user chose, so it is
48
+ * resolved against the provider registry first and becomes `null` when it is not a registry
49
+ * member. A configured alias therefore cannot reach the key whatever it was named.
50
+ */
51
+ export interface FailureFingerprintFacts {
52
+ readonly cause: RequestFailureCause;
53
+ readonly statusClass: FailureStatusClass;
54
+ readonly providerClass: string | null;
55
+ readonly inboundProtocol: NonNullable<PersistedUsageEntry["inboundProtocol"]> | null;
56
+ readonly terminalStatus: RequestTerminalStatus | null;
57
+ readonly closeReason: RequestCloseReason | null;
58
+ readonly transportPhase: NonNullable<PersistedUsageEntry["transportPhase"]> | null;
59
+ readonly terminalSource: NonNullable<PersistedUsageEntry["terminalSource"]> | null;
60
+ }
61
+
62
+ /**
63
+ * Fixed positions, with every absent fact written as an explicit `null`.
64
+ *
65
+ * Omitting an absent field, or joining the present ones with a delimiter, would let two
66
+ * different failures collide: `[a, null, b]` and `[a, b]` are the same string once the nulls
67
+ * are dropped. A fixed-arity tuple cannot collide that way, which is why the shape is a tuple
68
+ * rather than an object with optional keys.
69
+ */
70
+ export type FailureFingerprintTuple = readonly [
71
+ version: typeof FAILURE_FINGERPRINT_VERSION,
72
+ cause: FailureFingerprintFacts["cause"],
73
+ statusClass: FailureFingerprintFacts["statusClass"],
74
+ providerClass: FailureFingerprintFacts["providerClass"],
75
+ inboundProtocol: FailureFingerprintFacts["inboundProtocol"],
76
+ terminalStatus: FailureFingerprintFacts["terminalStatus"],
77
+ closeReason: FailureFingerprintFacts["closeReason"],
78
+ transportPhase: FailureFingerprintFacts["transportPhase"],
79
+ terminalSource: FailureFingerprintFacts["terminalSource"],
80
+ ];
81
+
82
+ export function failureStatusClass(status: unknown): FailureStatusClass {
83
+ if (typeof status !== "number" || !Number.isInteger(status) || status < 100 || status > 599) {
84
+ return "unknown";
85
+ }
86
+ const index = Math.floor(status / 100) - 1;
87
+ return FAILURE_STATUS_CLASSES[index] ?? "unknown";
88
+ }
89
+
90
+ export function canonicalFailureFingerprintTuple(
91
+ facts: FailureFingerprintFacts,
92
+ ): FailureFingerprintTuple {
93
+ return [
94
+ FAILURE_FINGERPRINT_VERSION,
95
+ facts.cause,
96
+ facts.statusClass,
97
+ facts.providerClass,
98
+ facts.inboundProtocol,
99
+ facts.terminalStatus,
100
+ facts.closeReason,
101
+ facts.transportPhase,
102
+ facts.terminalSource,
103
+ ];
104
+ }
105
+
106
+ /**
107
+ * The version travels in the value, not only in the hashed input.
108
+ *
109
+ * Both matter and for different reasons: hashing it means two versions of the same failure never
110
+ * collide, and prefixing it means a reader holding an old fingerprint can tell that it is old
111
+ * instead of concluding the failure stopped happening.
112
+ */
113
+ export function computeFailureFingerprint(facts: FailureFingerprintFacts): FailureFingerprint {
114
+ const digest = createHash("sha256")
115
+ .update(JSON.stringify(canonicalFailureFingerprintTuple(facts)))
116
+ .digest("hex");
117
+ return `v${FAILURE_FINGERPRINT_VERSION}:${digest}`;
118
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The scan owner for the failure projection: one checkpointed pass over usage.jsonl.
3
+ *
4
+ * Shaped after `src/server/management/usage-aggregate-cache.ts`, which solved the same problem
5
+ * for the usage summary. That similarity is deliberate -- the correctness here is entirely in
6
+ * the checkpoint discipline, and two subtly different versions of it is how a projection quietly
7
+ * extends stale groups across a file that was replaced under it.
8
+ *
9
+ * Every bound this projection obeys belongs to the scanner it calls, not to itself: the 1 MiB
10
+ * row ceiling, the 1 MiB chunk, the cooperative yield, the opened-EOF snapshot boundary and the
11
+ * path/device/inode/birthtime identity with its 64 KiB boundary digest. A projection with its
12
+ * own limits would be a second storage policy, which is what this lane exists to avoid.
13
+ */
14
+ import {
15
+ currentUsageLogRevision,
16
+ usageLogIdentityKey,
17
+ usageLogRevisionKey,
18
+ type UsageLogRevision,
19
+ } from "./log";
20
+ import {
21
+ scanUsageLedgerCooperatively,
22
+ UsageLedgerRebuildRequiredError,
23
+ } from "./ledger-scanner";
24
+ import {
25
+ createFailureProjectionAccumulator,
26
+ type FailureProjectionAccumulator,
27
+ type FailureProjectionSnapshot,
28
+ } from "./failure-projection";
29
+
30
+ export type FailureProjectionUpdate = "unchanged" | "append" | "rebuild";
31
+
32
+ export interface FailureProjectionResult extends FailureProjectionSnapshot {
33
+ update: FailureProjectionUpdate;
34
+ /** True once any row was skipped for exceeding the scanner's row ceiling. Sticky. */
35
+ historyIncomplete: boolean;
36
+ }
37
+
38
+ interface RetainedProjection {
39
+ accumulator: FailureProjectionAccumulator;
40
+ historyIncomplete: boolean;
41
+ revision: UsageLogRevision | null;
42
+ identityKey: string;
43
+ revisionKey: string;
44
+ processedThroughBytes: number;
45
+ processedThroughDigest: string;
46
+ }
47
+
48
+ const MAX_REBUILD_ATTEMPTS = 2;
49
+ let retained: RetainedProjection | null = null;
50
+ let inFlight: Promise<FailureProjectionResult> | null = null;
51
+
52
+ function resultFrom(state: RetainedProjection, update: FailureProjectionUpdate): FailureProjectionResult {
53
+ return { ...state.accumulator.snapshot(), update, historyIncomplete: state.historyIncomplete };
54
+ }
55
+
56
+ function retain(
57
+ accumulator: FailureProjectionAccumulator,
58
+ scan: Awaited<ReturnType<typeof scanUsageLedgerCooperatively>>,
59
+ historyIncomplete: boolean,
60
+ ): RetainedProjection {
61
+ return {
62
+ accumulator,
63
+ historyIncomplete: historyIncomplete || scan.oversizedRows > 0,
64
+ revision: scan.revision,
65
+ identityKey: usageLogIdentityKey(scan.revision),
66
+ revisionKey: usageLogRevisionKey(scan.revision),
67
+ processedThroughBytes: scan.processedThroughBytes,
68
+ processedThroughDigest: scan.processedThroughDigest,
69
+ };
70
+ }
71
+
72
+ async function rebuild(signal: AbortSignal | undefined): Promise<FailureProjectionResult> {
73
+ let lastError: unknown;
74
+ for (let attempt = 0; attempt < MAX_REBUILD_ATTEMPTS; attempt += 1) {
75
+ const accumulator = createFailureProjectionAccumulator();
76
+ try {
77
+ const scan = await scanUsageLedgerCooperatively({
78
+ ...(signal ? { signal } : {}),
79
+ onEntry: entry => accumulator.add(entry),
80
+ });
81
+ retained = retain(accumulator, scan, false);
82
+ return resultFrom(retained, "rebuild");
83
+ } catch (error) {
84
+ lastError = error;
85
+ if (!(error instanceof UsageLedgerRebuildRequiredError) || attempt + 1 >= MAX_REBUILD_ATTEMPTS) throw error;
86
+ }
87
+ }
88
+ throw lastError ?? new Error("failure projection rebuild did not settle");
89
+ }
90
+
91
+ /**
92
+ * Whether the observed ledger can still be read as an append onto the retained state.
93
+ *
94
+ * A same-size file whose revision metadata moved is a replacement or an in-place edit, not an
95
+ * append. Treating it as one would extend groups built from rows that no longer exist, so it
96
+ * forces a rebuild even though the byte count is unchanged.
97
+ */
98
+ function requiresRebuild(state: RetainedProjection, observed: UsageLogRevision | null): boolean {
99
+ if (state.identityKey !== usageLogIdentityKey(observed)) return true;
100
+ if (!state.revision || !observed) return state.revision !== observed;
101
+ if (observed.size < state.revision.size) return true;
102
+ return observed.size === state.revision.size && usageLogRevisionKey(observed) !== state.revisionKey;
103
+ }
104
+
105
+ async function append(
106
+ state: RetainedProjection,
107
+ signal: AbortSignal | undefined,
108
+ ): Promise<FailureProjectionResult> {
109
+ // Clone first and publish only after the scanner verifies the captured suffix, so a mutation
110
+ // discovered mid-scan leaves the retained state exactly as it was.
111
+ const candidate = state.accumulator.clone();
112
+ try {
113
+ const scan = await scanUsageLedgerCooperatively({
114
+ ...(signal ? { signal } : {}),
115
+ startAtBytes: state.processedThroughBytes,
116
+ expectedIdentityKey: state.identityKey,
117
+ expectedProcessedThroughDigest: state.processedThroughDigest,
118
+ onEntry: entry => candidate.add(entry),
119
+ });
120
+ retained = retain(candidate, scan, state.historyIncomplete);
121
+ return resultFrom(retained, "append");
122
+ } catch (error) {
123
+ if (retained === state) retained = null;
124
+ if (error instanceof UsageLedgerRebuildRequiredError) return rebuild(signal);
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ async function refresh(signal: AbortSignal | undefined): Promise<FailureProjectionResult> {
130
+ const state = retained;
131
+ if (!state) return rebuild(signal);
132
+ const observed = currentUsageLogRevision();
133
+ if (requiresRebuild(state, observed)) return rebuild(signal);
134
+ if (observed && state.revision && observed.size === state.revision.size) {
135
+ return resultFrom(state, "unchanged");
136
+ }
137
+ return append(state, signal);
138
+ }
139
+
140
+ /**
141
+ * The current grouping, refreshed from the ledger.
142
+ *
143
+ * Single-flighted: two concurrent readers would otherwise run two scans of the same file and
144
+ * one of them would publish over the other's checkpoint.
145
+ */
146
+ export async function getFailureProjection(
147
+ options: { signal?: AbortSignal } = {},
148
+ ): Promise<FailureProjectionResult> {
149
+ if (inFlight) return inFlight;
150
+ const flight = refresh(options.signal).finally(() => {
151
+ if (inFlight === flight) inFlight = null;
152
+ });
153
+ inFlight = flight;
154
+ return flight;
155
+ }
156
+
157
+ /**
158
+ * Drop the retained projection.
159
+ *
160
+ * Safe at any time and for any reason: it is rebuildable from the ledger by construction, which
161
+ * is the property that lets memory pressure discard the whole thing rather than prune individual
162
+ * groups. Pruning groups would make this projection a retention policy of its own.
163
+ */
164
+ export function discardRetainedFailureProjection(): number {
165
+ const count = retained?.accumulator.groupCount ?? 0;
166
+ retained = null;
167
+ return count;
168
+ }
169
+
170
+ /** Test-only process-state reset for isolated harnesses. */
171
+ export function resetFailureProjectionCacheForTests(): void {
172
+ retained = null;
173
+ inFlight = null;
174
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Failures grouped by what they have in common, rebuilt from the canonical ledger.
3
+ *
4
+ * This is the derived form of #3748. The original built a second durable store; this holds only
5
+ * a count and two timestamps per group, and every one of them falls out of a scan of
6
+ * usage.jsonl. Delete a row from the ledger and it leaves this projection on the next rebuild,
7
+ * which is what it means for retention to have one owner rather than four.
8
+ *
9
+ * What it deliberately does NOT hold: the occurrence list the original retained (a second copy
10
+ * of history with its own retention policy), and the mutable monitoring/dispatched/fixed/ignored
11
+ * remediation status with its free-text notes. Those are operator state, not event history: they
12
+ * cannot be reconstructed from immutable request rows, so presenting them as a derived ledger
13
+ * would be presenting a claim this projection cannot make. They need their own owner if they are
14
+ * wanted, keyed by the fingerprint below.
15
+ */
16
+ import { getProviderRegistryEntry } from "../providers/registry";
17
+ import { baseProviderLabel } from "../providers/label";
18
+ import {
19
+ computeFailureFingerprint,
20
+ failureStatusClass,
21
+ FAILURE_FINGERPRINT_VERSION,
22
+ type FailureFingerprint,
23
+ type FailureFingerprintFacts,
24
+ } from "./failure-fingerprint";
25
+ import {
26
+ classifyRequestOutcome,
27
+ isRequestCloseReason,
28
+ isRequestTerminalStatus,
29
+ } from "./request-outcome";
30
+ import {
31
+ isKnownInboundProtocol,
32
+ isKnownRequestFailureCause,
33
+ isKnownTerminalSource,
34
+ isKnownTransportPhase,
35
+ type PersistedUsageEntry,
36
+ } from "./log";
37
+
38
+ /**
39
+ * The configured provider name reduced to a registry member, or null.
40
+ *
41
+ * The durable `provider` is whatever the user named their provider entry, so it is open text and
42
+ * cannot enter a key that promises to carry no content. Resolving it against the registry makes
43
+ * the value closed by construction: either it is one of the ids this build ships, or it is
44
+ * nothing. A user who names a provider after themselves groups under `null`, which is the
45
+ * correct answer -- the projection does not know which provider it is.
46
+ */
47
+ export function failureProviderClass(provider: string): string | null {
48
+ return getProviderRegistryEntry(baseProviderLabel(provider))?.id ?? null;
49
+ }
50
+
51
+ export interface FailureProjectionGroup extends FailureFingerprintFacts {
52
+ fingerprint: FailureFingerprint;
53
+ firstSeen: number;
54
+ lastSeen: number;
55
+ count: number;
56
+ }
57
+
58
+ export interface FailureProjectionSnapshot {
59
+ fingerprintVersion: typeof FAILURE_FINGERPRINT_VERSION;
60
+ groups: readonly FailureProjectionGroup[];
61
+ /**
62
+ * Failed rows written before the recorder stored a cause. Counted rather than bucketed under
63
+ * an invented "unknown" cause, because a group an operator cannot act on is worse than a
64
+ * number that says how much history predates the field.
65
+ */
66
+ unattributedFailures: number;
67
+ /** Failed rows whose timestamp is not a finite number, so they cannot date a group. */
68
+ invalidTimestampFailures: number;
69
+ }
70
+
71
+ export interface FailureProjectionAccumulator {
72
+ add(entry: PersistedUsageEntry): void;
73
+ clone(): FailureProjectionAccumulator;
74
+ snapshot(): FailureProjectionSnapshot;
75
+ readonly groupCount: number;
76
+ }
77
+
78
+ interface MutableGroup extends FailureFingerprintFacts {
79
+ fingerprint: FailureFingerprint;
80
+ firstSeen: number;
81
+ lastSeen: number;
82
+ count: number;
83
+ }
84
+
85
+ function factsFor(entry: PersistedUsageEntry): FailureFingerprintFacts | null {
86
+ if (!isKnownRequestFailureCause(entry.failureCause)) return null;
87
+ return {
88
+ cause: entry.failureCause,
89
+ statusClass: failureStatusClass(entry.status),
90
+ providerClass: failureProviderClass(entry.provider),
91
+ inboundProtocol: isKnownInboundProtocol(entry.inboundProtocol) ? entry.inboundProtocol : null,
92
+ // Validated rather than copied. This is the one tuple slot whose durable type is a plain
93
+ // string, and it is assembled from an upstream terminal frame, so an unvalidated value is
94
+ // the single way upstream-controlled text could reach a grouping key.
95
+ terminalStatus: isRequestTerminalStatus(entry.terminalStatus) ? entry.terminalStatus : null,
96
+ closeReason: isRequestCloseReason(entry.closeReason) ? entry.closeReason : null,
97
+ transportPhase: isKnownTransportPhase(entry.transportPhase) ? entry.transportPhase : null,
98
+ terminalSource: isKnownTerminalSource(entry.terminalSource) ? entry.terminalSource : null,
99
+ };
100
+ }
101
+
102
+ function createFrom(groups: Map<string, MutableGroup>, counters: {
103
+ unattributed: number;
104
+ invalidTimestamp: number;
105
+ }): FailureProjectionAccumulator {
106
+ let unattributedFailures = counters.unattributed;
107
+ let invalidTimestampFailures = counters.invalidTimestamp;
108
+
109
+ return {
110
+ add(entry: PersistedUsageEntry): void {
111
+ // The shared classifier decides what a failure is, so this projection and the exporter
112
+ // agree on which rows are in scope. An incomplete turn is not here: it has no cause.
113
+ if (classifyRequestOutcome(entry) !== "failed") return;
114
+ const facts = factsFor(entry);
115
+ if (facts === null) {
116
+ unattributedFailures += 1;
117
+ return;
118
+ }
119
+ if (typeof entry.timestamp !== "number" || !Number.isFinite(entry.timestamp)) {
120
+ invalidTimestampFailures += 1;
121
+ return;
122
+ }
123
+ const fingerprint = computeFailureFingerprint(facts);
124
+ const existing = groups.get(fingerprint);
125
+ if (existing === undefined) {
126
+ groups.set(fingerprint, {
127
+ ...facts,
128
+ fingerprint,
129
+ firstSeen: entry.timestamp,
130
+ lastSeen: entry.timestamp,
131
+ count: 1,
132
+ });
133
+ return;
134
+ }
135
+ // Min and max rather than first-and-last-written: a ledger is append-ordered in practice
136
+ // but nothing in the format promises it, and a projection that assumed order would report
137
+ // a first-seen later than its last-seen for a hand-merged file.
138
+ existing.firstSeen = Math.min(existing.firstSeen, entry.timestamp);
139
+ existing.lastSeen = Math.max(existing.lastSeen, entry.timestamp);
140
+ existing.count += 1;
141
+ },
142
+
143
+ clone(): FailureProjectionAccumulator {
144
+ const copy = new Map<string, MutableGroup>();
145
+ for (const [key, group] of groups) copy.set(key, { ...group });
146
+ return createFrom(copy, {
147
+ unattributed: unattributedFailures,
148
+ invalidTimestamp: invalidTimestampFailures,
149
+ });
150
+ },
151
+
152
+ snapshot(): FailureProjectionSnapshot {
153
+ // Most recent first, then by fingerprint, so two runs over the same ledger produce the
154
+ // same order. A tie broken by insertion order would depend on scan chunking.
155
+ const ordered = [...groups.values()]
156
+ .map(group => ({ ...group }))
157
+ .sort((a, b) => b.lastSeen - a.lastSeen || (a.fingerprint < b.fingerprint ? -1 : a.fingerprint > b.fingerprint ? 1 : 0));
158
+ return {
159
+ fingerprintVersion: FAILURE_FINGERPRINT_VERSION,
160
+ groups: ordered,
161
+ unattributedFailures,
162
+ invalidTimestampFailures,
163
+ };
164
+ },
165
+
166
+ get groupCount(): number {
167
+ return groups.size;
168
+ },
169
+ };
170
+ }
171
+
172
+ export function createFailureProjectionAccumulator(): FailureProjectionAccumulator {
173
+ return createFrom(new Map(), { unattributed: 0, invalidTimestamp: 0 });
174
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Opt-in size limit for the canonical usage ledger (#5063).
3
+ *
4
+ * Retention on usage.jsonl is the right architecture -- the alternative is a projection that
5
+ * deletes rows the ledger still has, which is a second retention policy. What #5063's version
6
+ * could not promise is that a row appended by another writer between its size snapshot and its
7
+ * rename survived: it captured a size, copied a suffix, and renamed over whatever was there.
8
+ *
9
+ * Two things close that here. The append is synchronous and this runs inside the same call
10
+ * stack, with no await between the append and the publication, so no in-process append can
11
+ * interleave. And `validateBeforeRename` re-opens the target immediately before the rename and
12
+ * refuses unless its identity, size and revision metadata are byte-for-byte what was copied --
13
+ * so an append from anywhere else aborts the replacement instead of losing the row. The original
14
+ * file and that append both survive; the next append retries from a fresh revision.
15
+ *
16
+ * What remains outside the contract: a program that ignores the OpenCodex ledger owner entirely
17
+ * can still write between the final comparison and the rename. No portable conditional rename
18
+ * exists to prevent that, and the honest claim is that the race is closed for every cooperating
19
+ * writer and detected up to the last possible moment for anything else.
20
+ */
21
+ import { closeSync, fstatSync, openSync, readSync, writeSync } from "node:fs";
22
+ import { atomicWriteFileStreamed } from "../config/atomic-write";
23
+ import {
24
+ currentUsageLogRevision,
25
+ usageLogIdentityKey,
26
+ usageLogPath,
27
+ usageLogRevisionKey,
28
+ type UsageLogRevision,
29
+ } from "./log";
30
+ import {
31
+ MIN_USAGE_LEDGER_MAX_BYTES,
32
+ USAGE_LEDGER_RETENTION_TARGET_RATIO,
33
+ } from "./retention-contract";
34
+
35
+ /** Bounded copy buffer. Matches the ledger scanner's chunk so one storage policy governs both. */
36
+ const COPY_CHUNK_BYTES = 1024 * 1024;
37
+
38
+ export class UsageLedgerRevisionChangedError extends Error {
39
+ readonly code = "usage_ledger_revision_changed";
40
+
41
+ constructor() {
42
+ super("usage ledger changed while its retained span was being published");
43
+ this.name = "UsageLedgerRevisionChangedError";
44
+ }
45
+ }
46
+
47
+ export type UsageLedgerRetentionResult =
48
+ | { kind: "disabled" }
49
+ | { kind: "unchanged"; currentBytes: number }
50
+ | { kind: "replaced"; currentBytes: number; removedBytes: number }
51
+ | { kind: "deferred"; currentBytes: number; reason: "revision-changed" | "no-boundary" };
52
+
53
+ /**
54
+ * The first LF at or after `from`, so the retained span starts on a row boundary.
55
+ *
56
+ * Any nonempty suffix that is not LF-terminated is uncommitted by the scanner's definition, even
57
+ * if it happens to parse. Starting anywhere but after an LF would publish half a row as a whole
58
+ * one, which is the shape that makes a ledger unreadable rather than merely shorter.
59
+ */
60
+ function firstRowBoundary(fd: number, from: number, end: number): number | null {
61
+ const buffer = Buffer.allocUnsafe(COPY_CHUNK_BYTES);
62
+ for (let position = from; position < end;) {
63
+ const read = readSync(fd, buffer, 0, Math.min(buffer.byteLength, end - position), position);
64
+ if (read <= 0) return null;
65
+ const index = buffer.subarray(0, read).indexOf(0x0a);
66
+ if (index >= 0) return position + index + 1;
67
+ position += read;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function copyRange(sourceFd: number, targetFd: number, from: number, to: number): void {
73
+ const buffer = Buffer.allocUnsafe(COPY_CHUNK_BYTES);
74
+ for (let position = from; position < to;) {
75
+ const read = readSync(sourceFd, buffer, 0, Math.min(buffer.byteLength, to - position), position);
76
+ if (read <= 0) throw new Error("usage ledger shrank while its retained span was copied");
77
+ let written = 0;
78
+ while (written < read) written += writeSync(targetFd, buffer, written, read - written);
79
+ position += read;
80
+ }
81
+ }
82
+
83
+ function sameRevision(a: UsageLogRevision | null, b: UsageLogRevision | null): boolean {
84
+ return a !== null && b !== null
85
+ && usageLogIdentityKey(a) === usageLogIdentityKey(b)
86
+ && usageLogRevisionKey(a) === usageLogRevisionKey(b)
87
+ && a.size === b.size;
88
+ }
89
+
90
+ /**
91
+ * Trim the ledger to the newest whole rows when it exceeds `maxBytes`.
92
+ *
93
+ * Rows are copied BYTE FOR BYTE and never parsed or re-serialized. That is what keeps the
94
+ * failure stage and cause, the attempts, the spend record and any field a later build adds
95
+ * intact through a compaction: a retention pass that understood the row shape would silently
96
+ * drop every field it was written before.
97
+ */
98
+ export function enforceUsageLedgerSizeLimit(
99
+ maxBytes: number | undefined,
100
+ /**
101
+ * Runs after the retained span is copied and before the pre-rename check.
102
+ *
103
+ * A parameter rather than an exported flag, so the only way to reach this window is to be the
104
+ * caller. The revision guard below is the one piece of this module that cannot be observed
105
+ * from its inputs and outputs, and a contract nothing can drive is a contract nobody has
106
+ * checked.
107
+ */
108
+ options: { onSpanCopied?: () => void } = {},
109
+ ): UsageLedgerRetentionResult {
110
+ if (maxBytes === undefined || !Number.isSafeInteger(maxBytes) || maxBytes < MIN_USAGE_LEDGER_MAX_BYTES) {
111
+ return { kind: "disabled" };
112
+ }
113
+ const captured = currentUsageLogRevision();
114
+ if (!captured || captured.size <= maxBytes) {
115
+ return captured ? { kind: "unchanged", currentBytes: captured.size } : { kind: "disabled" };
116
+ }
117
+ const path = usageLogPath();
118
+ // Trim below the ceiling rather than to it, so an append does not immediately re-cross the
119
+ // line and make every subsequent append pay for a full rewrite.
120
+ const target = Math.floor(maxBytes * USAGE_LEDGER_RETENTION_TARGET_RATIO);
121
+ let sourceFd: number;
122
+ try {
123
+ sourceFd = openSync(path, "r");
124
+ } catch {
125
+ return { kind: "deferred", currentBytes: captured.size, reason: "revision-changed" };
126
+ }
127
+ let sourceClosed = false;
128
+ try {
129
+ const opened = fstatSync(sourceFd);
130
+ if (Number(opened.size) !== captured.size || Number(opened.ino) !== captured.ino) {
131
+ return { kind: "deferred", currentBytes: captured.size, reason: "revision-changed" };
132
+ }
133
+ const start = firstRowBoundary(sourceFd, Math.max(0, captured.size - target), captured.size);
134
+ // No LF in the retained window means one row is larger than the whole target. Deleting it
135
+ // would empty the ledger to satisfy a ceiling it cannot meet, so nothing is done.
136
+ if (start === null || start >= captured.size) {
137
+ return { kind: "deferred", currentBytes: captured.size, reason: "no-boundary" };
138
+ }
139
+ atomicWriteFileStreamed(path, descriptor => {
140
+ copyRange(sourceFd, descriptor, start, captured.size);
141
+ // Windows cannot replace a destination held open by this reader. The
142
+ // copied bytes are complete; keep the pathname revision guard below.
143
+ closeSync(sourceFd);
144
+ sourceClosed = true;
145
+ options.onSpanCopied?.();
146
+ }, {
147
+ // The last possible moment. Anything that appended, replaced or rewrote the ledger while
148
+ // the copy ran moves size, inode or revision metadata, and the throw leaves both the
149
+ // original file and that write exactly as they are.
150
+ validateBeforeRename: () => {
151
+ if (!sameRevision(currentUsageLogRevision(), captured)) {
152
+ throw new UsageLedgerRevisionChangedError();
153
+ }
154
+ },
155
+ });
156
+ return { kind: "replaced", currentBytes: captured.size - start, removedBytes: start };
157
+ } catch (error) {
158
+ if (error instanceof UsageLedgerRevisionChangedError) {
159
+ return { kind: "deferred", currentBytes: captured.size, reason: "revision-changed" };
160
+ }
161
+ throw error;
162
+ } finally {
163
+ if (!sourceClosed) closeSync(sourceFd);
164
+ }
165
+ }