cartethyia 2.5.9 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (386) hide show
  1. package/.env.example +15 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
  38. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  39. package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
  40. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  41. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  42. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  43. package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
  44. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  46. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  48. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
  50. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
  52. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
  54. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
  60. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
  62. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  66. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  68. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
  72. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
  74. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
  76. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  77. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
  78. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
  80. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  82. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  86. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  92. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
  94. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  96. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
  98. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
  100. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
  102. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  105. package/dist/dashboard/index.html +1 -1
  106. package/dist/dashboard/index.html.gz +0 -0
  107. package/dist/dashboard/precompressed-assets.json +51 -51
  108. package/migrations/README.md +52 -52
  109. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  110. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  111. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  112. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  113. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  114. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  115. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  116. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  117. package/package.json +96 -97
  118. package/scripts/cli/admin.ts +126 -125
  119. package/scripts/cli/autostart.ts +237 -237
  120. package/scripts/cli/background.ts +120 -110
  121. package/scripts/cli/bin/carte.cjs +8 -8
  122. package/scripts/cli/bin/cartethyia.cjs +167 -167
  123. package/scripts/cli/console-menu.ts +308 -275
  124. package/scripts/cli/controller.ts +145 -0
  125. package/scripts/cli/doctor.ts +1 -1
  126. package/scripts/cli/env.ts +99 -99
  127. package/scripts/cli/gateway.ts +264 -261
  128. package/scripts/{health.ts → cli/health.ts} +4 -4
  129. package/scripts/cli/instance.ts +234 -224
  130. package/scripts/cli/main.ts +188 -164
  131. package/scripts/cli/platform.ts +64 -64
  132. package/scripts/cli/prompt.ts +275 -107
  133. package/scripts/cli/services.ts +88 -88
  134. package/scripts/cli/setup.ts +445 -445
  135. package/scripts/cli/start.ts +85 -82
  136. package/scripts/cli/startup-signal.ts +23 -23
  137. package/scripts/cli/telemetry.ts +70 -0
  138. package/scripts/cli/tray/index.ts +263 -263
  139. package/scripts/cli/tray/windows.ps1 +107 -107
  140. package/scripts/cli/tray.ts +192 -208
  141. package/scripts/cli/update.ts +39 -39
  142. package/scripts/cli/version.ts +67 -67
  143. package/scripts/cli/wordmark.ts +58 -58
  144. package/src/boot/app.ts +16 -1
  145. package/src/boot/dependencies.ts +128 -25
  146. package/src/config/package-paths.ts +24 -0
  147. package/src/config/resources.ts +137 -0
  148. package/src/config/runtime.ts +133 -76
  149. package/src/config/spec.ts +30 -29
  150. package/src/config/telemetry.ts +0 -25
  151. package/src/console/api-keys/bucket-spend.ts +14 -17
  152. package/src/console/api-keys/contracts.ts +410 -410
  153. package/src/console/api-keys/routes.ts +12 -0
  154. package/src/console/audit/store.ts +2 -3
  155. package/src/console/auth/setup-service.ts +8 -1
  156. package/src/console/backup/routes.ts +7 -0
  157. package/src/console/backup/service.ts +463 -463
  158. package/src/console/backup/store.ts +16 -11
  159. package/src/console/backup/validate.ts +1 -4
  160. package/src/console/cli-tools/fs-ops.ts +18 -2
  161. package/src/console/data-management/contracts.ts +1 -8
  162. package/src/console/data-management/operations.ts +61 -48
  163. package/src/console/data-management/routes.ts +4 -2
  164. package/src/console/domain-registration.ts +25 -3
  165. package/src/console/inferhub/client.ts +139 -0
  166. package/src/console/inferhub/key-store.ts +129 -0
  167. package/src/console/inferhub/normalize.ts +347 -0
  168. package/src/console/inferhub/routes.ts +411 -0
  169. package/src/console/inferhub/store.ts +109 -0
  170. package/src/console/inferhub/types.ts +183 -0
  171. package/src/console/observability/contracts.ts +2 -41
  172. package/src/console/observability/store.ts +176 -371
  173. package/src/console/providers/catalog/public-model-store.ts +0 -0
  174. package/src/console/providers/catalog/store.ts +23 -36
  175. package/src/console/quota/account-quota-shared.ts +52 -52
  176. package/src/console/quota/account-quota-tenant.ts +16 -24
  177. package/src/console/quota/refresh.ts +441 -441
  178. package/src/console/remote-support/service.ts +270 -114
  179. package/src/console/routing/pools/import-parse.ts +206 -206
  180. package/src/console/routing/pools/operations.ts +543 -543
  181. package/src/console/routing/pools/probe-verdict.ts +132 -132
  182. package/src/console/routing/pools/routes.ts +181 -181
  183. package/src/console/routing/pools/store.ts +1 -1
  184. package/src/console/settings/contracts.ts +42 -40
  185. package/src/console/settings/store.ts +25 -25
  186. package/src/console/share/share-stats.ts +22 -4
  187. package/src/console/share/share-usage.ts +30 -4
  188. package/src/console/shared/cursor.ts +15 -0
  189. package/src/console/updates/repository-updates.ts +40 -39
  190. package/src/console/updates/routes.ts +12 -0
  191. package/src/main.ts +13 -5
  192. package/src/network/pool/resolver.ts +1104 -1104
  193. package/src/observability/client-name.ts +22 -0
  194. package/src/observability/event-log.ts +5 -29
  195. package/src/observability/event-query.ts +690 -0
  196. package/src/observability/log-ring.ts +26 -2
  197. package/src/observability/log-sanitize.ts +118 -0
  198. package/src/observability/logger.ts +17 -13
  199. package/src/observability/metrics.ts +35 -0
  200. package/src/observability/payload-capture.ts +114 -41
  201. package/src/observability/payload-store.ts +249 -24
  202. package/src/observability/raw-event-writer.ts +118 -0
  203. package/src/observability/runtime-metrics.ts +71 -1
  204. package/src/observability/telemetry-buffer.ts +100 -17
  205. package/src/observability/telemetry-reconciliation.ts +417 -0
  206. package/src/persistence/boot.ts +20 -9
  207. package/src/persistence/db-pg.ts +13 -0
  208. package/src/persistence/db-sqlite.ts +569 -497
  209. package/src/persistence/dialect.ts +3 -2
  210. package/src/persistence/migrate.ts +181 -181
  211. package/src/persistence/schema.ts +1496 -1406
  212. package/src/persistence/shared-cache.ts +5 -1
  213. package/src/persistence/stores/api-key-store.ts +581 -581
  214. package/src/persistence/stores/telemetry-store.ts +124 -63
  215. package/src/protocol/reasoning-text.ts +30 -0
  216. package/src/protocol/registry.ts +2 -0
  217. package/src/protocol/response/chat.ts +33 -7
  218. package/src/protocol/response/codex.ts +14 -2
  219. package/src/protocol/response/messages.ts +5 -2
  220. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  221. package/src/providers/bundled-discovery-providers.ts +122 -122
  222. package/src/providers/default-registry.ts +14 -0
  223. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  224. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  225. package/src/providers/discovery/probe-phases.ts +43 -7
  226. package/src/providers/discovery/probing-service.ts +70 -32
  227. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  228. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  231. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  232. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  233. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  234. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  235. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  236. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  237. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  238. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  239. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  240. package/src/providers/integrations/claude/claude.ts +3 -4
  241. package/src/providers/integrations/cline/cline.ts +94 -8
  242. package/src/providers/integrations/codex/codex.ts +61 -37
  243. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  244. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  245. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  246. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  247. package/src/providers/integrations/cursor/cursor.ts +29 -17
  248. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  249. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  250. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  251. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  252. package/src/providers/integrations/github/github.ts +3 -1
  253. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  254. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  255. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  256. package/src/providers/integrations/hermes.ts +7 -1
  257. package/src/providers/integrations/inferhub.ts +201 -73
  258. package/src/providers/integrations/kilo/kilo.ts +152 -152
  259. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  260. package/src/providers/integrations/opencode/provider.ts +2 -0
  261. package/src/providers/integrations/openrouter/provider.ts +129 -129
  262. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  263. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  264. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  265. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  266. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  267. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  268. package/src/providers/integrations/trae/trae.ts +22 -24
  269. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  270. package/src/providers/integrations/zcode/oauth.ts +1 -1
  271. package/src/providers/operations/account-health-service.ts +605 -279
  272. package/src/providers/provider-metadata.ts +12 -1
  273. package/src/providers/provider-model-definition.ts +373 -373
  274. package/src/providers/provider-probe.ts +3 -0
  275. package/src/providers/quota/quota-contracts.ts +3 -7
  276. package/src/providers/search/search-provider.ts +2 -6
  277. package/src/security/admission/contracts.ts +10 -0
  278. package/src/security/admission/in-memory-store.ts +89 -14
  279. package/src/security/admission/lua.ts +191 -182
  280. package/src/security/admission/redis-store.ts +30 -0
  281. package/src/security/admission/service.ts +18 -0
  282. package/src/security/api-key-auth-cache.ts +96 -0
  283. package/src/security/api-key-auth.ts +11 -100
  284. package/src/security/client-router-fingerprint.ts +3 -7
  285. package/src/security/ip-boundary.ts +7 -3
  286. package/src/shared/coerce.ts +67 -0
  287. package/src/shared/hash.ts +6 -0
  288. package/src/shared/time.ts +7 -0
  289. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  290. package/src/transport/dispatch/attempt-loop.ts +103 -81
  291. package/src/transport/dispatch/capture-policy.ts +78 -0
  292. package/src/transport/dispatch/proxy-request.ts +4 -2
  293. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  294. package/src/transport/dispatch/streaming/stream.ts +19 -2
  295. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  296. package/src/transport/failure-policy.ts +27 -0
  297. package/src/transport/guards/abuse-guard.ts +2 -2
  298. package/src/transport/guards/auth-guard.ts +17 -0
  299. package/src/transport/guards/body-policy.ts +3 -2
  300. package/src/transport/guards/concurrency-guard.ts +89 -13
  301. package/src/transport/guards/early-rejection-capture.ts +43 -3
  302. package/src/transport/guards/error-lifecycle.ts +10 -2
  303. package/src/transport/guards/guard-errors.ts +23 -0
  304. package/src/transport/guards/headroom-guard.ts +41 -7
  305. package/src/transport/guards/pipeline.ts +24 -0
  306. package/src/transport/guards/request-context.ts +2 -1
  307. package/src/transport/routing/admission.ts +51 -9
  308. package/src/transport/routing/bounded-lru.ts +49 -0
  309. package/src/transport/routing/candidate-index.ts +88 -0
  310. package/src/transport/routing/reservations.ts +121 -7
  311. package/src/transport/routing/round-robin.ts +10 -22
  312. package/src/transport/routing/route-catalog.ts +26 -13
  313. package/src/transport/routing/route-model.ts +22 -2
  314. package/src/transport/routing/router.ts +53 -31
  315. package/src/transport/surface/chat/encode.ts +2 -3
  316. package/src/transport/surface/chat/parse.ts +2 -1
  317. package/src/transport/surface/dialects.ts +11 -3
  318. package/src/transport/surface/messages/encode.ts +2 -1
  319. package/src/transport/surface/messages/parse.ts +3 -1
  320. package/src/transport/surface/messages/stream.ts +2 -6
  321. package/src/workers/maintenance-lease.ts +200 -0
  322. package/src/workers/scheduler.ts +101 -1
  323. package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
  324. package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
  325. package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
  326. package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
  327. package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
  328. package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
  329. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
  330. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
  331. package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
  332. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  333. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
  334. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
  335. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
  336. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
  337. package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
  338. package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
  339. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
  340. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
  341. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
  342. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
  343. package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
  344. package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
  345. package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
  346. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  347. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  348. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  349. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  350. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  351. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  352. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  353. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  354. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  355. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  356. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  357. package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
  358. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
  360. package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
  361. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  363. package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  365. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  367. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  368. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  369. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  370. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  371. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  372. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  373. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  374. package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
  375. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  376. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  377. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  378. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  379. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  380. package/scripts/build/aot.ts +0 -143
  381. package/scripts/build/build.ts +0 -24
  382. package/scripts/build/compile.ts +0 -88
  383. package/scripts/build/restart.ts +0 -120
  384. package/scripts/build/start.ts +0 -25
  385. package/src/observability/event-compaction.ts +0 -317
  386. package/src/observability/event-day-index.ts +0 -440
@@ -4,6 +4,7 @@
4
4
  * snapshot and then subscribes over SSE (`console/observability/logs.ts`); nothing
5
5
  * here touches the database — restarts clear the tail by design.
6
6
  */
7
+ import { sanitizeLogValue } from './log-sanitize';
7
8
 
8
9
  export const CONSOLE_LOG_LEVELS = ["debug", "info", "warn", "error"] as const;
9
10
 
@@ -53,6 +54,19 @@ export interface ConsoleLogMetadata {
53
54
  readonly details?: Record<string, unknown>;
54
55
  }
55
56
 
57
+ /**
58
+ * Bounds one structured `details` object.
59
+ *
60
+ * Returns a fresh record rather than mutating, so a caller that keeps its own
61
+ * reference to the object it passed does not find it rewritten.
62
+ */
63
+ function sanitizeLogDetails(details: Record<string, unknown>): Record<string, unknown> {
64
+ const sanitized = sanitizeLogValue(details);
65
+ return typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)
66
+ ? (sanitized as Record<string, unknown>)
67
+ : {};
68
+ }
69
+
56
70
  export function pushStructuredConsoleLog(
57
71
  level: ConsoleLogLevel,
58
72
  msg: string,
@@ -67,8 +81,12 @@ export type ConsoleLogEvent =
67
81
  | { readonly type: "line"; readonly line: ConsoleLogLine }
68
82
  | { readonly type: "clear" };
69
83
 
70
- const CAPACITY = 500;
71
- const MAX_MSG = 2_000;
84
+ /** Lines retained. Exported so the console can state the tail's depth. */
85
+ export const CONSOLE_LOG_CAPACITY = 500;
86
+ /** Code units kept per rendered message. Exported for the same reason. */
87
+ export const CONSOLE_LOG_MAX_MESSAGE_CODE_UNITS = 2_000;
88
+ const CAPACITY = CONSOLE_LOG_CAPACITY;
89
+ const MAX_MSG = CONSOLE_LOG_MAX_MESSAGE_CODE_UNITS;
72
90
 
73
91
  const lines: ConsoleLogLine[] = [];
74
92
  // Start index of the live window: eviction advances the pointer instead of
@@ -82,6 +100,12 @@ function appendLine(line: Omit<ConsoleLogLine, "ts" | "msg"> & { readonly msg: s
82
100
  ts: new Date().toISOString(),
83
101
  ...line,
84
102
  msg: line.msg.length > MAX_MSG ? `${line.msg.slice(0, MAX_MSG)}…` : line.msg,
103
+ // `details` is caller-supplied and never routed through the logger, so it
104
+ // is the one field that could arrive unbounded: a structured line carrying
105
+ // a whole response body would make this 500-line ring hold a fraction of
106
+ // one event. Bounded by the same sanitizer the logger uses, so the two
107
+ // paths cannot disagree about what "bounded" means.
108
+ ...(line.details === undefined ? {} : { details: sanitizeLogDetails(line.details) }),
85
109
  };
86
110
  lines.push(stored);
87
111
  if (lines.length - ringStart > CAPACITY) ringStart += 1;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Bounded structural sanitization for log payloads.
3
+ *
4
+ * Its own module because both directions need it: `logger.ts` bounds what it
5
+ * hands Pino and the console ring, and `log-ring.ts` bounds structured
6
+ * `details` pushed by callers that never go through the logger. Putting it in
7
+ * either one would be an import cycle.
8
+ *
9
+ * Depth alone does not bound a log payload. A single multi-megabyte string
10
+ * field passes any depth cap, and so does an array of ten thousand short
11
+ * items — either is copied in full on the request path, and a log call that
12
+ * allocates megabytes to describe an error makes the error worse. Every
13
+ * dimension gets a cap, and every marker is the same stable `[truncated]` so a
14
+ * bounded line is recognizable as bounded rather than looking like content.
15
+ */
16
+
17
+ /** Nesting levels kept. */
18
+ export const LOG_MAX_DEPTH = 5;
19
+ /** Array elements kept per array. */
20
+ export const LOG_MAX_ARRAY_ITEMS = 32;
21
+ /** Object keys kept per object. */
22
+ export const LOG_MAX_KEYS = 64;
23
+ /** Code units kept per string. */
24
+ export const LOG_MAX_STRING_CODE_UNITS = 4_096;
25
+ /**
26
+ * Total nodes visited per copy.
27
+ *
28
+ * The whole-copy cap, and the one that actually stops a wide graph: depth and
29
+ * the per-container limits bound each dimension, but a million sibling keys at
30
+ * depth 2 passes all of them.
31
+ */
32
+ export const LOG_MAX_NODES = 256;
33
+
34
+ /** Code units kept when rendering one console-ring line. */
35
+ export const LOG_RING_MAX_CODE_UNITS = 8_192;
36
+
37
+ /** Marker appended wherever content was dropped. */
38
+ export const LOG_TRUNCATED_MARKER = "[truncated]";
39
+ export const LOG_CIRCULAR_MARKER = "[circular]";
40
+
41
+ function isHighSurrogate(code: number): boolean {
42
+ return code >= 0xd800 && code <= 0xdbff;
43
+ }
44
+
45
+ /**
46
+ * Truncates without splitting a surrogate pair.
47
+ *
48
+ * Cutting between a high and low surrogate leaves a lone half, which renders
49
+ * as U+FFFD — a log line that looks like the data was corrupt rather than
50
+ * merely cut short.
51
+ */
52
+ export function truncateLogString(value: string, maxCodeUnits: number): string {
53
+ if (value.length <= maxCodeUnits) return value;
54
+ const end =
55
+ maxCodeUnits > 0 && isHighSurrogate(value.charCodeAt(maxCodeUnits - 1))
56
+ ? maxCodeUnits - 1
57
+ : maxCodeUnits;
58
+ return `${value.slice(0, end)}${LOG_TRUNCATED_MARKER}`;
59
+ }
60
+
61
+ /**
62
+ * A circular-safe, bounded copy.
63
+ *
64
+ * Cycle-guarded because the canonical redactor applied downstream recurses
65
+ * without one: a circular log arg would overflow the stack before redaction
66
+ * ran. Errors collapse to name+message — a stack is far too long for a tail
67
+ * line, and the message already carries the cause.
68
+ *
69
+ * The node budget is a mutable counter object rather than a returned value:
70
+ * a recursive walk that threaded remaining budget through every branch would
71
+ * silently unbound any subtree that forgot to pass it along.
72
+ */
73
+ export function sanitizeLogValue(value: unknown): unknown {
74
+ return decycle(value, { remaining: LOG_MAX_NODES }, new WeakSet(), 0);
75
+ }
76
+
77
+ function decycle(
78
+ value: unknown,
79
+ budget: { remaining: number },
80
+ seen: WeakSet<object>,
81
+ depth: number,
82
+ ): unknown {
83
+ if (typeof value === "string") return truncateLogString(value, LOG_MAX_STRING_CODE_UNITS);
84
+ if (value === null || typeof value !== "object") return value;
85
+ if (seen.has(value)) return LOG_CIRCULAR_MARKER;
86
+ if (depth > LOG_MAX_DEPTH) return LOG_TRUNCATED_MARKER;
87
+ if (budget.remaining <= 0) return LOG_TRUNCATED_MARKER;
88
+ budget.remaining -= 1;
89
+ if (value instanceof Error) {
90
+ return {
91
+ name: truncateLogString(value.name, LOG_MAX_STRING_CODE_UNITS),
92
+ message: truncateLogString(value.message, LOG_MAX_STRING_CODE_UNITS),
93
+ };
94
+ }
95
+ seen.add(value);
96
+ if (Array.isArray(value)) {
97
+ const kept = value
98
+ .slice(0, LOG_MAX_ARRAY_ITEMS)
99
+ .map((item) => decycle(item, budget, seen, depth + 1));
100
+ // The count is the point of a log line: "200 items, first 32 shown" reads
101
+ // very differently from "32 items".
102
+ if (value.length > LOG_MAX_ARRAY_ITEMS) {
103
+ kept.push(`[truncated:${value.length - LOG_MAX_ARRAY_ITEMS} more]`);
104
+ }
105
+ return kept;
106
+ }
107
+ const out: Record<string, unknown> = {};
108
+ const entries = Object.entries(value);
109
+ for (let index = 0; index < entries.length; index += 1) {
110
+ const [key, entry] = entries[index]!;
111
+ if (index >= LOG_MAX_KEYS) {
112
+ out[LOG_TRUNCATED_MARKER] = entries.length - LOG_MAX_KEYS;
113
+ break;
114
+ }
115
+ out[truncateLogString(key, LOG_MAX_STRING_CODE_UNITS)] = decycle(entry, budget, seen, depth + 1);
116
+ }
117
+ return out;
118
+ }
@@ -1,5 +1,6 @@
1
1
  import pino from 'pino';
2
2
  import { pushConsoleLog, type ConsoleLogLevel } from './log-ring';
3
+ import { LOG_RING_MAX_CODE_UNITS, sanitizeLogValue, truncateLogString } from './log-sanitize';
3
4
 
4
5
  /**
5
6
  * Structured logger using Pino for production-ready logging.
@@ -58,19 +59,13 @@ const logger = pino({
58
59
  * without a cycle guard, so a circular log arg would overflow the stack
59
60
  * before redaction even runs. Errors collapse to name+message - stacks are
60
61
  * too long for a tail line and the message already carries the cause.
62
+ *
63
+ * Bounded in every dimension, not just depth: see the constants above. The
64
+ * node budget is carried by a mutable counter object rather than a return
65
+ * value, because a recursive walk that returned remaining budget would have to
66
+ * thread it through every branch and a missed one would silently unbound a
67
+ * subtree.
61
68
  */
62
- function decycle(value: unknown, seen: WeakSet<object> = new WeakSet(), depth = 0): unknown {
63
- if (value === null || typeof value !== "object") return value;
64
- if (seen.has(value)) return "[circular]";
65
- if (depth > 5) return "[truncated]";
66
- if (value instanceof Error) return { name: value.name, message: value.message };
67
- seen.add(value);
68
- if (Array.isArray(value)) return value.map((item) => decycle(item, seen, depth + 1));
69
- const out: Record<string, unknown> = {};
70
- for (const [key, entry] of Object.entries(value)) out[key] = decycle(entry, seen, depth + 1);
71
- return out;
72
- }
73
-
74
69
  /**
75
70
  * Flatten a log call into one ring line. Args keep their real values: a log
76
71
  * line whose credential was rewritten into `***REDACTED***` is
@@ -79,7 +74,7 @@ function decycle(value: unknown, seen: WeakSet<object> = new WeakSet(), depth =
79
74
  */
80
75
  function safeLogValue(value: unknown): unknown {
81
76
  try {
82
- return decycle(value);
77
+ return sanitizeLogValue(value);
83
78
  } catch {
84
79
  return "[unserializable args]";
85
80
  }
@@ -98,6 +93,10 @@ function formatRingMessage(msg: string, args: readonly unknown[]): string {
98
93
  } catch {
99
94
  rendered = "[unserializable args]";
100
95
  }
96
+ // Bounded on the way out too: the copy above caps each field, but a few
97
+ // hundred capped fields still render to a line long enough that the ring —
98
+ // a fixed 500-entry buffer — holds a meaningful fraction of one log call.
99
+ rendered = truncateLogString(rendered, LOG_RING_MAX_CODE_UNITS);
101
100
  return rendered.length > 0 ? `${msg} ${rendered}` : msg;
102
101
  }
103
102
 
@@ -160,6 +159,11 @@ function safeErrorForPino(error: Error): Error {
160
159
  }
161
160
 
162
161
  function logError(msg: string, error: Error | undefined, args: readonly unknown[]): void {
162
+ // Same fast-path as `logArgs`, and for the same reason: `safeErrorForPino`
163
+ // copies every own enumerable field of the error, and an error from a failed
164
+ // upstream call carries the response body. That copy is the largest one in
165
+ // this module and it used to run unconditionally.
166
+ if (!logger.isLevelEnabled("error")) return;
163
167
  const safe = safeLogArgs(args);
164
168
  const ringError = error === undefined ? undefined : safeErrorForRing(error);
165
169
  const pinoError = error === undefined ? undefined : safeErrorForPino(error);
@@ -279,10 +279,21 @@ export class PrometheusRegistry {
279
279
  readonly cartethyia_ip_abuse_keys: GaugeMetric;
280
280
  readonly cartethyia_quota_cache_entries: GaugeMetric;
281
281
  readonly cartethyia_model_catalog_entries: GaugeMetric;
282
+ readonly cartethyia_process_in_flight: GaugeMetric;
283
+ readonly cartethyia_process_concurrency_limit: GaugeMetric;
284
+ readonly cartethyia_telemetry_backlog_items: GaugeMetric;
285
+ readonly cartethyia_telemetry_backlog_bytes: GaugeMetric;
282
286
  readonly cartethyia_proxy_dial_proxy_resolved_total: CounterMetric;
283
287
  readonly proxy_provider_adapter_load_ms: HistogramMetric;
284
288
  readonly pool_cooldown_record_failed: CounterMetric;
285
289
  readonly quota_cache_invalidate_failed: CounterMetric;
290
+ /** Payload frames dropped because the writer backlog was at its bound. */
291
+ readonly cartethyia_payload_backlog_dropped_total: CounterMetric;
292
+ /**
293
+ * Telemetry backfill conflicts: same (tenant, request) identity with
294
+ * different content. Reported, never overwritten.
295
+ */
296
+ readonly cartethyia_telemetry_raw_conflicts_total: CounterMetric;
286
297
 
287
298
  private readonly all: RenderableMetric[] = [];
288
299
 
@@ -343,6 +354,22 @@ export class PrometheusRegistry {
343
354
  "cartethyia_memory_limit_bytes",
344
355
  "Container/cgroup memory limit in bytes (0 when unknown)",
345
356
  );
357
+ this.cartethyia_process_in_flight = this.gauge(
358
+ "cartethyia_process_in_flight",
359
+ "Gateway requests currently admitted into this process",
360
+ );
361
+ this.cartethyia_process_concurrency_limit = this.gauge(
362
+ "cartethyia_process_concurrency_limit",
363
+ "Effective in-flight ceiling for this process, derived from the memory limit",
364
+ );
365
+ this.cartethyia_telemetry_backlog_items = this.gauge(
366
+ "cartethyia_telemetry_backlog_items",
367
+ "Telemetry events queued for the next drain",
368
+ );
369
+ this.cartethyia_telemetry_backlog_bytes = this.gauge(
370
+ "cartethyia_telemetry_backlog_bytes",
371
+ "Estimated bytes queued for the next telemetry drain",
372
+ );
346
373
  this.cartethyia_routing_roundrobin_entries = this.gauge(
347
374
  "cartethyia_routing_roundrobin_entries",
348
375
  "Round-robin routing state entries by scope",
@@ -377,6 +404,14 @@ export class PrometheusRegistry {
377
404
  "quota_cache_invalidate_failed",
378
405
  "Quota cache invalidations that failed",
379
406
  );
407
+ this.cartethyia_payload_backlog_dropped_total = this.counter(
408
+ "cartethyia_payload_backlog_dropped_total",
409
+ "Payload captures dropped because the payload writer backlog was full",
410
+ );
411
+ this.cartethyia_telemetry_raw_conflicts_total = this.counter(
412
+ "cartethyia_telemetry_raw_conflicts_total",
413
+ "Telemetry backfill conflicts (same tenant/request identity, different content)",
414
+ );
380
415
  }
381
416
 
382
417
  counter(name: string, help: string, labelNames: readonly string[] = []): CounterMetric {
@@ -1,59 +1,93 @@
1
1
  import type { CartethyiaDatabase } from "../persistence/db-handle";
2
2
  import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
3
- import { prunePayloadFrames, writePayloadFrame } from "./payload-store";
3
+ import { PayloadBacklogFullError, prunePayloadFrames, writePayloadFrame } from "./payload-store";
4
4
  import { resolveTelemetryPayloadMaxBytes } from "../config/telemetry";
5
+ import { metrics } from "./metrics";
5
6
  import { preferencesReaderFor } from "../persistence/tenant-preferences";
6
7
  import type {
7
8
  TelemetryPayloadDepth,
8
9
  TelemetryPayloadMode,
9
- TelemetryPayloadRetentionMinutes,
10
10
  } from "../console/settings/contracts";
11
11
  export type CaptureScope = "tenant" | "debug_session" | "operator_flag";
12
12
 
13
13
  /**
14
14
  * How long a captured payload frame lives when the deployment has not chosen.
15
15
  *
16
+ * An hour, not fifteen minutes: a frame is the only record of what a request
17
+ * actually sent and received, and the window is when an operator can still go
18
+ * back and read it. Fifteen minutes is shorter than the time between noticing
19
+ * a bad response and opening the drawer, so the frame was already gone by the
20
+ * time someone looked for it. The cost is disk, which the Data Management
21
+ * panel reports and a tenant can reclaim on demand.
22
+ *
16
23
  * Exported so surfaces that report on disk usage quote the same window the
17
24
  * capture path enforces, rather than a second copy of the default that would
18
25
  * drift the first time this one changes.
19
26
  */
20
27
  export function payloadRetentionMs(): number {
21
- const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS ?? 15 * 60_000);
22
- if (!Number.isFinite(raw) || raw < 1_000 || raw > MAX_PAYLOAD_RETENTION_MS) return 15 * 60_000;
28
+ const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS ?? DEFAULT_PAYLOAD_RETENTION_MS);
29
+ if (!Number.isFinite(raw) || raw < 1_000 || raw > MAX_PAYLOAD_RETENTION_MS) return DEFAULT_PAYLOAD_RETENTION_MS;
23
30
  return Math.floor(raw);
24
31
  }
25
32
 
26
33
  /**
27
- * The longest payload retention a tenant may ask for: seven days.
28
- *
29
- * Two things depend on this being a hard ceiling rather than a suggestion.
34
+ * The fallback window, exported so a test can assert the default without
35
+ * pinning the number.
30
36
  *
31
- * The prune pass deletes a frame file it cannot parse once the file's last
32
- * write is older than the retention window — that bound is the only thing that
33
- * makes a damaged file reclaimable at all. A tenant allowed to keep frames for
34
- * a year while the prune assumed an hour would have live frames deleted out
35
- * from under them.
36
- *
37
- * The disk-usage panel counts a frame as reclaimable once its file is older
38
- * than the same window. Without a ceiling it would offer a reclaim button for
39
- * frames that are still live, and the operator would press it and see
40
- * "nothing to reclaim".
37
+ * A literal in a test is a veto on changing policy: the assertion fails the
38
+ * moment the default moves, so the number stops being a choice and becomes
39
+ * whatever the test happened to be written against.
41
40
  */
41
+ export const DEFAULT_PAYLOAD_RETENTION_MS = 60 * 60_000;
42
+
43
+ /** Upper bound for a deployment-level retention override, not a tenant setting. */
42
44
  export const MAX_PAYLOAD_RETENTION_MS = 7 * 24 * 60 * 60_000;
43
45
 
44
- /** Milliseconds one tenant's chosen retention resolves to. */
45
- export function retentionMsFor(minutes: number | null | undefined): number {
46
- if (minutes === null || minutes === undefined) return payloadRetentionMs();
47
- if (!Number.isFinite(minutes)) return payloadRetentionMs();
48
- return Math.min(Math.max(1_000, Math.floor(minutes * 60_000)), MAX_PAYLOAD_RETENTION_MS);
46
+ /**
47
+ * Truncates a string to at most `maxBytes` UTF-8 bytes without splitting a
48
+ * code point.
49
+ *
50
+ * The budget contract everywhere in this module is **bytes**, not UTF-16 code
51
+ * units: `.length` on a CJK-heavy body counts one per character while the same
52
+ * text costs three bytes on the wire and on disk, so a "1 MiB" cap kept ~3 MiB
53
+ * of CJK content and dropped 3× more Latin content than the operator asked
54
+ * for. Slicing at a raw byte boundary can split a multi-byte sequence; this
55
+ * helper backs up to the last complete sequence so the truncated text stays
56
+ * valid.
57
+ */
58
+ export function truncateUtf8(value: string, maxBytes: number): string {
59
+ if (maxBytes <= 0) return "";
60
+ const totalBytes = Buffer.byteLength(value, "utf8");
61
+ if (totalBytes <= maxBytes) return value;
62
+ const sliced = Buffer.from(value, "utf8").subarray(0, maxBytes);
63
+ // Walk back from the cut (at most 3 bytes: the longest UTF-8 sequence is 4
64
+ // bytes) to the lead byte of the sequence the cut landed inside, then drop
65
+ // that whole sequence. A cut that lands between sequences stays put.
66
+ for (let back = 0; back < 4 && sliced.length - back > 0; back += 1) {
67
+ const byte = sliced[sliced.length - back - 1]!;
68
+ if ((byte & 0xc0) !== 0x80) {
69
+ // This is a lead byte (or ASCII): the sequence it starts has n bytes.
70
+ const sequenceLength =
71
+ byte >= 0xf0 ? 4 : byte >= 0xe0 ? 3 : byte >= 0xc0 ? 2 : 1;
72
+ // The cut is `back` bytes into this sequence. If the sequence does not
73
+ // fully fit within the slice, drop the whole sequence.
74
+ if (back + 1 < sequenceLength) {
75
+ return sliced.subarray(0, sliced.length - back - 1).toString("utf8");
76
+ }
77
+ return sliced.toString("utf8");
78
+ }
79
+ // Continuation byte: the lead byte is further back.
80
+ }
81
+ // Degenerate slice of only continuation bytes: nothing complete to keep.
82
+ return "";
49
83
  }
50
84
 
51
85
  /**
52
86
  * Settings-gated payload capture. Request-event metadata is always retained;
53
87
  * tenants opt into drawer capture through Settings → Privacy: `metadata`
54
88
  * keeps only the Proxy→Provider request line, `full` keeps redacted bodies
55
- * at the tenant's chosen depth, and both live for the tenant's chosen
56
- * retention. Fail-closed on error.
89
+ * at the tenant's chosen depth. Their lifetime is the tenant's Request detail
90
+ * window, or the deployment default when unset. Fail-closed on error.
57
91
  */
58
92
  export async function resolvePayloadCapture(
59
93
  db: CartethyiaDatabase,
@@ -72,7 +106,11 @@ export async function resolvePayloadCapture(
72
106
  const prefs = await preferencesReaderFor(db).readPreferences(tenantId);
73
107
  const mode = prefs?.telemetryPayloads;
74
108
  const depth = prefs?.telemetryPayloadDepth;
75
- const retention = prefs?.telemetryPayloadRetentionMinutes;
109
+ const minutes = prefs?.telemetryWindowMinutes;
110
+ const retentionMs =
111
+ typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0
112
+ ? Math.min(MAX_PAYLOAD_RETENTION_MS, Math.floor(minutes * 60_000))
113
+ : payloadRetentionMs();
76
114
  return {
77
115
  ok: true,
78
116
  mode: mode === "full" || mode === "metadata" || mode === "none" ? mode : "none",
@@ -86,7 +124,7 @@ export async function resolvePayloadCapture(
86
124
  : depth === "full"
87
125
  ? "maximum"
88
126
  : "minimum",
89
- retentionMs: retentionMsFor(retention as TelemetryPayloadRetentionMinutes | undefined),
127
+ retentionMs,
90
128
  };
91
129
  } catch {
92
130
  // A preference read that fails must not invent capture. Off keeps nothing.
@@ -135,7 +173,9 @@ export type PayloadCaptureState =
135
173
  /** The preference read failed, and capture fails closed. */
136
174
  | "skipped_preference_read_failed"
137
175
  /** The storage write failed after the decision to capture. */
138
- | "failed_write";
176
+ | "failed_write"
177
+ /** The payload writer's backlog was at its bound, so the frame was dropped. */
178
+ | "failed_backlog";
139
179
 
140
180
  export interface PayloadCaptureInput {
141
181
  tenantId: string;
@@ -150,8 +190,7 @@ export interface PayloadCaptureInput {
150
190
  */
151
191
  maxBytes?: number;
152
192
  /**
153
- * How long this frame lives, in ms. Defaults to the tenant's chosen
154
- * retention, then the deployment default. Bounded by
193
+ * Retention for this capture; normally the deployment default. Bounded by
155
194
  * {@link MAX_PAYLOAD_RETENTION_MS} either way.
156
195
  */
157
196
  retentionMs?: number;
@@ -295,6 +334,8 @@ export function buildPayloadRecord(
295
334
  const kept: unknown[] = [];
296
335
  let accounted = 0;
297
336
  const truncatedKeys = new Set<keyof StoredPayload>();
337
+ // A fully-dropped body's own byte size, for the marker the drawer renders.
338
+ const truncatedOriginalBytes = new Map<keyof StoredPayload, number>();
298
339
  /**
299
340
  * Whether a surface kept a *body* — not whether it is merely non-null.
300
341
  *
@@ -311,13 +352,21 @@ export function buildPayloadRecord(
311
352
  continue;
312
353
  }
313
354
  const text = JSON.stringify(body);
314
- if (accounted + text.length > maxBytes) {
355
+ // Accounted in UTF-8 bytes: the cap is a storage budget, and the bytes a
356
+ // body costs on disk are its UTF-8 size. Code-unit accounting under-counts
357
+ // multi-byte content ~3x (CJK) and over-allocates Latin content.
358
+ const textBytes = Buffer.byteLength(text, "utf8");
359
+ if (accounted + textBytes > maxBytes) {
360
+ // Same drop-then-marker rule as before — only the accounting basis is
361
+ // now bytes. A partial head would be raw JSON text where the drawer
362
+ // renders a structured panel, so the body is kept whole or marked.
315
363
  truncatedKeys.add(key);
364
+ truncatedOriginalBytes.set(key, textBytes);
316
365
  stored[key] = null;
317
366
  serialized.push("null");
318
367
  continue;
319
368
  }
320
- accounted += text.length;
369
+ accounted += textBytes;
321
370
  stored[key] = body;
322
371
  serialized.push(text);
323
372
  kept.push(body);
@@ -332,7 +381,7 @@ export function buildPayloadRecord(
332
381
  _original_bytes: originalBytes,
333
382
  _hint: "Truncated by the capture cap — raise Settings → Telemetry payloads → Capture depth to keep more.",
334
383
  });
335
- for (const key of truncatedKeys) stored[key] = marker(accounted);
384
+ for (const key of truncatedKeys) stored[key] = marker(truncatedOriginalBytes.get(key) ?? 0);
336
385
  return {
337
386
  request_id: input.requestId ?? null,
338
387
  captured_at: now,
@@ -385,7 +434,12 @@ export class TelemetryPayloadCapture {
385
434
  this.store = new DrizzleTelemetryStore(db);
386
435
  }
387
436
 
388
- async capture(input: PayloadCaptureInput): Promise<{ id: string; expiresAt: Date }> {
437
+ async capture(input: PayloadCaptureInput): Promise<{
438
+ id: string;
439
+ expiresAt: Date;
440
+ /** Terminal capture state for the request row (`failed_backlog` on a dropped frame). */
441
+ payloadCaptureState?: PayloadCaptureState;
442
+ }> {
389
443
  if (!isCaptureAllowed(input)) {
390
444
  throw Object.assign(new Error("payload capture not opted in"), {
391
445
  code: "capture_not_opted_in",
@@ -402,14 +456,33 @@ export class TelemetryPayloadCapture {
402
456
  input.maxBytes ?? resolveTelemetryPayloadMaxBytes(),
403
457
  retentionMs,
404
458
  );
405
- const reference = await writePayloadFrame(record, record.expires_at, record._serialized
406
- ? {
407
- requestId: record.request_id,
408
- capturedAt: record.captured_at.toISOString(),
409
- signals: record.signals,
410
- bodies: record._serialized,
411
- }
412
- : undefined);
459
+ let reference;
460
+ try {
461
+ reference = await writePayloadFrame(record, record.expires_at, record._serialized
462
+ ? {
463
+ requestId: record.request_id,
464
+ capturedAt: record.captured_at.toISOString(),
465
+ signals: record.signals,
466
+ bodies: record._serialized,
467
+ }
468
+ : undefined);
469
+ } catch (error) {
470
+ if (error instanceof PayloadBacklogFullError) {
471
+ // The writer's backlog is at its bound: the frame is dropped here,
472
+ // counted, and surfaced on the request as its own terminal state —
473
+ // distinct from a storage failure, because "the disk is slow" and
474
+ // "capture burst past the backlog bound" need different fixes. No
475
+ // `telemetry_payloads` row is inserted: a row without a frame would
476
+ // read as a corrupt reference.
477
+ metrics.cartethyia_payload_backlog_dropped_total.inc(1);
478
+ return {
479
+ id: "",
480
+ expiresAt: record.expires_at,
481
+ payloadCaptureState: "failed_backlog",
482
+ };
483
+ }
484
+ throw error;
485
+ }
413
486
  // Typed columns only — bodies live in the frame file. The schema has no
414
487
  // jsonb body column, so a body cannot be written to Postgres here.
415
488
  return this.store.insertPayload({