cartethyia 2.5.2 → 2.5.4

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 (277) hide show
  1. package/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-C2orBsI2.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  3. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +1 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-BV6pxVQo.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-Bsan4679.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-CW-wPmJ6.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-0p8uX7vB.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  13. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js +1 -0
  14. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-BvzCx8Kr.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  17. package/dist/dashboard/assets/{ModelPicker-CWkTUtpX.js → ModelPicker-BY1aKEkP.js} +1 -1
  18. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  19. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +1 -0
  20. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  21. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +3 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js +1 -0
  24. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  25. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +7 -0
  26. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  27. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +1 -0
  28. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  29. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +1 -0
  30. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  31. package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-DDbMQsoo.js} +1 -1
  32. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  33. package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-CB3Z7VTf.js} +1 -1
  34. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  35. package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-D95UCPHI.js} +3 -3
  36. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  37. package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-Cnae9SWA.js} +13 -13
  38. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  39. package/dist/dashboard/assets/{api-keys-uNywYlfv.js → api-keys-VY68Mhd1.js} +1 -1
  40. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  41. package/dist/dashboard/assets/app-COlK95oi.css +2 -0
  42. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  43. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-Chi7pCnt.js} +1 -1
  44. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  45. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-D4pQapKj.js} +1 -1
  46. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  47. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-DH6cg-_B.js} +1 -1
  48. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  49. package/dist/dashboard/assets/{card--AtFedAq.js → card-BKqlUOe4.js} +1 -1
  50. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  51. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-fTgljTGA.js} +1 -1
  52. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  53. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-D5Ys_sM0.js} +1 -1
  54. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  55. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  56. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  57. package/dist/dashboard/assets/{dialog-BtpsJU98.js → dialog-BYj-8WuK.js} +1 -1
  58. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  59. package/dist/dashboard/assets/{drawer-WwwtzihF.js → drawer-CmXIZ6H1.js} +1 -1
  60. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  61. package/dist/dashboard/assets/entry-CBg5zeu8.js +1 -0
  62. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  63. package/dist/dashboard/assets/entry-CQBr6U9c.js +1 -0
  64. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CgdSAzEV.js +4 -0
  66. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  67. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-BlptL8Uf.js} +1 -1
  68. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  69. package/dist/dashboard/assets/{index-DCBtROoK.js → index-js6TJvdr.js} +2 -2
  70. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  71. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-DUWDSmDV.js} +1 -1
  72. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  73. package/dist/dashboard/assets/{input-BG41et9G.js → input-LVjgZq4f.js} +1 -1
  74. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  75. package/dist/dashboard/assets/{layout-C8mer_Ak.js → layout-CPfxpsfy.js} +1 -1
  76. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  77. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-DupvLfDx.js} +1 -1
  78. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  79. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-C4pEJUqI.js} +1 -1
  80. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  81. package/dist/dashboard/assets/model-bans-BRiuufkB.js +1 -0
  82. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  83. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-B4ROwk8L.js} +1 -1
  84. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  85. package/dist/dashboard/assets/numbers-YNcHvRY7.js +2 -0
  86. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  87. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-gmjzC3RS.js} +1 -1
  88. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js.gz → query-keys-gmjzC3RS.js.gz} +0 -0
  89. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-C7tBT5o-.js} +1 -1
  90. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  91. package/dist/dashboard/assets/{select-DsX5QT8K.js → select-WUCXorXw.js} +2 -2
  92. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  93. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-C_zKl1GS.js} +1 -1
  94. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  95. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-_3O0F8Xn.js} +1 -1
  96. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  97. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-BgTxHcJS.js} +1 -1
  98. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  99. package/dist/dashboard/assets/switch-DEjTtOWf.js +1 -0
  100. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  101. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  102. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-BWSC0UAS.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  105. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  106. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  107. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  108. package/dist/dashboard/index.html +1 -1
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +54 -54
  111. package/package.json +1 -1
  112. package/scripts/cli/autostart.ts +237 -0
  113. package/scripts/cli/command/console-menu.ts +244 -246
  114. package/scripts/cli/command/tray.ts +208 -204
  115. package/scripts/cli/command/update.ts +27 -10
  116. package/scripts/cli/main.ts +28 -1
  117. package/src/boot/app.ts +693 -693
  118. package/src/boot/dependencies.ts +646 -616
  119. package/src/config/spec.ts +81 -2
  120. package/src/config/telemetry.ts +46 -0
  121. package/src/console/api-keys/bucket-spend.ts +15 -4
  122. package/src/console/backup/contracts.ts +15 -2
  123. package/src/console/backup/routes.ts +52 -1
  124. package/src/console/backup/service.ts +60 -4
  125. package/src/console/console-router.ts +8 -0
  126. package/src/console/data-management/contracts.ts +93 -0
  127. package/src/console/data-management/operations.ts +229 -0
  128. package/src/console/data-management/routes.ts +104 -0
  129. package/src/console/domain-registration.ts +39 -0
  130. package/src/console/observability/contracts.ts +524 -485
  131. package/src/console/observability/store.ts +1115 -1018
  132. package/src/console/providers/catalog/contracts.ts +6 -0
  133. package/src/console/providers/catalog/provider-operations.ts +20 -0
  134. package/src/console/providers/catalog/routes.ts +7 -0
  135. package/src/console/providers/catalog/store.ts +22 -1
  136. package/src/console/quota/account-quota-shared.ts +52 -39
  137. package/src/console/quota/refresh.ts +441 -429
  138. package/src/console/routing/pools/import-parse.ts +206 -0
  139. package/src/console/routing/pools/operations.ts +543 -501
  140. package/src/console/routing/pools/routes.ts +181 -164
  141. package/src/console/settings/contracts.ts +37 -0
  142. package/src/console/settings/store.ts +51 -0
  143. package/src/console/share/share-router.ts +6 -9
  144. package/src/console/updates/routes.ts +16 -0
  145. package/src/main.ts +10 -0
  146. package/src/observability/event-compaction.ts +315 -0
  147. package/src/observability/event-day-index.ts +442 -0
  148. package/src/observability/event-log.ts +152 -3
  149. package/src/observability/payload-capture.ts +373 -344
  150. package/src/observability/payload-store.ts +440 -366
  151. package/src/observability/telemetry-status.ts +28 -16
  152. package/src/persistence/health-event-flush.ts +36 -0
  153. package/src/persistence/schema.ts +1402 -1360
  154. package/src/persistence/stores/api-key-store.ts +6 -0
  155. package/src/persistence/stores/share-store.ts +583 -556
  156. package/src/persistence/stores/telemetry-store.ts +237 -214
  157. package/src/protocol/codecs.ts +1 -2
  158. package/src/protocol/image-clamp.ts +19 -1
  159. package/src/protocol/registry.ts +8 -0
  160. package/src/protocol/request/gemini.ts +9 -2
  161. package/src/protocol/request/messages.ts +14 -2
  162. package/src/protocol/response/gemini.ts +18 -4
  163. package/src/providers/adapters/compatible-adapter.ts +3 -0
  164. package/src/providers/bundled-discovery-providers.ts +122 -119
  165. package/src/providers/configured-openai-providers.ts +136 -134
  166. package/src/providers/default-registry.ts +5 -4
  167. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  168. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  169. package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
  170. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
  171. package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
  172. package/src/providers/integrations/cline/cline-quota.ts +132 -128
  173. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  174. package/src/providers/integrations/codex/codex.ts +2 -29
  175. package/src/providers/integrations/cursor/cursor-quota.ts +310 -0
  176. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  177. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  178. package/src/providers/integrations/cursor/cursor.ts +1485 -960
  179. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  180. package/src/providers/integrations/factory/factory.ts +8 -1
  181. package/src/providers/integrations/gemini.ts +6 -1
  182. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  183. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  184. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  185. package/src/providers/integrations/opencode/provider.ts +531 -532
  186. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  187. package/src/providers/integrations/tokenharbor.ts +2 -2
  188. package/src/providers/integrations/trae/trae-catalog.ts +5 -3
  189. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  190. package/src/providers/operations/account-health-service.ts +1523 -1457
  191. package/src/providers/operations/client-versions.ts +9 -2
  192. package/src/providers/provider-contracts.ts +7 -0
  193. package/src/providers/provider-metadata.ts +13 -1
  194. package/src/providers/quota/quota-support.ts +8 -0
  195. package/src/security/api-key-auth.ts +504 -494
  196. package/src/transport/dispatch/attempt-finalize.ts +46 -5
  197. package/src/transport/dispatch/generation.ts +6 -0
  198. package/src/transport/dispatch/proxy-request.ts +33 -1
  199. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  200. package/src/transport/dispatch/streaming/stream.ts +30 -2
  201. package/src/transport/dispatch/upstream.ts +9 -0
  202. package/src/transport/request/preparer.ts +7 -6
  203. package/src/transport/request/state.ts +9 -0
  204. package/src/transport/request/tenant-preferences.ts +60 -0
  205. package/src/transport/routing/route-catalog.ts +854 -799
  206. package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
  207. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js +0 -1
  208. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
  209. package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
  210. package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
  211. package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
  212. package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
  213. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js +0 -1
  214. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
  215. package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
  216. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  217. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js +0 -1
  218. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
  219. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
  220. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
  221. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js +0 -1
  222. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
  223. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js +0 -7
  224. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
  225. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js +0 -1
  226. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
  227. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
  228. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
  229. package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
  230. package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
  231. package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
  232. package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
  233. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  234. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  235. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  236. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  237. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  238. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  239. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  240. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  241. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  242. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  243. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  244. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  245. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  246. package/dist/dashboard/assets/entry-Dj9AR0gY.js +0 -4
  247. package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
  248. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  249. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  250. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  251. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  252. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  253. package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
  254. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  255. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  256. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  257. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  258. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  259. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  260. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  261. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  262. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  263. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  264. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  265. package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
  266. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  267. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  268. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  269. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  270. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  271. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  272. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  273. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  274. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  275. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  276. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-DCQZRmHY.js} +0 -0
  277. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-DCQZRmHY.js.gz} +0 -0
@@ -1,366 +1,440 @@
1
- import { mkdir, open, readdir, readFile, stat, unlink } from "node:fs/promises";
2
- import { basename, join } from "node:path";
3
- import { randomUUID } from "node:crypto";
4
- import { gunzipSync, gzipSync } from "node:zlib";
5
-
6
- export interface PayloadFileReference {
7
- /**
8
- * `jsonb-file` is a frame written before compression existed: plain JSON.
9
- * New frames are `jsonl-file`: the same JSON, gzipped. The row names its
10
- * encoding, so a reader never guesses and an old row still resolves.
11
- */
12
- readonly storage: "jsonb-file" | "jsonl-file";
13
- readonly file: string;
14
- readonly offset: number;
15
- readonly length: number;
16
- readonly checksum: string;
17
- readonly version: 1;
18
- }
19
-
20
- interface PayloadFrame {
21
- readonly version: 1;
22
- readonly id: string;
23
- readonly expiresAt: string;
24
- readonly payload: unknown;
25
- }
26
-
27
- export function isPayloadFileReference(value: unknown): value is PayloadFileReference {
28
- if (typeof value !== "object" || value === null) return false;
29
- const record = value as Record<string, unknown>;
30
- return (
31
- (record["storage"] === "jsonb-file" || record["storage"] === "jsonl-file") &&
32
- typeof record["file"] === "string" &&
33
- typeof record["offset"] === "number" &&
34
- typeof record["length"] === "number" &&
35
- typeof record["checksum"] === "string" &&
36
- record["version"] === 1
37
- );
38
- }
39
-
40
- /**
41
- * Builds a frame reference from a `telemetry_payloads` row's typed columns.
42
- * Returns undefined when any column is out of contract so callers treat a
43
- * corrupt index row as "no payload" rather than path-traversing or reading
44
- * the wrong bytes.
45
- */
46
- export function payloadReferenceFromRow(row: {
47
- readonly storage: string;
48
- readonly file: string;
49
- readonly offset: number;
50
- readonly length: number;
51
- readonly checksum: string;
52
- readonly version: number;
53
- }): PayloadFileReference | undefined {
54
- const candidate = {
55
- storage: row.storage,
56
- file: row.file,
57
- offset: row.offset,
58
- length: row.length,
59
- checksum: row.checksum,
60
- version: row.version,
61
- };
62
- return isPayloadFileReference(candidate) ? candidate : undefined;
63
- }
64
-
65
- const FRAME_HEADER_BYTES = 4;
66
- const MAX_FRAME_BYTES = 256 * 1024 * 1024;
67
- const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
68
- const DEFAULT_DIRECTORY = "./data/telemetry-payloads";
69
- let writeTail: Promise<void> = Promise.resolve();
70
-
71
- /**
72
- * Single source of truth for the telemetry payload directory: the
73
- * `CARTETHYIA_TELEMETRY_PAYLOAD_DIR` override when set, otherwise the
74
- * default `./data/telemetry-payloads`. Every writer and reader must resolve
75
- * the directory through this function so the two can never drift.
76
- */
77
- export function payloadDirectory(): string {
78
- return process.env.CARTETHYIA_TELEMETRY_PAYLOAD_DIR?.trim() || DEFAULT_DIRECTORY;
79
- }
80
-
81
- function maxFileBytes(): number {
82
- const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_FILE_MAX_BYTES ?? DEFAULT_MAX_FILE_BYTES);
83
- return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_FILE_BYTES;
84
- }
85
-
86
- function checksum(value: Buffer | readonly Buffer[]): string {
87
- const parts = Array.isArray(value) ? value : [value];
88
- let hash = 2166136261;
89
- for (const part of parts) {
90
- for (let index = 0; index < part.byteLength; index += 1) {
91
- hash ^= part[index]!;
92
- hash = Math.imul(hash, 16777619);
93
- }
94
- }
95
- return (hash >>> 0).toString(16).padStart(8, "0");
96
- }
97
-
98
- function fileName(now = new Date()): string {
99
- return `${now.toISOString().slice(0, 13).replaceAll(":", "-")}-${process.pid}.jsonb`;
100
- }
101
-
102
- /**
103
- * Gzipped frame body.
104
- *
105
- * The pieces stay separate until here, because nothing before the write reads
106
- * them assembled and joining them earlier is a copy of the whole frame. The
107
- * join happens once, at the moment of compression, and the compressed buffer
108
- * is what gets written, checksummed, and bounded — so the cap and the
109
- * reference both describe bytes on disk, not bytes of JSON.
110
- */
111
- function compressFrame(parts: readonly Buffer[]): Buffer {
112
- return gzipSync(Buffer.concat(parts));
113
- }
114
-
115
- async function appendFrame(frame: PayloadFrame, parts?: readonly Buffer[]): Promise<PayloadFileReference> {
116
- const directory = payloadDirectory();
117
- await mkdir(directory, { recursive: true });
118
- parts ??= [Buffer.from(JSON.stringify(frame), "utf8")];
119
- const encoded = compressFrame(parts);
120
- if (encoded.byteLength > MAX_FRAME_BYTES) throw new Error("telemetry payload frame exceeds limit");
121
- const file = fileName();
122
- const path = join(directory, file);
123
- const currentSize = await stat(path).then((value) => value.size).catch(() => 0);
124
- const offset = currentSize;
125
- if (offset + FRAME_HEADER_BYTES + encoded.byteLength > maxFileBytes()) {
126
- const rotated = `${Date.now()}-${randomUUID()}.jsonb`;
127
- return appendFrameToPath(frame, join(directory, rotated), basename(rotated), undefined, encoded);
128
- }
129
- return appendFrameToPath(frame, path, file, offset, encoded);
130
- }
131
-
132
- async function appendFrameToPath(
133
- frame: PayloadFrame,
134
- path: string,
135
- knownFile = basename(path),
136
- knownOffset?: number,
137
- knownEncoded?: Buffer,
138
- ): Promise<PayloadFileReference> {
139
- const encoded = knownEncoded ?? compressFrame([Buffer.from(JSON.stringify(frame), "utf8")]);
140
- const offset = knownOffset ?? await stat(path).then((value) => value.size).catch(() => 0);
141
- const header = Buffer.alloc(FRAME_HEADER_BYTES);
142
- header.writeUInt32BE(encoded.byteLength, 0);
143
- const handle = await open(path, "a");
144
- try {
145
- await handle.write(header);
146
- await handle.write(encoded);
147
- } finally {
148
- await handle.close();
149
- }
150
- return {
151
- storage: "jsonl-file",
152
- file: knownFile,
153
- offset,
154
- length: FRAME_HEADER_BYTES + encoded.byteLength,
155
- checksum: checksum(encoded),
156
- version: 1,
157
- };
158
- }
159
-
160
- const FRAME_BODY_KEYS = [
161
- "request_body",
162
- "response_body",
163
- "client_response_body",
164
- "provider_request_body",
165
- "provider_response_body",
166
- ] as const;
167
-
168
- /**
169
- * The pieces of a frame whose bodies were already serialized while measured.
170
- *
171
- * Returned as separate buffers rather than one assembled buffer: the file
172
- * write accepts a list, and assembling them first is a copy of the whole frame
173
- * that nothing reads back.
174
- */
175
- function prebuiltFrameParts(
176
- id: string,
177
- expiresAt: string,
178
- capturedAt: string,
179
- requestId: string | null,
180
- signals: unknown,
181
- bodies: readonly string[],
182
- ): Buffer[] {
183
- const parts = [
184
- Buffer.from(
185
- `{"version":1,"id":${JSON.stringify(id)},"expiresAt":${JSON.stringify(expiresAt)},` +
186
- `"payload":{"request_id":${JSON.stringify(requestId)},"captured_at":${JSON.stringify(capturedAt)},` +
187
- `"expires_at":${JSON.stringify(expiresAt)}`,
188
- "utf8",
189
- ),
190
- ];
191
- for (let index = 0; index < FRAME_BODY_KEYS.length; index += 1) {
192
- parts.push(Buffer.from(`,"${FRAME_BODY_KEYS[index]}":${bodies[index] ?? "null"}`, "utf8"));
193
- }
194
- parts.push(Buffer.from(`,"signals":${JSON.stringify(signals)}}}`, "utf8"));
195
- return parts;
196
- }
197
-
198
- export function writePayloadFrame(
199
- payload: unknown,
200
- expiresAt: Date,
201
- prebuilt?: { readonly requestId: string | null; readonly capturedAt: string; readonly signals: unknown; readonly bodies: readonly string[] },
202
- ): Promise<PayloadFileReference> {
203
- const id = randomUUID();
204
- const expires = expiresAt.toISOString();
205
- const parts = prebuilt
206
- ? prebuiltFrameParts(id, expires, prebuilt.capturedAt, prebuilt.requestId, prebuilt.signals, prebuilt.bodies)
207
- : undefined;
208
- const frame: PayloadFrame = { version: 1, id, expiresAt: expires, payload };
209
- const task = writeTail.then(() => appendFrame(frame, parts));
210
- writeTail = task.then(() => undefined, () => undefined);
211
- return task;
212
- }
213
-
214
- export async function readPayloadFrame(reference: PayloadFileReference): Promise<unknown | undefined> {
215
- // Basename-constrain the reference: a hostile/restored row carrying
216
- // `../../x` must not escape the payload directory (path traversal).
217
- const safeFile = basename(reference.file);
218
- if (safeFile !== reference.file || safeFile.length === 0) return undefined;
219
- const path = join(payloadDirectory(), safeFile);
220
- // Read exactly the frame. Reading the whole file copied every earlier frame
221
- // in the same hour into memory just to return one of them.
222
- const handle = await open(path, "r");
223
- let data: Buffer;
224
- try {
225
- const stat = await handle.stat();
226
- if (reference.offset < 0 || reference.offset + reference.length > stat.size) return undefined;
227
- data = Buffer.alloc(reference.length);
228
- await handle.read(data, 0, reference.length, reference.offset);
229
- } finally {
230
- await handle.close();
231
- }
232
- const frame = data;
233
- const length = frame.readUInt32BE(0);
234
- if (length <= 0 || length + FRAME_HEADER_BYTES !== frame.byteLength) return undefined;
235
- const stored = frame.subarray(FRAME_HEADER_BYTES);
236
- if (checksum(stored) !== reference.checksum) return undefined;
237
- // The row says which encoding it wrote. A compressed row that does not
238
- // decompress is a damaged frame, reported as absent like any other
239
- // unreadable frame, never thrown into the request that asked for it.
240
- const json = reference.storage === "jsonl-file" ? gunzipFrame(stored) : stored;
241
- if (json === undefined) return undefined;
242
- const parsed = JSON.parse(json.toString("utf8")) as PayloadFrame;
243
- return parsed.version === 1 ? parsed.payload : undefined;
244
- }
245
-
246
- /** Decompressed frame body, or undefined when the bytes are not gzip. */
247
- function gunzipFrame(stored: Buffer): Buffer | undefined {
248
- try {
249
- return gunzipSync(stored);
250
- } catch {
251
- return undefined;
252
- }
253
- }
254
-
255
- /**
256
- * What a whole file's parseable frames say about reclaiming it.
257
- *
258
- * `live` — at least one frame is still inside its retention window.
259
- * `none` — every frame was read and every one has expired.
260
- * `unknown` — parsing stopped on a torn tail or a corrupt frame, so the rest
261
- * of the file is unreadable and cannot be assumed empty of live frames.
262
- */
263
- type FrameLiveness = "live" | "none" | "unknown";
264
-
265
- /**
266
- * The JSON of one stored frame, whichever encoding wrote it.
267
- *
268
- * A frame names no encoding of its own — the row does — and this pass reads
269
- * files, not rows. gzip bytes start with the 0x1f 0x8b magic and plain JSON
270
- * starts with `{`, so the encoding is read off the bytes. Anything else is
271
- * corrupt and falls through to the caller's parse, which reports the file
272
- * unreadable rather than empty.
273
- */
274
- function frameJson(stored: Buffer): Buffer {
275
- if (stored.byteLength >= 2 && stored[0] === 0x1f && stored[1] === 0x8b) {
276
- return gunzipSync(stored);
277
- }
278
- return stored;
279
- }
280
-
281
-
282
- function frameLiveness(data: Buffer, before: Date): FrameLiveness {
283
- let offset = 0;
284
- while (offset + FRAME_HEADER_BYTES <= data.byteLength) {
285
- const length = data.readUInt32BE(offset);
286
- const end = offset + FRAME_HEADER_BYTES + length;
287
- // A length that runs past the file is a half-written tail, not a frame.
288
- if (length <= 0 || end > data.byteLength) return "unknown";
289
- let frame: PayloadFrame;
290
- try {
291
- frame = JSON.parse(frameJson(data.subarray(offset + FRAME_HEADER_BYTES, end)).toString("utf8")) as PayloadFrame;
292
- } catch {
293
- return "unknown";
294
- }
295
- if (new Date(frame.expiresAt).getTime() >= before.getTime()) return "live";
296
- offset = end;
297
- }
298
- // Bytes left over that are too short to hold a header are an unreadable tail.
299
- return offset === data.byteLength ? "none" : "unknown";
300
- }
301
-
302
- /**
303
- * Reclaims frame files that no longer hold a live payload.
304
- *
305
- * Frames are append-only and this pass never rewrites a file. That is a
306
- * correctness requirement, not an optimization: a `telemetry_payloads` row
307
- * addresses its body by file + offset + length, so compacting a file in place
308
- * would shift every later frame and leave live rows pointing at the wrong
309
- * bytes — the drawer then reads `undefined` and the payload silently vanishes
310
- * while its row is still current. A file is dropped whole, and only once
311
- * nothing in it is still live.
312
- *
313
- * `unparseableWrittenBefore` bounds the damaged-file case. A region that
314
- * cannot be parsed cannot prove it holds no live frame, so such a file is kept
315
- * until its last write is older than that instant — every frame it could hold
316
- * was written before then and has therefore expired. A caller that cannot
317
- * supply that bound leaves the default, which keeps damaged files rather than
318
- * risk dropping a frame a row still addresses.
319
- *
320
- * One damaged file must not abort the pass. A single corrupt frame used to
321
- * throw straight out of the loop, so every other expired file stayed on disk
322
- * forever and the volume only grew — and because a container restart is the
323
- * usual way a half-written tail appears, the failure looked Docker-specific.
324
- */
325
- export async function prunePayloadFrames(
326
- before: Date,
327
- unparseableWrittenBefore: Date = new Date(0),
328
- ): Promise<number> {
329
- const directory = payloadDirectory();
330
- const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
331
- let deleted = 0;
332
- for (const entry of entries) {
333
- if (!entry.isFile() || !entry.name.endsWith(".jsonb")) continue;
334
- const path = join(directory, entry.name);
335
- try {
336
- if (await fileReclaimable(path, before, unparseableWrittenBefore)) {
337
- await unlink(path);
338
- deleted += 1;
339
- }
340
- } catch {
341
- // Stat, read, or unlink failed on this one file. Leave it for the next
342
- // pass rather than abandoning every remaining file's reclaim.
343
- }
344
- }
345
- return deleted;
346
- }
347
-
348
- /**
349
- * True when the file can be deleted without losing a frame a row still
350
- * addresses. A readable file is reclaimable once every frame has expired; a
351
- * damaged one only once `unparseableWrittenBefore` rules out a live frame in
352
- * the part that could not be parsed.
353
- */
354
- async function fileReclaimable(
355
- path: string,
356
- before: Date,
357
- unparseableWrittenBefore: Date,
358
- ): Promise<boolean> {
359
- const data = await readFile(path);
360
- if (data.byteLength < FRAME_HEADER_BYTES) return true;
361
- const liveness = frameLiveness(data, before);
362
- if (liveness === "live") return false;
363
- if (liveness === "none") return true;
364
- const written = await stat(path).then((value) => value.mtimeMs);
365
- return written <= unparseableWrittenBefore.getTime();
366
- }
1
+ import { mkdir, open, readdir, readFile, stat, unlink } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { gunzipSync, gzipSync } from "node:zlib";
5
+
6
+ export interface PayloadFileReference {
7
+ /**
8
+ * `jsonb-file` is a frame written before compression existed: plain JSON.
9
+ * New frames are `jsonl-file`: the same JSON, gzipped. The row names its
10
+ * encoding, so a reader never guesses and an old row still resolves.
11
+ */
12
+ readonly storage: "jsonb-file" | "jsonl-file";
13
+ readonly file: string;
14
+ readonly offset: number;
15
+ readonly length: number;
16
+ readonly checksum: string;
17
+ readonly version: 1;
18
+ }
19
+
20
+ interface PayloadFrame {
21
+ readonly version: 1;
22
+ readonly id: string;
23
+ readonly expiresAt: string;
24
+ readonly payload: unknown;
25
+ }
26
+
27
+ export function isPayloadFileReference(value: unknown): value is PayloadFileReference {
28
+ if (typeof value !== "object" || value === null) return false;
29
+ const record = value as Record<string, unknown>;
30
+ return (
31
+ (record["storage"] === "jsonb-file" || record["storage"] === "jsonl-file") &&
32
+ typeof record["file"] === "string" &&
33
+ typeof record["offset"] === "number" &&
34
+ typeof record["length"] === "number" &&
35
+ typeof record["checksum"] === "string" &&
36
+ record["version"] === 1
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Builds a frame reference from a `telemetry_payloads` row's typed columns.
42
+ * Returns undefined when any column is out of contract so callers treat a
43
+ * corrupt index row as "no payload" rather than path-traversing or reading
44
+ * the wrong bytes.
45
+ */
46
+ export function payloadReferenceFromRow(row: {
47
+ readonly storage: string;
48
+ readonly file: string;
49
+ readonly offset: number;
50
+ readonly length: number;
51
+ readonly checksum: string;
52
+ readonly version: number;
53
+ }): PayloadFileReference | undefined {
54
+ const candidate = {
55
+ storage: row.storage,
56
+ file: row.file,
57
+ offset: row.offset,
58
+ length: row.length,
59
+ checksum: row.checksum,
60
+ version: row.version,
61
+ };
62
+ return isPayloadFileReference(candidate) ? candidate : undefined;
63
+ }
64
+
65
+ /** Extension for newly written frame files. Old `.jsonb` files are still read. */
66
+ const FRAME_FILE_SUFFIX = ".jsonl";
67
+
68
+ /**
69
+ * True for a frame file, whichever extension it carries.
70
+ *
71
+ * Files written before the format change end `.jsonb`. They still hold live
72
+ * frames whose rows point at them, so a prune that recognised only the new
73
+ * extension would abandon them — and since nothing else removes them, they
74
+ * would sit on disk indefinitely.
75
+ */
76
+ function isFrameFileName(name: string): boolean {
77
+ return name.endsWith(FRAME_FILE_SUFFIX) || name.endsWith(".jsonb");
78
+ }
79
+
80
+ const FRAME_HEADER_BYTES = 4;
81
+ const MAX_FRAME_BYTES = 256 * 1024 * 1024;
82
+ const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
83
+ const DEFAULT_DIRECTORY = "./data/telemetry-payloads";
84
+ let writeTail: Promise<void> = Promise.resolve();
85
+
86
+ /**
87
+ * Single source of truth for the telemetry payload directory: the
88
+ * `CARTETHYIA_TELEMETRY_PAYLOAD_DIR` override when set, otherwise the
89
+ * default `./data/telemetry-payloads`. Every writer and reader must resolve
90
+ * the directory through this function so the two can never drift.
91
+ */
92
+ export function payloadDirectory(): string {
93
+ return process.env.CARTETHYIA_TELEMETRY_PAYLOAD_DIR?.trim() || DEFAULT_DIRECTORY;
94
+ }
95
+
96
+ function maxFileBytes(): number {
97
+ const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_FILE_MAX_BYTES ?? DEFAULT_MAX_FILE_BYTES);
98
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_FILE_BYTES;
99
+ }
100
+
101
+ function checksum(value: Buffer | readonly Buffer[]): string {
102
+ const parts = Array.isArray(value) ? value : [value];
103
+ let hash = 2166136261;
104
+ for (const part of parts) {
105
+ for (let index = 0; index < part.byteLength; index += 1) {
106
+ hash ^= part[index]!;
107
+ hash = Math.imul(hash, 16777619);
108
+ }
109
+ }
110
+ return (hash >>> 0).toString(16).padStart(8, "0");
111
+ }
112
+
113
+ function fileName(now = new Date()): string {
114
+ return `${now.toISOString().slice(0, 13).replaceAll(":", "-")}-${process.pid}${FRAME_FILE_SUFFIX}`;
115
+ }
116
+
117
+ /**
118
+ * Gzipped frame body.
119
+ *
120
+ * The pieces stay separate until here, because nothing before the write reads
121
+ * them assembled and joining them earlier is a copy of the whole frame. The
122
+ * join happens once, at the moment of compression, and the compressed buffer
123
+ * is what gets written, checksummed, and bounded — so the cap and the
124
+ * reference both describe bytes on disk, not bytes of JSON.
125
+ */
126
+ function compressFrame(parts: readonly Buffer[]): Buffer {
127
+ return gzipSync(Buffer.concat(parts));
128
+ }
129
+
130
+ async function appendFrame(frame: PayloadFrame, parts?: readonly Buffer[]): Promise<PayloadFileReference> {
131
+ const directory = payloadDirectory();
132
+ await mkdir(directory, { recursive: true });
133
+ // Encoded as a line here rather than at the write, so the cap, the checksum,
134
+ // and the reference all describe the same bytes that land on disk — the
135
+ // prebuilt-parts path and the plain one cannot disagree about the shape.
136
+ const json = parts === undefined
137
+ ? Buffer.from(JSON.stringify(frame), "utf8")
138
+ : Buffer.concat(parts);
139
+ const compressed = compressFrame([json]);
140
+ if (compressed.byteLength > MAX_FRAME_BYTES) throw new Error("telemetry payload frame exceeds limit");
141
+ const encoded = toLine(compressed);
142
+ const file = fileName();
143
+ const path = join(directory, file);
144
+ const currentSize = await stat(path).then((value) => value.size).catch(() => 0);
145
+ const offset = currentSize;
146
+ if (offset + encoded.byteLength > maxFileBytes()) {
147
+ const rotated = `${Date.now()}-${randomUUID()}${FRAME_FILE_SUFFIX}`;
148
+ return appendFrameToPath(join(directory, rotated), encoded, basename(rotated));
149
+ }
150
+ return appendFrameToPath(path, encoded, file, offset);
151
+ }
152
+
153
+ /**
154
+ * One frame as a stored line: base64 of the gzipped JSON, newline-terminated.
155
+ *
156
+ * The 4-byte binary length header is gone. A header made every frame opaque —
157
+ * you could not `cat` the file or read a frame without first decoding a
158
+ * big-endian length and then gunzipping exactly that many bytes. A newline is
159
+ * the delimiter instead, so the file is a text file of base64 lines and a
160
+ * frame's extent is found by reading to the next `\n`.
161
+ *
162
+ * base64 rather than raw gzip bytes because gzip contains newlines, which
163
+ * would make the delimiter ambiguous. The row still names `jsonl-file`, so no
164
+ * schema change and no migration: readers tell old from new off the bytes.
165
+ */
166
+ function toLine(compressed: Buffer): Buffer {
167
+ return Buffer.from(`${compressed.toString("base64")}\n`, "utf8");
168
+ }
169
+
170
+ async function appendFrameToPath(
171
+ path: string,
172
+ encoded: Buffer,
173
+ knownFile = basename(path),
174
+ knownOffset?: number,
175
+ ): Promise<PayloadFileReference> {
176
+ const offset = knownOffset ?? await stat(path).then((value) => value.size).catch(() => 0);
177
+ const handle = await open(path, "a");
178
+ try {
179
+ await handle.write(encoded);
180
+ } finally {
181
+ await handle.close();
182
+ }
183
+ return {
184
+ storage: "jsonl-file",
185
+ file: knownFile,
186
+ offset,
187
+ length: encoded.byteLength,
188
+ checksum: checksum(encoded),
189
+ version: 1,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * The frame's body keys, in the same order `buildPayloadRecord` serializes
195
+ * them.
196
+ *
197
+ * The record hands the writer one string per body already serialized, so the
198
+ * two orders must agree or every body lands under the wrong key. The order is
199
+ * drawer order with the legacy `response_body` alias last, for the same reason
200
+ * the record accounts it last: it names no panel of its own and must not
201
+ * spend the shared byte budget ahead of the provider stages.
202
+ */
203
+ const FRAME_BODY_KEYS = [
204
+ "request_body",
205
+ "provider_request_body",
206
+ "provider_response_body",
207
+ "client_response_body",
208
+ "response_body",
209
+ ] as const;
210
+
211
+ /**
212
+ * The pieces of a frame whose bodies were already serialized while measured.
213
+ *
214
+ * Returned as separate buffers rather than one assembled buffer: the file
215
+ * write accepts a list, and assembling them first is a copy of the whole frame
216
+ * that nothing reads back.
217
+ */
218
+ function prebuiltFrameParts(
219
+ id: string,
220
+ expiresAt: string,
221
+ capturedAt: string,
222
+ requestId: string | null,
223
+ signals: unknown,
224
+ bodies: readonly string[],
225
+ ): Buffer[] {
226
+ const parts = [
227
+ Buffer.from(
228
+ `{"version":1,"id":${JSON.stringify(id)},"expiresAt":${JSON.stringify(expiresAt)},` +
229
+ `"payload":{"request_id":${JSON.stringify(requestId)},"captured_at":${JSON.stringify(capturedAt)},` +
230
+ `"expires_at":${JSON.stringify(expiresAt)}`,
231
+ "utf8",
232
+ ),
233
+ ];
234
+ for (let index = 0; index < FRAME_BODY_KEYS.length; index += 1) {
235
+ parts.push(Buffer.from(`,"${FRAME_BODY_KEYS[index]}":${bodies[index] ?? "null"}`, "utf8"));
236
+ }
237
+ parts.push(Buffer.from(`,"signals":${JSON.stringify(signals)}}}`, "utf8"));
238
+ return parts;
239
+ }
240
+
241
+ export function writePayloadFrame(
242
+ payload: unknown,
243
+ expiresAt: Date,
244
+ prebuilt?: { readonly requestId: string | null; readonly capturedAt: string; readonly signals: unknown; readonly bodies: readonly string[] },
245
+ ): Promise<PayloadFileReference> {
246
+ const id = randomUUID();
247
+ const expires = expiresAt.toISOString();
248
+ const parts = prebuilt
249
+ ? prebuiltFrameParts(id, expires, prebuilt.capturedAt, prebuilt.requestId, prebuilt.signals, prebuilt.bodies)
250
+ : undefined;
251
+ const frame: PayloadFrame = { version: 1, id, expiresAt: expires, payload };
252
+ const task = writeTail.then(() => appendFrame(frame, parts));
253
+ writeTail = task.then(() => undefined, () => undefined);
254
+ return task;
255
+ }
256
+
257
+ export async function readPayloadFrame(reference: PayloadFileReference): Promise<unknown | undefined> {
258
+ // Basename-constrain the reference: a hostile/restored row carrying
259
+ // `../../x` must not escape the payload directory (path traversal).
260
+ const safeFile = basename(reference.file);
261
+ if (safeFile !== reference.file || safeFile.length === 0) return undefined;
262
+ const path = join(payloadDirectory(), safeFile);
263
+ // Read exactly the frame. Reading the whole file copied every earlier frame
264
+ // in the same hour into memory just to return one of them.
265
+ const handle = await open(path, "r");
266
+ let data: Buffer;
267
+ try {
268
+ const stat = await handle.stat();
269
+ if (reference.offset < 0 || reference.offset + reference.length > stat.size) return undefined;
270
+ data = Buffer.alloc(reference.length);
271
+ await handle.read(data, 0, reference.length, reference.offset);
272
+ } finally {
273
+ await handle.close();
274
+ }
275
+ const frame = data;
276
+ const json = frameJsonFromSlice(frame, reference.storage);
277
+ if (json === undefined) return undefined;
278
+ const parsed = JSON.parse(json.toString("utf8")) as PayloadFrame;
279
+ return parsed.version === 1 ? parsed.payload : undefined;
280
+ }
281
+
282
+ /** Decompressed frame body, or undefined when the bytes are not gzip. */
283
+ function gunzipFrame(stored: Buffer): Buffer | undefined {
284
+ try {
285
+ return gunzipSync(stored);
286
+ } catch {
287
+ return undefined;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * The JSON of one frame read out of a file, in either on-disk shape.
293
+ *
294
+ * Old frames carry a 4-byte big-endian length header, so their first byte is
295
+ * `0x00` for any frame under 16 MB. New frames are a base64 line, which never
296
+ * begins with a null byte. That one byte separates the two without a version
297
+ * field, a schema change, or a migration — the same trick `frameJson` already
298
+ * uses to tell gzip from plain JSON by its magic.
299
+ *
300
+ * Old frames are still read because they exist on disk right now and their
301
+ * rows point at them; they expire on their own within the retention window, so
302
+ * tolerating both is temporary by construction.
303
+ */
304
+ function frameJsonFromSlice(
305
+ slice: Buffer,
306
+ storage: PayloadFileReference["storage"],
307
+ ): Buffer | undefined {
308
+ const hasBinaryHeader = slice.byteLength >= FRAME_HEADER_BYTES && slice[0] === 0x00;
309
+ if (hasBinaryHeader) {
310
+ const length = slice.readUInt32BE(0);
311
+ if (length <= 0 || length + FRAME_HEADER_BYTES !== slice.byteLength) return undefined;
312
+ const stored = slice.subarray(FRAME_HEADER_BYTES);
313
+ return storage === "jsonl-file" ? gunzipFrame(stored) : stored;
314
+ }
315
+ // New shape: a base64 line. The trailing newline is not part of the payload.
316
+ const text = slice.toString("utf8").trim();
317
+ if (text.length === 0) return undefined;
318
+ const gzipped = Buffer.from(text, "base64");
319
+ return gunzipFrame(gzipped);
320
+ }
321
+
322
+ /**
323
+ * What a whole file's parseable frames say about reclaiming it.
324
+ *
325
+ * `live` — at least one frame is still inside its retention window.
326
+ * `none` — every frame was read and every one has expired.
327
+ * `unknown` — parsing stopped on a torn tail or a corrupt frame, so the rest
328
+ * of the file is unreadable and cannot be assumed empty of live frames.
329
+ */
330
+ type FrameLiveness = "live" | "none" | "unknown";
331
+
332
+ function frameLiveness(data: Buffer, before: Date): FrameLiveness {
333
+ let offset = 0;
334
+ // One byte, not FRAME_HEADER_BYTES: a line frame is bounded by its newline
335
+ // and needs no minimum length of its own. Keying the loop to the old header
336
+ // size would stop a new-shape file a few bytes early and report it
337
+ // unreadable, which leaves it on disk forever.
338
+ while (offset < data.byteLength) {
339
+ // New frames end at the next newline; old ones carry a length header. The
340
+ // first byte tells them apart, so a file holding a mix still walks.
341
+ const newline = data.indexOf(0x0a, offset);
342
+ const hasBinaryHeader = data[offset] === 0x00;
343
+
344
+ let end: number;
345
+ let slice: Buffer;
346
+ if (hasBinaryHeader) {
347
+ const length = data.readUInt32BE(offset);
348
+ end = offset + FRAME_HEADER_BYTES + length;
349
+ // A length that runs past the file is a half-written tail, not a frame.
350
+ if (length <= 0 || end > data.byteLength) return "unknown";
351
+ slice = data.subarray(offset, end);
352
+ } else {
353
+ if (newline <= 0) return "unknown";
354
+ end = newline + 1;
355
+ slice = data.subarray(offset, end);
356
+ }
357
+
358
+ const json = frameJsonFromSlice(slice, "jsonl-file");
359
+ if (json === undefined) return "unknown";
360
+ let frame: PayloadFrame;
361
+ try {
362
+ frame = JSON.parse(json.toString("utf8")) as PayloadFrame;
363
+ } catch {
364
+ return "unknown";
365
+ }
366
+ if (new Date(frame.expiresAt).getTime() >= before.getTime()) return "live";
367
+ offset = end;
368
+ }
369
+ // Bytes left over that are too short to hold a header are an unreadable tail.
370
+ return offset === data.byteLength ? "none" : "unknown";
371
+ }
372
+
373
+ /**
374
+ * Reclaims frame files that no longer hold a live payload.
375
+ *
376
+ * Frames are append-only and this pass never rewrites a file. That is a
377
+ * correctness requirement, not an optimization: a `telemetry_payloads` row
378
+ * addresses its body by file + offset + length, so compacting a file in place
379
+ * would shift every later frame and leave live rows pointing at the wrong
380
+ * bytes — the drawer then reads `undefined` and the payload silently vanishes
381
+ * while its row is still current. A file is dropped whole, and only once
382
+ * nothing in it is still live.
383
+ *
384
+ * `unparseableWrittenBefore` bounds the damaged-file case. A region that
385
+ * cannot be parsed cannot prove it holds no live frame, so such a file is kept
386
+ * until its last write is older than that instant — every frame it could hold
387
+ * was written before then and has therefore expired. A caller that cannot
388
+ * supply that bound leaves the default, which keeps damaged files rather than
389
+ * risk dropping a frame a row still addresses.
390
+ *
391
+ * One damaged file must not abort the pass. A single corrupt frame used to
392
+ * throw straight out of the loop, so every other expired file stayed on disk
393
+ * forever and the volume only grew — and because a container restart is the
394
+ * usual way a half-written tail appears, the failure looked Docker-specific.
395
+ */
396
+ export async function prunePayloadFrames(
397
+ before: Date,
398
+ unparseableWrittenBefore: Date = new Date(0),
399
+ ): Promise<number> {
400
+ const directory = payloadDirectory();
401
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
402
+ let deleted = 0;
403
+ for (const entry of entries) {
404
+ if (!entry.isFile() || !isFrameFileName(entry.name)) continue;
405
+ const path = join(directory, entry.name);
406
+ try {
407
+ if (await fileReclaimable(path, before, unparseableWrittenBefore)) {
408
+ await unlink(path);
409
+ deleted += 1;
410
+ }
411
+ } catch {
412
+ // Stat, read, or unlink failed on this one file. Leave it for the next
413
+ // pass rather than abandoning every remaining file's reclaim.
414
+ }
415
+ }
416
+ return deleted;
417
+ }
418
+
419
+ /**
420
+ * True when the file can be deleted without losing a frame a row still
421
+ * addresses. A readable file is reclaimable once every frame has expired; a
422
+ * damaged one only once `unparseableWrittenBefore` rules out a live frame in
423
+ * the part that could not be parsed.
424
+ */
425
+ async function fileReclaimable(
426
+ path: string,
427
+ before: Date,
428
+ unparseableWrittenBefore: Date,
429
+ ): Promise<boolean> {
430
+ const data = await readFile(path);
431
+ // Nothing written yet, so nothing to lose. The old test was "shorter than a
432
+ // binary header", which the line format has no need of; a real frame — old
433
+ // or new — is far longer than that anyway.
434
+ if (data.byteLength === 0) return true;
435
+ const liveness = frameLiveness(data, before);
436
+ if (liveness === "live") return false;
437
+ if (liveness === "none") return true;
438
+ const written = await stat(path).then((value) => value.mtimeMs);
439
+ return written <= unparseableWrittenBefore.getTime();
440
+ }