cartethyia 2.5.4 → 2.5.7

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 (395) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-DE2607Tb.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-DE2607Tb.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-C2PNYWzk.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-C2PNYWzk.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-B-9xnAQN.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-B-9xnAQN.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-xeax9nJt.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-xeax9nJt.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-Df4jjaHJ.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-Df4jjaHJ.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-Dyojsm84.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-Dyojsm84.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-DeWSOEUO.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-DeWSOEUO.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-C_klIZom.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-C_klIZom.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-BYph-InK.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-BYph-InK.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-CvyQCYWJ.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-CvyQCYWJ.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-Cm1JYilI.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-Cm1JYilI.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  60. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  61. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  62. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
  64. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-Bog0p925.js +4 -0
  66. package/dist/dashboard/assets/entry-Bog0p925.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  68. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-js6TJvdr.js → index-CZQIrdoX.js} +2 -2
  72. package/dist/dashboard/assets/index-CZQIrdoX.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
  74. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
  76. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  77. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  78. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
  80. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
  82. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
  86. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  87. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  88. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
  92. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-WUCXorXw.js → select-BPpSsBgL.js} +1 -1
  94. package/dist/dashboard/assets/select-BPpSsBgL.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
  96. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
  98. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
  100. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
  102. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  103. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  104. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  105. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  106. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  109. package/dist/dashboard/index.html +1 -1
  110. package/dist/dashboard/index.html.gz +0 -0
  111. package/dist/dashboard/precompressed-assets.json +54 -54
  112. package/migrations/README.md +52 -52
  113. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  114. package/package.json +95 -95
  115. package/scripts/cli/admin.ts +125 -0
  116. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  117. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
  118. package/scripts/cli/doctor.ts +37 -0
  119. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  120. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  121. package/scripts/cli/instance.ts +224 -0
  122. package/scripts/cli/main.ts +61 -18
  123. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  124. package/scripts/cli/port.ts +100 -0
  125. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  126. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  127. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  128. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  129. package/scripts/cli/tray/index.ts +62 -12
  130. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  131. package/scripts/cli/{command/update.ts → update.ts} +39 -39
  132. package/scripts/cli/version.ts +67 -0
  133. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  134. package/src/boot/app.ts +697 -693
  135. package/src/boot/dependencies.ts +647 -646
  136. package/src/boot/timeout.ts +2 -13
  137. package/src/config/cooldowns.ts +186 -138
  138. package/src/config/network.ts +8 -14
  139. package/src/config/providers.ts +7 -10
  140. package/src/config/runtime.ts +76 -76
  141. package/src/config/security.ts +15 -0
  142. package/src/config/spec.ts +20 -30
  143. package/src/config/telemetry.ts +0 -20
  144. package/src/config/timeouts.ts +0 -6
  145. package/src/console/admin/model-abuse/contracts.ts +28 -6
  146. package/src/console/admin/model-abuse/routes.ts +56 -3
  147. package/src/console/api-keys/contracts.ts +410 -414
  148. package/src/console/api-keys/routes.ts +881 -880
  149. package/src/console/auth/credentials.ts +3 -3
  150. package/src/console/auth/setup-service.ts +2 -2
  151. package/src/console/backup/export-endpoint.ts +6 -2
  152. package/src/console/backup/nine-router.ts +2 -5
  153. package/src/console/backup/service.ts +4 -0
  154. package/src/console/backup/store.ts +192 -29
  155. package/src/console/backup/validate.ts +3 -45
  156. package/src/console/cli-tools/fs-ops.ts +487 -487
  157. package/src/console/console-router-guards.ts +1 -1
  158. package/src/console/data-management/contracts.ts +13 -6
  159. package/src/console/data-management/operations.ts +23 -18
  160. package/src/console/data-management/routes.ts +0 -1
  161. package/src/console/domain-registration.ts +10 -5
  162. package/src/console/observability/live.ts +22 -0
  163. package/src/console/observability/store.ts +39 -16
  164. package/src/console/observability/usage-dimensions.ts +4 -4
  165. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  166. package/src/console/providers/catalog/model-operations.ts +5 -2
  167. package/src/console/providers/catalog/provider-operations.ts +4 -4
  168. package/src/console/providers/catalog/public-model-store.ts +8 -11
  169. package/src/console/providers/catalog/store.ts +1553 -1553
  170. package/src/console/providers/oauth/routes.ts +8 -12
  171. package/src/console/quota/account-quota-tenant.ts +6 -6
  172. package/src/console/quota/quota-view.ts +1 -1
  173. package/src/console/remote-support/contracts.ts +61 -0
  174. package/src/console/remote-support/routes.ts +65 -0
  175. package/src/console/remote-support/service.ts +207 -0
  176. package/src/console/response-compression.ts +0 -3
  177. package/src/console/routing/pools/probe-result.ts +3 -2
  178. package/src/console/routing/pools/probe-verdict.ts +132 -132
  179. package/src/console/routing/pools/store.ts +8 -8
  180. package/src/console/settings/contracts.ts +58 -2
  181. package/src/console/settings/store.ts +30 -3
  182. package/src/console/shared/cursor.ts +8 -8
  183. package/src/console/shared/errors.ts +1 -3
  184. package/src/network/http2-bidi.ts +2 -4
  185. package/src/network/pool/agent.ts +478 -478
  186. package/src/network/pool/selector.ts +5 -4
  187. package/src/network/retry.ts +1 -2
  188. package/src/network/ssrf.ts +5 -4
  189. package/src/observability/event-compaction.ts +80 -78
  190. package/src/observability/event-day-index.ts +13 -15
  191. package/src/observability/event-log.ts +4 -4
  192. package/src/observability/logger.ts +6 -6
  193. package/src/observability/payload-capture.ts +151 -33
  194. package/src/observability/payload-store.ts +397 -440
  195. package/src/observability/redaction.ts +5 -6
  196. package/src/observability/request-notify.ts +43 -0
  197. package/src/observability/telemetry-status.ts +8 -9
  198. package/src/observability/token-speed.ts +5 -4
  199. package/src/persistence/db-mode.ts +7 -7
  200. package/src/persistence/readiness.ts +1 -1
  201. package/src/persistence/schema.ts +1406 -1402
  202. package/src/persistence/shared-cache.ts +1 -2
  203. package/src/persistence/stores/api-key-store.ts +18 -6
  204. package/src/persistence/stores/share-store.ts +4 -3
  205. package/src/persistence/stores/telemetry-store.ts +236 -237
  206. package/src/protocol/canonical-model.ts +11 -3
  207. package/src/protocol/codecs.ts +138 -4
  208. package/src/protocol/gateway-error.ts +6 -4
  209. package/src/protocol/request/chat.ts +21 -1
  210. package/src/protocol/request/codex.ts +10 -1
  211. package/src/protocol/request/messages.ts +4 -5
  212. package/src/protocol/request/responses.ts +9 -1
  213. package/src/protocol/response/chat.ts +34 -4
  214. package/src/protocol/response/codex.ts +7 -4
  215. package/src/protocol/response/dsml.ts +140 -28
  216. package/src/protocol/response/responses.ts +11 -5
  217. package/src/protocol/stream-error-frames.ts +4 -5
  218. package/src/providers/authentication/jwt-validator.ts +9 -10
  219. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  220. package/src/providers/configured-openai-providers.ts +134 -136
  221. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  222. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  223. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  224. package/src/providers/discovery/probe-phases.ts +4 -4
  225. package/src/providers/discovery/probe-wire.ts +6 -13
  226. package/src/providers/discovery/probing-service.ts +1117 -1045
  227. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  228. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  231. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  232. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  233. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  234. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  235. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  236. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  237. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  238. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  239. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  240. package/src/providers/integrations/claude/claude.ts +608 -608
  241. package/src/providers/integrations/cline/cline-quota.ts +132 -132
  242. package/src/providers/integrations/cline/cline.ts +537 -539
  243. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  244. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  245. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  246. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  247. package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
  248. package/src/providers/integrations/cursor/cursor.ts +11 -163
  249. package/src/providers/integrations/factory/factory-models.ts +3 -5
  250. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  251. package/src/providers/integrations/grok/grok.ts +6 -7
  252. package/src/providers/integrations/hermes.ts +14 -18
  253. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  254. package/src/providers/integrations/kiro/kiro.ts +1 -1
  255. package/src/providers/integrations/mistral.ts +6 -6
  256. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  257. package/src/providers/integrations/opencode/provider.ts +8 -8
  258. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  259. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  260. package/src/providers/integrations/qoder/qoder.ts +884 -865
  261. package/src/providers/integrations/shared/connect.ts +6 -6
  262. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  263. package/src/providers/integrations/trae/trae.ts +2 -2
  264. package/src/providers/integrations/zcode/oauth.ts +6 -1
  265. package/src/providers/operations/account-health-service.ts +1528 -1523
  266. package/src/providers/operations/account-reset-service.ts +1 -1
  267. package/src/providers/operations/client-versions.ts +6 -6
  268. package/src/providers/operations/custom-cli-headers.ts +3 -4
  269. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  270. package/src/providers/operations/provider-catalog-service.ts +3 -4
  271. package/src/providers/provider-metadata.ts +4 -8
  272. package/src/providers/provider-registry.ts +6 -7
  273. package/src/providers/provider-usage.ts +1 -1
  274. package/src/security/abuse.ts +15 -10
  275. package/src/security/access-control.ts +144 -146
  276. package/src/security/admission/contracts.ts +11 -0
  277. package/src/security/admission/in-memory-store.ts +4 -3
  278. package/src/security/admission/lease-sweep.ts +6 -6
  279. package/src/security/admission/reasons.ts +5 -1
  280. package/src/security/admission/redis-store.ts +211 -210
  281. package/src/security/admission/service.ts +4 -0
  282. package/src/security/api-key-auth.ts +504 -504
  283. package/src/security/ip-boundary.ts +155 -15
  284. package/src/security/model-abuse.ts +366 -94
  285. package/src/security/outbound-headers.ts +3 -4
  286. package/src/shared/sleep.ts +4 -0
  287. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  288. package/src/transport/dispatch/attempt-loop.ts +531 -530
  289. package/src/transport/dispatch/stream-bridge.ts +1 -3
  290. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  291. package/src/transport/dispatch/streaming/stream.ts +5 -6
  292. package/src/transport/failure-policy.ts +162 -8
  293. package/src/transport/guards/auth-guard.ts +7 -4
  294. package/src/transport/guards/concurrency-guard.ts +0 -5
  295. package/src/transport/guards/error-lifecycle.ts +13 -6
  296. package/src/transport/guards/pipeline.ts +15 -3
  297. package/src/transport/guards/request-context.ts +8 -2
  298. package/src/transport/request/inflight.ts +5 -6
  299. package/src/transport/request/preparer.ts +2 -2
  300. package/src/transport/request/state.ts +12 -8
  301. package/src/transport/routing/admission.ts +4 -0
  302. package/src/transport/routing/route-catalog.ts +8 -1
  303. package/src/transport/routing/route-model.ts +46 -14
  304. package/src/transport/routing/router.ts +21 -14
  305. package/src/transport/surface/completion.ts +2 -3
  306. package/src/transport/surface/dialects.ts +5 -6
  307. package/src/transport/surface/responses/encode.ts +38 -0
  308. package/src/transport/surface/responses/parse.ts +4 -0
  309. package/src/transport/translation/capabilities.ts +2 -3
  310. package/src/transport/translation/quirks.ts +3 -3
  311. package/src/workers/buddy-growth-tasks.ts +5 -10
  312. package/src/workers/daily-checkin.ts +17 -23
  313. package/src/workers/quota-refresh-sweep.ts +26 -26
  314. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  315. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
  316. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  317. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  318. package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
  319. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  320. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  321. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  322. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  323. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  324. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
  325. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  326. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
  327. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  328. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
  329. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  330. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  331. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
  332. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  333. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
  334. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  335. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
  336. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  337. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  338. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  339. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  340. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
  341. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  342. package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
  343. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  344. package/dist/dashboard/assets/app-COlK95oi.css +0 -2
  345. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  346. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  347. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  348. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  349. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  350. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  351. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  352. package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
  353. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  354. package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
  355. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  356. package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
  357. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  358. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
  360. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  361. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  363. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  365. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
  367. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  368. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  369. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  370. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  371. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  372. package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
  373. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  374. package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
  375. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  376. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  377. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  378. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  379. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  380. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  381. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  382. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  383. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  384. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  385. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  386. package/scripts/cli/command/admin.ts +0 -65
  387. package/scripts/cli/command/doctor.ts +0 -257
  388. package/scripts/cli/gateway/drain-token.ts +0 -78
  389. package/scripts/cli/gateway/instance-lock.ts +0 -105
  390. package/scripts/cli/gateway/stop.ts +0 -51
  391. package/scripts/cli/support/bun.ts +0 -27
  392. package/scripts/cli/support/version.ts +0 -37
  393. /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
  394. /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
  395. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,440 +1,397 @@
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
- }
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
+ readonly storage: "jsonl-file";
8
+ readonly file: string;
9
+ readonly offset: number;
10
+ readonly length: number;
11
+ readonly checksum: string;
12
+ readonly version: 1;
13
+ }
14
+
15
+ interface PayloadFrame {
16
+ readonly version: 1;
17
+ readonly id: string;
18
+ readonly expiresAt: string;
19
+ readonly payload: unknown;
20
+ }
21
+
22
+ export function isPayloadFileReference(value: unknown): value is PayloadFileReference {
23
+ if (typeof value !== "object" || value === null) return false;
24
+ const record = value as Record<string, unknown>;
25
+ return (
26
+ record["storage"] === "jsonl-file" &&
27
+ typeof record["file"] === "string" &&
28
+ typeof record["offset"] === "number" &&
29
+ typeof record["length"] === "number" &&
30
+ typeof record["checksum"] === "string" &&
31
+ record["version"] === 1
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Builds a frame reference from a `telemetry_payloads` row's typed columns.
37
+ * Returns undefined when any column is out of contract so callers treat a
38
+ * corrupt index row as "no payload" rather than path-traversing or reading
39
+ * the wrong bytes.
40
+ */
41
+ export function payloadReferenceFromRow(row: {
42
+ readonly storage: string;
43
+ readonly file: string;
44
+ readonly offset: number;
45
+ readonly length: number;
46
+ readonly checksum: string;
47
+ readonly version: number;
48
+ }): PayloadFileReference | undefined {
49
+ const candidate = {
50
+ storage: row.storage,
51
+ file: row.file,
52
+ offset: row.offset,
53
+ length: row.length,
54
+ checksum: row.checksum,
55
+ version: row.version,
56
+ };
57
+ return isPayloadFileReference(candidate) ? candidate : undefined;
58
+ }
59
+
60
+ /** Extension for frame files. */
61
+ const FRAME_FILE_SUFFIX = ".jsonl";
62
+
63
+ function isFrameFileName(name: string): boolean {
64
+ return name.endsWith(FRAME_FILE_SUFFIX);
65
+ }
66
+
67
+ const MAX_FRAME_BYTES = 256 * 1024 * 1024;
68
+ const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
69
+ const DEFAULT_DIRECTORY = "./data/telemetry-payloads";
70
+ let writeTail: Promise<void> = Promise.resolve();
71
+
72
+ /**
73
+ * Single source of truth for the telemetry payload directory: the
74
+ * `CARTETHYIA_TELEMETRY_PAYLOAD_DIR` override when set, otherwise the
75
+ * default `./data/telemetry-payloads`. Every writer and reader must resolve
76
+ * the directory through this function so the two can never drift.
77
+ */
78
+ export function payloadDirectory(): string {
79
+ return process.env.CARTETHYIA_TELEMETRY_PAYLOAD_DIR?.trim() || DEFAULT_DIRECTORY;
80
+ }
81
+
82
+ function maxFileBytes(): number {
83
+ const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_FILE_MAX_BYTES ?? DEFAULT_MAX_FILE_BYTES);
84
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_FILE_BYTES;
85
+ }
86
+
87
+ function checksum(value: Buffer | readonly Buffer[]): string {
88
+ const parts = Array.isArray(value) ? value : [value];
89
+ let hash = 2166136261;
90
+ for (const part of parts) {
91
+ for (let index = 0; index < part.byteLength; index += 1) {
92
+ hash ^= part[index]!;
93
+ hash = Math.imul(hash, 16777619);
94
+ }
95
+ }
96
+ return (hash >>> 0).toString(16).padStart(8, "0");
97
+ }
98
+
99
+ function fileName(now = new Date()): string {
100
+ return `${now.toISOString().slice(0, 13).replaceAll(":", "-")}-${process.pid}${FRAME_FILE_SUFFIX}`;
101
+ }
102
+
103
+ /**
104
+ * Gzipped frame body.
105
+ *
106
+ * The pieces stay separate until here, because nothing before the write reads
107
+ * them assembled and joining them earlier is a copy of the whole frame. The
108
+ * join happens once, at the moment of compression, and the compressed buffer
109
+ * is what gets written, checksummed, and bounded — so the cap and the
110
+ * reference both describe bytes on disk, not bytes of JSON.
111
+ */
112
+ function compressFrame(parts: readonly Buffer[]): Buffer {
113
+ return gzipSync(Buffer.concat(parts));
114
+ }
115
+
116
+ async function appendFrame(frame: PayloadFrame, parts?: readonly Buffer[]): Promise<PayloadFileReference> {
117
+ const directory = payloadDirectory();
118
+ await mkdir(directory, { recursive: true });
119
+ // Encoded as a line here rather than at the write, so the cap, the checksum,
120
+ // and the reference all describe the same bytes that land on disk — the
121
+ // prebuilt-parts path and the plain one cannot disagree about the shape.
122
+ const json = parts === undefined
123
+ ? Buffer.from(JSON.stringify(frame), "utf8")
124
+ : Buffer.concat(parts);
125
+ const compressed = compressFrame([json]);
126
+ if (compressed.byteLength > MAX_FRAME_BYTES) throw new Error("telemetry payload frame exceeds limit");
127
+ const encoded = toLine(compressed);
128
+ const file = fileName();
129
+ const path = join(directory, file);
130
+ const currentSize = await stat(path).then((value) => value.size).catch(() => 0);
131
+ const offset = currentSize;
132
+ if (offset + encoded.byteLength > maxFileBytes()) {
133
+ const rotated = `${Date.now()}-${randomUUID()}${FRAME_FILE_SUFFIX}`;
134
+ return appendFrameToPath(join(directory, rotated), encoded, basename(rotated));
135
+ }
136
+ return appendFrameToPath(path, encoded, file, offset);
137
+ }
138
+
139
+ /**
140
+ * One frame as a stored line: base64 of the gzipped JSON, newline-terminated.
141
+ *
142
+ * A newline is the frame delimiter, so the file is a text file of base64 lines
143
+ * and a frame's extent is found by reading to the next `\n` — a binary length
144
+ * header would make every frame opaque, unreadable without first decoding a
145
+ * big-endian length and then gunzipping exactly that many bytes.
146
+ *
147
+ * base64 rather than raw gzip bytes because gzip contains newlines, which
148
+ * would make the delimiter ambiguous.
149
+ */
150
+ function toLine(compressed: Buffer): Buffer {
151
+ return Buffer.from(`${compressed.toString("base64")}\n`, "utf8");
152
+ }
153
+
154
+ async function appendFrameToPath(
155
+ path: string,
156
+ encoded: Buffer,
157
+ knownFile = basename(path),
158
+ knownOffset?: number,
159
+ ): Promise<PayloadFileReference> {
160
+ const offset = knownOffset ?? await stat(path).then((value) => value.size).catch(() => 0);
161
+ const handle = await open(path, "a");
162
+ try {
163
+ await handle.write(encoded);
164
+ } finally {
165
+ await handle.close();
166
+ }
167
+ return {
168
+ storage: "jsonl-file",
169
+ file: knownFile,
170
+ offset,
171
+ length: encoded.byteLength,
172
+ checksum: checksum(encoded),
173
+ version: 1,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * The frame's body keys, in the same order `buildPayloadRecord` serializes
179
+ * them.
180
+ *
181
+ * The record hands the writer one string per body already serialized, so the
182
+ * two orders must agree or every body lands under the wrong key. The order is
183
+ * drawer order with the legacy `response_body` alias last, for the same reason
184
+ * the record accounts it last: it names no panel of its own and must not
185
+ * spend the shared byte budget ahead of the provider stages.
186
+ *
187
+ * There are five, not four. `buildPayloadRecord` builds one string per surface
188
+ * and `response_body` is the fifth — dropping it here wrote the frame with the
189
+ * first four bodies and silently discarded the last, so a capture whose client
190
+ * response was in the legacy slot came back with that panel empty.
191
+ */
192
+ const FRAME_BODY_KEYS = [
193
+ "request_body",
194
+ "provider_request_body",
195
+ "provider_response_body",
196
+ "client_response_body",
197
+ "response_body",
198
+ ] as const;
199
+
200
+ /**
201
+ * The pieces of a frame whose bodies were already serialized while measured.
202
+ *
203
+ * Returned as separate buffers rather than one assembled buffer: the file
204
+ * write accepts a list, and assembling them first is a copy of the whole frame
205
+ * that nothing reads back.
206
+ */
207
+ function prebuiltFrameParts(
208
+ id: string,
209
+ expiresAt: string,
210
+ capturedAt: string,
211
+ requestId: string | null,
212
+ signals: unknown,
213
+ bodies: readonly string[],
214
+ ): Buffer[] {
215
+ const parts = [
216
+ Buffer.from(
217
+ `{"version":1,"id":${JSON.stringify(id)},"expiresAt":${JSON.stringify(expiresAt)},` +
218
+ `"payload":{"request_id":${JSON.stringify(requestId)},"captured_at":${JSON.stringify(capturedAt)},` +
219
+ `"expires_at":${JSON.stringify(expiresAt)}`,
220
+ "utf8",
221
+ ),
222
+ ];
223
+ for (let index = 0; index < FRAME_BODY_KEYS.length; index += 1) {
224
+ parts.push(Buffer.from(`,"${FRAME_BODY_KEYS[index]}":${bodies[index] ?? "null"}`, "utf8"));
225
+ }
226
+ parts.push(Buffer.from(`,"signals":${JSON.stringify(signals)}}}`, "utf8"));
227
+ return parts;
228
+ }
229
+
230
+ export function writePayloadFrame(
231
+ payload: unknown,
232
+ expiresAt: Date,
233
+ prebuilt?: { readonly requestId: string | null; readonly capturedAt: string; readonly signals: unknown; readonly bodies: readonly string[] },
234
+ ): Promise<PayloadFileReference> {
235
+ const id = randomUUID();
236
+ const expires = expiresAt.toISOString();
237
+ const parts = prebuilt
238
+ ? prebuiltFrameParts(id, expires, prebuilt.capturedAt, prebuilt.requestId, prebuilt.signals, prebuilt.bodies)
239
+ : undefined;
240
+ const frame: PayloadFrame = { version: 1, id, expiresAt: expires, payload };
241
+ const task = writeTail.then(() => appendFrame(frame, parts));
242
+ writeTail = task.then(() => undefined, () => undefined);
243
+ return task;
244
+ }
245
+
246
+ export async function readPayloadFrame(reference: PayloadFileReference): Promise<unknown | undefined> {
247
+ // Basename-constrain the reference: a hostile/restored row carrying
248
+ // `../../x` must not escape the payload directory (path traversal).
249
+ const safeFile = basename(reference.file);
250
+ if (safeFile !== reference.file || safeFile.length === 0) return undefined;
251
+ const path = join(payloadDirectory(), safeFile);
252
+ // Read exactly the frame. Reading the whole file copied every earlier frame
253
+ // in the same hour into memory just to return one of them.
254
+ const handle = await open(path, "r");
255
+ let data: Buffer;
256
+ try {
257
+ const stat = await handle.stat();
258
+ if (reference.offset < 0 || reference.offset + reference.length > stat.size) return undefined;
259
+ data = Buffer.alloc(reference.length);
260
+ await handle.read(data, 0, reference.length, reference.offset);
261
+ } finally {
262
+ await handle.close();
263
+ }
264
+ const frame = data;
265
+ const json = frameJsonFromSlice(frame);
266
+ if (json === undefined) return undefined;
267
+ const parsed = JSON.parse(json.toString("utf8")) as PayloadFrame;
268
+ return parsed.version === 1 ? parsed.payload : undefined;
269
+ }
270
+
271
+ /** Decompressed frame body, or undefined when the bytes are not gzip. */
272
+ function gunzipFrame(stored: Buffer): Buffer | undefined {
273
+ try {
274
+ return gunzipSync(stored);
275
+ } catch {
276
+ return undefined;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * The JSON of one frame read out of a file.
282
+ *
283
+ * A frame is a base64 line — base64 because gzip contains newlines, which would
284
+ * make the newline delimiter ambiguous. The bytes are gunzipped only if they
285
+ * are gzip; `gunzipFrame` returning `undefined` is what marks a slice as not a
286
+ * frame.
287
+ */
288
+ function frameJsonFromSlice(
289
+ slice: Buffer,
290
+ ): Buffer | undefined {
291
+ // Frames are a base64 line. The trailing newline is not part of the payload.
292
+ const text = slice.toString("utf8").trim();
293
+ if (text.length === 0) return undefined;
294
+ const gzipped = Buffer.from(text, "base64");
295
+ return gunzipFrame(gzipped);
296
+ }
297
+
298
+ /**
299
+ * What a whole file's parseable frames say about reclaiming it.
300
+ *
301
+ * `live` — at least one frame is still inside its retention window.
302
+ * `none` — every frame was read and every one has expired.
303
+ * `unknown` — parsing stopped on a torn tail or a corrupt frame, so the rest
304
+ * of the file is unreadable and cannot be assumed empty of live frames.
305
+ */
306
+ type FrameLiveness = "live" | "none" | "unknown";
307
+
308
+ function frameLiveness(data: Buffer, before: Date): FrameLiveness {
309
+ let offset = 0;
310
+ while (offset < data.byteLength) {
311
+ const newline = data.indexOf(0x0a, offset);
312
+ if (newline <= 0) return "unknown";
313
+ const end = newline + 1;
314
+ const slice = data.subarray(offset, end);
315
+ const json = frameJsonFromSlice(slice);
316
+ if (json === undefined) return "unknown";
317
+ let frame: PayloadFrame;
318
+ try {
319
+ frame = JSON.parse(json.toString("utf8")) as PayloadFrame;
320
+ } catch {
321
+ return "unknown";
322
+ }
323
+ if (new Date(frame.expiresAt).getTime() >= before.getTime()) return "live";
324
+ offset = end;
325
+ }
326
+ // Bytes left over that are too short to hold a frame are an unreadable tail.
327
+ return offset === data.byteLength ? "none" : "unknown";
328
+ }
329
+
330
+ /**
331
+ * Reclaims frame files that no longer hold a live payload.
332
+ *
333
+ * Frames are append-only and this pass never rewrites a file. That is a
334
+ * correctness requirement, not an optimization: a `telemetry_payloads` row
335
+ * addresses its body by file + offset + length, so compacting a file in place
336
+ * would shift every later frame and leave live rows pointing at the wrong
337
+ * bytes — the drawer then reads `undefined` and the payload silently vanishes
338
+ * while its row is still current. A file is dropped whole, and only once
339
+ * nothing in it is still live.
340
+ *
341
+ * `unparseableWrittenBefore` bounds the damaged-file case. A region that
342
+ * cannot be parsed cannot prove it holds no live frame, so such a file is kept
343
+ * until its last write is older than that instant — every frame it could hold
344
+ * was written before then and has therefore expired. A caller that cannot
345
+ * supply that bound leaves the default, which keeps damaged files rather than
346
+ * risk dropping a frame a row still addresses.
347
+ *
348
+ * One damaged file must not abort the pass. If a single corrupt frame threw
349
+ * straight out of the loop, every other expired file would stay on disk
350
+ * forever and the volume would only grow — and because a container restart is
351
+ * the usual way a half-written tail appears, the failure would look
352
+ * Docker-specific.
353
+ */
354
+ export async function prunePayloadFrames(
355
+ before: Date,
356
+ unparseableWrittenBefore: Date = new Date(0),
357
+ ): Promise<number> {
358
+ const directory = payloadDirectory();
359
+ const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
360
+ let deleted = 0;
361
+ for (const entry of entries) {
362
+ if (!entry.isFile() || !isFrameFileName(entry.name)) continue;
363
+ const path = join(directory, entry.name);
364
+ try {
365
+ if (await fileReclaimable(path, before, unparseableWrittenBefore)) {
366
+ await unlink(path);
367
+ deleted += 1;
368
+ }
369
+ } catch {
370
+ // Stat, read, or unlink failed on this one file. Leave it for the next
371
+ // pass rather than abandoning every remaining file's reclaim.
372
+ }
373
+ }
374
+ return deleted;
375
+ }
376
+
377
+ /**
378
+ * True when the file can be deleted without losing a frame a row still
379
+ * addresses. A readable file is reclaimable once every frame has expired; a
380
+ * damaged one only once `unparseableWrittenBefore` rules out a live frame in
381
+ * the part that could not be parsed.
382
+ */
383
+ async function fileReclaimable(
384
+ path: string,
385
+ before: Date,
386
+ unparseableWrittenBefore: Date,
387
+ ): Promise<boolean> {
388
+ const data = await readFile(path);
389
+ // Nothing written yet, so nothing to lose. A real frame is far longer than
390
+ // any header this format could carry.
391
+ if (data.byteLength === 0) return true;
392
+ const liveness = frameLiveness(data, before);
393
+ if (liveness === "live") return false;
394
+ if (liveness === "none") return true;
395
+ const written = await stat(path).then((value) => value.mtimeMs);
396
+ return written <= unparseableWrittenBefore.getTime();
397
+ }