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
@@ -6,6 +6,8 @@ import { DEFAULT_BOUNDS } from "../config/network";
6
6
  import { type SourceSurface, type UsageRecord } from "../protocol/canonical-model";
7
7
  import { log } from "./logger";
8
8
  import { scrubSecrets } from "./redaction";
9
+ import { clientNameFromUserAgent } from "./client-name";
10
+
9
11
 
10
12
  /**
11
13
  * Buffered durable telemetry writer. Coalesces per-request telemetry into
@@ -103,6 +105,12 @@ function telemetryEventRow(event: TelemetryEventInput) {
103
105
  // `cachedInputTokens` stays — `health` aggregates it into the Overview
104
106
  // cache-hit rate.
105
107
  return {
108
+ // Stable identity: the request's own id. Retries reuse the materialized
109
+ // row (the buffer retries the same rows across its five attempts), and
110
+ // the (tenant_id, request_id) unique boundary makes a re-drain or a
111
+ // cross-process race converge to one row via ON CONFLICT DO NOTHING
112
+ // instead of double-charging.
113
+ id: event.requestId,
106
114
  tenantId: event.tenantId,
107
115
  requestId: event.requestId,
108
116
  // Stamped here, not by the reader. A row without it has no real time, and a
@@ -115,6 +123,8 @@ function telemetryEventRow(event: TelemetryEventInput) {
115
123
  endpoint: event.endpoint ?? null,
116
124
  apiKeyId: event.apiKeyId ?? null,
117
125
  userAgent: event.userAgent ?? null,
126
+ // Canonical client grouping, stamped once so SQL breakdowns group by it.
127
+ clientName: clientNameFromUserAgent(event.userAgent),
118
128
  clientIp: event.clientIp ?? null,
119
129
  providerId: event.providerId ?? null,
120
130
  accountId: event.accountId ?? null,
@@ -153,6 +163,9 @@ export interface TelemetryBatchBufferOptions {
153
163
  readonly maxBatch?: number;
154
164
  readonly maxItems?: number;
155
165
  readonly maxBytes?: number;
166
+ /** Test seam: a deterministic hook before a retry timer is installed. */
167
+ readonly onRetry?: (attempt: number) => void | Promise<void>;
168
+ readonly store?: Pick<DrizzleTelemetryStore, "insertEvents">;
156
169
  }
157
170
 
158
171
  /** Cooldown between `[telemetry] failed to record` log lines during a sustained drain outage. */
@@ -166,8 +179,8 @@ const DRAIN_RETRY_MAX_MS = 1_000;
166
179
  import { exponentialBackoff, type BackoffOptions } from "../network/backoff";
167
180
 
168
181
  /** Exponential retry delay for the `attempt`-th retry, clamped to the ceiling. */
169
- function drainRetryDelayMs(attempt: number): number {
170
- const options: BackoffOptions = { baseDelayMs: DRAIN_RETRY_BASE_MS, maxDelayMs: DRAIN_RETRY_MAX_MS };
182
+ function drainRetryDelayMs(attempt: number, baseDelayMs = DRAIN_RETRY_BASE_MS): number {
183
+ const options: BackoffOptions = { baseDelayMs, maxDelayMs: DRAIN_RETRY_MAX_MS };
171
184
  return exponentialBackoff(attempt, options);
172
185
  }
173
186
 
@@ -192,6 +205,20 @@ export function adaptiveFlushIntervalMs(
192
205
  return Math.max(ADAPTIVE_FLUSH_MIN_MS, Math.round(baseMs * (1 - fill)));
193
206
  }
194
207
 
208
+ /** The telemetry backlog's current shape — see {@link TelemetryBatchBuffer.snapshot}. */
209
+ export interface TelemetryBacklogSnapshot {
210
+ /** Queued events waiting for the next drain. */
211
+ readonly items: number;
212
+ /** Estimated queued bytes. */
213
+ readonly bytes: number;
214
+ readonly itemCapacity: number;
215
+ readonly byteCapacity: number;
216
+ /** Whether a drain is currently in flight. */
217
+ readonly flushing: boolean;
218
+ /** Consecutive fully-failed drains; 0 when healthy. */
219
+ readonly drainFailureStreak: number;
220
+ }
221
+
195
222
  export class TelemetryBatchBuffer {
196
223
  /**
197
224
  * One entry per queued event. `bytes` is a conservative enqueue-time
@@ -199,16 +226,17 @@ export class TelemetryBatchBuffer {
199
226
  * JSON serialization allocation on every request.
200
227
  */
201
228
  private readonly queue: Array<{ event: TelemetryEventInput; bytes: number }> = [];
202
- private readonly store: DrizzleTelemetryStore;
229
+ private readonly store: Pick<DrizzleTelemetryStore, "insertEvents">;
203
230
  private readonly flushIntervalMs: number;
204
231
  private readonly maxBatch: number;
205
232
  private readonly maxItems: number;
206
233
  private readonly maxBytes: number;
234
+ private readonly onRetry: ((attempt: number) => void | Promise<void>) | undefined;
207
235
  private queuedBytes = 0;
208
236
  private timer: ReturnType<typeof setTimeout> | undefined;
209
237
  private stopped = false;
210
238
  private flushing = false;
211
- private flushWait: Promise<void> = Promise.resolve();
239
+ private flushWait: Promise<boolean> = Promise.resolve(true);
212
240
  private lastDrainOutageAt = 0;
213
241
  private suppressedDrainOutages = 0;
214
242
  /** Consecutive fully-failed drains; drives the cross-drain outage backoff. */
@@ -220,11 +248,12 @@ export class TelemetryBatchBuffer {
220
248
  db: CartethyiaDatabase,
221
249
  opts: TelemetryBatchBufferOptions = {},
222
250
  ) {
223
- this.store = new DrizzleTelemetryStore(db);
251
+ this.store = opts.store ?? new DrizzleTelemetryStore(db);
224
252
  this.flushIntervalMs = opts.flushIntervalMs ?? 500;
225
253
  this.maxBatch = opts.maxBatch ?? 500;
226
254
  this.maxItems = opts.maxItems ?? 10000;
227
255
  this.maxBytes = opts.maxBytes ?? DEFAULT_BOUNDS.maxTelemetryQueueBytes;
256
+ this.onRetry = opts.onRetry;
228
257
  this.scheduleNextFlush(this.flushIntervalMs);
229
258
  }
230
259
 
@@ -246,10 +275,31 @@ export class TelemetryBatchBuffer {
246
275
  if (typeof this.timer.unref === "function") this.timer.unref();
247
276
  }
248
277
 
249
- /** Non-blocking; drops the event (and counts it) when the buffer is full by count or bytes. */
278
+ /**
279
+ * The backlog's current shape, for gauges and the health surface.
280
+ *
281
+ * The buffer already drops on its bounds, but a drop nobody can see is
282
+ * indistinguishable from "no traffic" — an operator watching the console
283
+ * during an outage would see flat telemetry and conclude nothing was
284
+ * happening, when in fact the queue was full and discarding.
285
+ */
286
+ snapshot(): TelemetryBacklogSnapshot {
287
+ return {
288
+ items: this.queue.length,
289
+ bytes: this.queuedBytes,
290
+ itemCapacity: this.maxItems,
291
+ byteCapacity: this.maxBytes,
292
+ flushing: this.flushing,
293
+ // The streak is the interesting one: a single failed drain is noise, a
294
+ // rising streak means the database is refusing writes and every event
295
+ // buffered past the cap is being dropped.
296
+ drainFailureStreak: this.drainFailureStreak,
297
+ };
298
+ }
299
+
250
300
  enqueue(event: TelemetryEventInput): void {
251
301
  const bytes = estimateTelemetryEventBytes(event);
252
- if (this.queue.length >= this.maxItems || this.queuedBytes + bytes > this.maxBytes) {
302
+ if (this.stopped || this.queue.length >= this.maxItems || this.queuedBytes + bytes > this.maxBytes) {
253
303
  metrics.cartethyia_telemetry_dropped_total.inc();
254
304
  return;
255
305
  }
@@ -266,8 +316,8 @@ export class TelemetryBatchBuffer {
266
316
  }
267
317
  }
268
318
 
269
- /** Drains the buffer with a single multi-row insert. Idempotent, and safe
270
- * against concurrent invocation: overlapping callers await the in-flight
319
+ /** Drains the buffer with a single multi-row insert. Safe against concurrent
320
+ * invocation: overlapping callers await the in-flight
271
321
  * drain and then re-enter until the queue is fully empty. Shutdown callers
272
322
  * rely on this loop so they can observe an "empty queue" postcondition
273
323
  * before closing the database pool, instead of returning immediately when
@@ -284,13 +334,16 @@ export class TelemetryBatchBuffer {
284
334
  }
285
335
  this.flushing = true;
286
336
  this.flushWait = (async () => {
337
+ let completed: boolean;
287
338
  try {
288
- await this.drain();
339
+ completed = await this.drain();
289
340
  } finally {
290
341
  this.flushing = false;
291
342
  }
343
+ return completed;
292
344
  })();
293
- await this.flushWait;
345
+ const completed = await this.flushWait;
346
+ if (!completed) return;
294
347
  }
295
348
  }
296
349
 
@@ -306,7 +359,23 @@ export class TelemetryBatchBuffer {
306
359
  clearTimeout(this.timer);
307
360
  this.timer = undefined;
308
361
  }
309
- if (options?.flush) await this.flush();
362
+ if (options?.flush) {
363
+ await this.flush();
364
+ } else if (this.flushing) {
365
+ // A retry interrupted by stop returns its in-flight batch to the head
366
+ // of the queue. Await that handoff before accounting for a drop.
367
+ await this.flushWait;
368
+ }
369
+ this.dropRemaining();
370
+ }
371
+
372
+ /** Drops only after shutdown has made the owner/timer unavailable. */
373
+ private dropRemaining(): void {
374
+ if (this.queue.length === 0) return;
375
+ metrics.cartethyia_telemetry_dropped_total.inc(this.queue.length);
376
+ metrics.cartethyia_telemetry_buffered.inc(-this.queue.length);
377
+ this.queue.length = 0;
378
+ this.queuedBytes = 0;
310
379
  }
311
380
 
312
381
  /**
@@ -320,8 +389,8 @@ export class TelemetryBatchBuffer {
320
389
  return this.flush();
321
390
  }
322
391
 
323
- private async drain(): Promise<void> {
324
- if (this.queue.length === 0) return;
392
+ private async drain(): Promise<boolean> {
393
+ if (this.queue.length === 0) return true;
325
394
  const batch = this.queue.splice(0, Math.min(this.queue.length, this.maxBatch));
326
395
  // Reuse the enqueue-time serialized sizes — no second JSON pass.
327
396
  const batchBytes = batch.reduce((sum, item) => sum + item.bytes, 0);
@@ -335,10 +404,23 @@ export class TelemetryBatchBuffer {
335
404
  // drains probe the database less and less often. Unref'd so a
336
405
  // mid-shutdown backoff cannot hold the event loop open.
337
406
  const { promise, resolve } = Promise.withResolvers<void>();
338
- const backoff = setTimeout(resolve, drainRetryDelayMs(this.drainFailureStreak + attempt - 1));
407
+ if (this.stopped) {
408
+ this.queue.unshift(...batch);
409
+ this.queuedBytes += batchBytes;
410
+ return false;
411
+ }
412
+ await this.onRetry?.(attempt);
413
+ const backoff = setTimeout(
414
+ resolve,
415
+ drainRetryDelayMs(this.drainFailureStreak + attempt - 1),
416
+ );
339
417
  if (typeof backoff.unref === "function") backoff.unref();
340
418
  await promise;
341
- if (this.stopped) return;
419
+ if (this.stopped) {
420
+ this.queue.unshift(...batch);
421
+ this.queuedBytes += batchBytes;
422
+ return false;
423
+ }
342
424
  }
343
425
  try {
344
426
  await this.store.insertEvents(rows);
@@ -346,7 +428,7 @@ export class TelemetryBatchBuffer {
346
428
  this.drainFailureStreak = 0;
347
429
  this.drainBlockedUntil = 0;
348
430
  metrics.cartethyia_telemetry_buffered.inc(-batch.length);
349
- return;
431
+ return true;
350
432
  } catch (error) {
351
433
  if (attempt === 0) firstError = error;
352
434
  lastError = error;
@@ -358,6 +440,7 @@ export class TelemetryBatchBuffer {
358
440
  this.logDrainOutage(lastError, firstError);
359
441
  metrics.cartethyia_telemetry_dropped_total.inc(batch.length);
360
442
  metrics.cartethyia_telemetry_buffered.inc(-batch.length);
443
+ return true;
361
444
  }
362
445
 
363
446
  /**
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Reconciles the raw telemetry mirror back into the SQL table.
3
+ *
4
+ * The durable record is `telemetry_events`, written transactionally on the
5
+ * request path. The raw JSONL mirror is at-least-once: a drain that retried
6
+ * after a crash can have appended a line whose SQL row never landed (or landed
7
+ * and was rolled back). This job is how an operator recovers that gap, and how
8
+ * a deployment that ran before `telemetry_events` was written gets its history
9
+ * back.
10
+ *
11
+ * Design constraints, in the order they bind:
12
+ *
13
+ * - **Idempotent.** The unique index on `(tenant_id, request_id)` is the
14
+ * boundary: `ON CONFLICT DO NOTHING` plus `RETURNING` means a re-run
15
+ * inserts nothing and charges nothing. The checkpoint can therefore be
16
+ * advanced optimistically and still be safe.
17
+ * - **Resumable.** Progress is a `(file_name, byte_offset)` checkpoint written
18
+ * in the SAME transaction as the rows it covers, so a crash replays at most
19
+ * one chunk and never loses a completed one.
20
+ * - **Leased.** Several processes share one database; a lease row
21
+ * (`telemetry_reconciliation_state`) means one runner at a time. The lease
22
+ * expires, so a crashed runner does not wedge the job forever.
23
+ * - **Bounded.** Chunked reads, a chunk cap, and a transaction cap. A 512 MiB
24
+ * backlog drains over successive runs instead of one unbounded sweep.
25
+ * - **Never touches `telemetry_usage_totals`.** Those counters were charged at
26
+ * request time. Backfilling rows must not double-charge them, so this path
27
+ * inserts events only.
28
+ */
29
+ import { and, eq, sql } from "drizzle-orm";
30
+ import { readdir, open, stat } from "node:fs/promises";
31
+ import { join } from "node:path";
32
+
33
+ import type { CartethyiaDatabase } from "../persistence/db-handle";
34
+ import { telemetryEvents, telemetryReconciliationState } from "../persistence/schema";
35
+ import { TELEMETRY_EVENT_DIR } from "./event-log";
36
+ import { clientNameFromUserAgent } from "./client-name";
37
+ import type { TelemetryEventRecord } from "./event-log";
38
+ import { hourStamp } from "../shared/time";
39
+
40
+ /** The single job name: one reconciliation stream, one checkpoint. */
41
+ const JOB_NAME = "raw_to_sql_v1";
42
+
43
+ /** How long a runner holds the job. Generous, so a slow run is not stolen. */
44
+ const LEASE_MS = 30 * 60_000;
45
+
46
+ /** One read of a file. Bounds peak memory to one chunk, not one file. */
47
+ const CHUNK_BYTES = 256 * 1024;
48
+
49
+ /**
50
+ * Rows per transaction. Enough to amortise commit cost, small enough that a
51
+ * single transaction is never a long lock or a huge WAL burst.
52
+ */
53
+ const ROWS_PER_TRANSACTION = 500;
54
+
55
+ /** Chunks per run. A backlog drains over successive runs, not one sweep. */
56
+ const MAX_CHUNKS_PER_RUN = 512;
57
+
58
+ /** Files per run, so a fresh backlog is not capped by one huge file. */
59
+ const MAX_FILES_PER_RUN = 64;
60
+
61
+ /**
62
+ * The current and previous UTC hour are skipped: the current one is still
63
+ * being appended to, and the previous one can still receive a batch that was
64
+ * flushed after the boundary. Those rows arrive through the request path
65
+ * itself — reconciling them would race the writer for no gain.
66
+ */
67
+ const HOURS_TO_SKIP = 2;
68
+
69
+ export interface ReconcileOptions {
70
+ /** Report only: count the gap without writing anything. */
71
+ readonly verify?: boolean;
72
+ readonly now?: Date;
73
+ }
74
+
75
+ export interface ReconcileReport {
76
+ readonly ran: boolean;
77
+ /** Why the run did nothing, when it did nothing. */
78
+ readonly skippedReason?: "lease_held" | "no_files" | "uptodate";
79
+ readonly leaseOwner: string;
80
+ readonly filesCompleted: number;
81
+ readonly eventsExamined: number;
82
+ readonly eventsInserted: number;
83
+ readonly conflicts: number;
84
+ readonly bytesRead: number;
85
+ }
86
+
87
+ /**
88
+ * The checkpoint: which file, and how far into it.
89
+ *
90
+ * `fileName` is compared as a string and files are processed in name order,
91
+ * so an hour that rolls over does not resurrect an older file: once the
92
+ * checkpoint passes a name, files sorting before it never come back.
93
+ */
94
+ interface Checkpoint {
95
+ readonly fileName: string;
96
+ readonly byteOffset: number;
97
+ }
98
+
99
+ function nowMs(options: ReconcileOptions): number {
100
+ return (options.now ?? new Date()).getTime();
101
+ }
102
+
103
+ /** Files eligible for reconciliation: JSONL logs, newest-completed-first is irrelevant here. */
104
+ async function candidateFiles(): Promise<readonly string[]> {
105
+ const all = await readdir(TELEMETRY_EVENT_DIR).catch(() => [] as string[]);
106
+ // The current and previous hour are still being written; see HOURS_TO_SKIP.
107
+ const floor = hourStamp(new Date(Date.now() - HOURS_TO_SKIP * 3_600_000));
108
+ return all
109
+ .filter((file) => file.endsWith(".jsonl") && !file.endsWith(".compacted.jsonl"))
110
+ .filter((file) => file.slice(0, 13) < floor)
111
+ .sort();
112
+ }
113
+
114
+ /**
115
+ * Parses the complete lines in one chunk.
116
+ *
117
+ * `carry` holds the trailing partial line: the chunk boundary falls mid-line
118
+ * whenever the writer's last append was split. It is prepended to the next
119
+ * chunk rather than parsed, because a half line is not a JSON document.
120
+ */
121
+ function parseChunk(text: string): { readonly events: TelemetryEventRecord[]; readonly carry: string } {
122
+ const lines = text.split("\n");
123
+ // The last element is either "" (the chunk ended on a newline, so every line
124
+ // is complete) or a partial line (keep it for the next chunk).
125
+ const carry = lines.pop() ?? "";
126
+ const events: TelemetryEventRecord[] = [];
127
+ for (const line of lines) {
128
+ if (line.length === 0) continue;
129
+ let value: unknown;
130
+ try {
131
+ value = JSON.parse(line);
132
+ } catch {
133
+ // A torn line from a crash mid-append. Skipped rather than fatal: the
134
+ // rest of the file is still recoverable, and one bad line must not
135
+ // strand a whole backlog.
136
+ continue;
137
+ }
138
+ if (typeof value !== "object" || value === null) continue;
139
+ const event = value as Partial<TelemetryEventRecord>;
140
+ if (typeof event.tenantId !== "string" || typeof event.requestId !== "string") continue;
141
+ events.push(value as TelemetryEventRecord);
142
+ }
143
+ return { events, carry };
144
+ }
145
+
146
+ /** The columns the backfill inserts. Mirrors `telemetryEventRow` on the write path. */
147
+ function toRow(event: TelemetryEventRecord): typeof telemetryEvents.$inferInsert {
148
+ return {
149
+ // `id = requestId` is what makes the unique index a working idempotency
150
+ // boundary: a retry of the same request carries the same id.
151
+ id: event.requestId,
152
+ tenantId: event.tenantId!,
153
+ requestId: event.requestId,
154
+ sourceSurface: event.sourceSurface as typeof telemetryEvents.$inferInsert.sourceSurface,
155
+ requestedModel: event.requestedModel,
156
+ reportedModel: event.reportedModel,
157
+ requestedEffort: event.requestedEffort,
158
+ providerId: event.providerId,
159
+ accountId: event.accountId,
160
+ networkPoolId: event.networkPoolId,
161
+ endpoint: event.endpoint,
162
+ apiKeyId: event.apiKeyId,
163
+ userAgent: event.userAgent,
164
+ // Derived the same way ingestion derives it, so a backfilled row groups
165
+ // with the rows the request path wrote instead of forming its own bucket.
166
+ clientName: clientNameFromUserAgent(event.userAgent),
167
+ clientIp: event.clientIp,
168
+ latencyMs: event.latencyMs,
169
+ ttfbMs: event.ttfbMs,
170
+ stream: event.stream,
171
+ httpStatus: event.httpStatus,
172
+ status: event.status as typeof telemetryEvents.$inferInsert.status,
173
+ errorCategory: event.errorCategory,
174
+ errorOrigin: event.errorOrigin,
175
+ inputTokens: event.inputTokens,
176
+ cachedInputTokens: event.cachedInputTokens,
177
+ outputTokens: event.outputTokens,
178
+ reasoningTokens: event.reasoningTokens,
179
+ estimatedCostUsd:
180
+ event.estimatedCostUsd === null ? null : String(event.estimatedCostUsd),
181
+ createdAt: new Date(event.createdAt),
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Acquires (or steals an expired) lease.
187
+ *
188
+ * `insert ... on conflict` in one statement: the row is created on first run
189
+ * and refreshed afterwards without a read-modify-write that two runners could
190
+ * interleave. `lease_expires_at` is the only arbiter, so a runner that died
191
+ * mid-file is replaced once its lease lapses rather than blocking forever.
192
+ */
193
+ async function acquireLease(db: CartethyiaDatabase, owner: string, now: Date): Promise<boolean> {
194
+ const expiresAt = new Date(now.getTime() + LEASE_MS);
195
+ const result = await db
196
+ .insert(telemetryReconciliationState)
197
+ .values({
198
+ jobName: JOB_NAME,
199
+ leaseOwner: owner,
200
+ leaseExpiresAt: expiresAt,
201
+ fileName: "",
202
+ byteOffset: 0,
203
+ filesCompleted: 0,
204
+ eventsExamined: 0,
205
+ eventsInserted: 0,
206
+ conflicts: 0,
207
+ updatedAt: now,
208
+ })
209
+ .onConflictDoUpdate({
210
+ target: telemetryReconciliationState.jobName,
211
+ set: { leaseOwner: owner, leaseExpiresAt: expiresAt, updatedAt: now },
212
+ // Take the lease only when nobody holds a live one: an expired lease or
213
+ // no lease at all means the previous runner is gone.
214
+ where: sql`(${telemetryReconciliationState.leaseExpiresAt} is null or ${telemetryReconciliationState.leaseExpiresAt} < ${now} or ${telemetryReconciliationState.leaseOwner} = ${owner})`,
215
+ })
216
+ .returning({ owner: telemetryReconciliationState.leaseOwner });
217
+ return result.length > 0;
218
+ }
219
+
220
+ async function releaseLease(db: CartethyiaDatabase, owner: string, now: Date): Promise<void> {
221
+ await db
222
+ .update(telemetryReconciliationState)
223
+ .set({ leaseExpiresAt: null, updatedAt: now })
224
+ .where(
225
+ and(
226
+ eq(telemetryReconciliationState.jobName, JOB_NAME),
227
+ eq(telemetryReconciliationState.leaseOwner, owner),
228
+ ),
229
+ );
230
+ }
231
+
232
+ async function readCheckpoint(db: CartethyiaDatabase): Promise<Checkpoint> {
233
+ const [row] = await db
234
+ .select({
235
+ fileName: telemetryReconciliationState.fileName,
236
+ byteOffset: telemetryReconciliationState.byteOffset,
237
+ })
238
+ .from(telemetryReconciliationState)
239
+ .where(eq(telemetryReconciliationState.jobName, JOB_NAME))
240
+ .limit(1);
241
+ return { fileName: row?.fileName ?? "", byteOffset: row?.byteOffset ?? 0 };
242
+ }
243
+
244
+ /**
245
+ * One run of the reconciliation job.
246
+ *
247
+ * Returns a report rather than throwing on "nothing to do": a scheduled or
248
+ * CLI invocation has to distinguish "no gap" from "could not run", and a
249
+ * thrown error would read as a failure of the whole command.
250
+ */
251
+ export async function reconcileTelemetry(
252
+ db: CartethyiaDatabase,
253
+ options: ReconcileOptions = {},
254
+ ): Promise<ReconcileReport> {
255
+ const owner = `${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
256
+ const now = new Date(nowMs(options));
257
+ const verify = options.verify === true;
258
+
259
+ const files = await candidateFiles();
260
+ if (files.length === 0) {
261
+ return {
262
+ ran: false,
263
+ skippedReason: "no_files",
264
+ leaseOwner: owner,
265
+ filesCompleted: 0,
266
+ eventsExamined: 0,
267
+ eventsInserted: 0,
268
+ conflicts: 0,
269
+ bytesRead: 0,
270
+ };
271
+ }
272
+
273
+ // `--verify` takes no lease: it only reads the mirror and counts, so two
274
+ // concurrent verifications cannot conflict and neither can block a real run.
275
+ if (!verify && !(await acquireLease(db, owner, now))) {
276
+ return {
277
+ ran: false,
278
+ skippedReason: "lease_held",
279
+ leaseOwner: owner,
280
+ filesCompleted: 0,
281
+ eventsExamined: 0,
282
+ eventsInserted: 0,
283
+ conflicts: 0,
284
+ bytesRead: 0,
285
+ };
286
+ }
287
+
288
+ const checkpoint = verify ? { fileName: "", byteOffset: 0 } : await readCheckpoint(db);
289
+ const pending = files.filter((file) => file > checkpoint.fileName);
290
+ if (pending.length === 0) {
291
+ if (!verify) await releaseLease(db, owner, new Date());
292
+ return {
293
+ ran: false,
294
+ skippedReason: "uptodate",
295
+ leaseOwner: owner,
296
+ filesCompleted: 0,
297
+ eventsExamined: 0,
298
+ eventsInserted: 0,
299
+ conflicts: 0,
300
+ bytesRead: 0,
301
+ };
302
+ }
303
+
304
+ let eventsExamined = 0;
305
+ let eventsInserted = 0;
306
+ let conflicts = 0;
307
+ let filesCompleted = 0;
308
+ let bytesRead = 0;
309
+ let chunks = 0;
310
+
311
+ try {
312
+ for (const file of pending.slice(0, MAX_FILES_PER_RUN)) {
313
+ const path = join(TELEMETRY_EVENT_DIR, file);
314
+ const size = (await stat(path).catch(() => undefined))?.size ?? 0;
315
+ const handle = await open(path, "r").catch(() => undefined);
316
+ if (handle === undefined) {
317
+ // Deleted between the listing and the open — retention pruned it.
318
+ continue;
319
+ }
320
+ let offset = file === checkpoint.fileName ? checkpoint.byteOffset : 0;
321
+ let carry = "";
322
+ try {
323
+ while (offset < size && chunks < MAX_CHUNKS_PER_RUN) {
324
+ const buffer = Buffer.alloc(Math.min(CHUNK_BYTES, size - offset));
325
+ const { bytesRead: read } = await handle.read(buffer, 0, buffer.length, offset);
326
+ if (read <= 0) break;
327
+ chunks += 1;
328
+ bytesRead += read;
329
+ offset += read;
330
+ const parsed = parseChunk(carry + buffer.toString("utf8", 0, read));
331
+ carry = parsed.carry;
332
+ eventsExamined += parsed.events.length;
333
+ if (parsed.events.length > 0 && !verify) {
334
+ for (let start = 0; start < parsed.events.length; start += ROWS_PER_TRANSACTION) {
335
+ const slice = parsed.events.slice(start, start + ROWS_PER_TRANSACTION);
336
+ const inserted = await insertChunk(db, slice, file, offset);
337
+ eventsInserted += inserted.inserted;
338
+ conflicts += inserted.conflicts;
339
+ }
340
+ }
341
+ }
342
+ } finally {
343
+ await handle.close().catch(() => undefined);
344
+ }
345
+ // Only complete files are marked done: a run that hit its chunk cap
346
+ // resumes inside this file, so the offset must stay.
347
+ if (offset >= size) {
348
+ filesCompleted += 1;
349
+ if (!verify) await writeCheckpoint(db, file, 0, now);
350
+ } else if (!verify) {
351
+ await writeCheckpoint(db, file, offset, now);
352
+ }
353
+ if (chunks >= MAX_CHUNKS_PER_RUN) break;
354
+ }
355
+ } finally {
356
+ if (!verify) await releaseLease(db, owner, new Date());
357
+ }
358
+
359
+ return {
360
+ ran: true,
361
+ leaseOwner: owner,
362
+ filesCompleted,
363
+ eventsExamined,
364
+ eventsInserted,
365
+ conflicts,
366
+ bytesRead,
367
+ };
368
+ }
369
+
370
+ /**
371
+ * Inserts one batch and advances the checkpoint in the SAME transaction.
372
+ *
373
+ * `ON CONFLICT DO NOTHING` + `RETURNING` is the idempotency boundary: the
374
+ * unique index on `(tenant_id, request_id)` decides, and only returned rows
375
+ * count. Usage totals are deliberately not touched — those counters were
376
+ * charged at request time, and re-charging from a backfill would inflate
377
+ * every dashboard figure for the affected window.
378
+ */
379
+ async function insertChunk(
380
+ db: CartethyiaDatabase,
381
+ events: readonly TelemetryEventRecord[],
382
+ fileName: string,
383
+ byteOffset: number,
384
+ ): Promise<{ readonly inserted: number; readonly conflicts: number }> {
385
+ return db.transaction(async (tx) => {
386
+ const rows = await tx
387
+ .insert(telemetryEvents)
388
+ .values(events.map(toRow))
389
+ .onConflictDoNothing()
390
+ .returning({ id: telemetryEvents.id });
391
+ await tx
392
+ .update(telemetryReconciliationState)
393
+ .set({
394
+ fileName,
395
+ byteOffset,
396
+ filesCompleted: sql`${telemetryReconciliationState.filesCompleted} + 0`,
397
+ eventsExamined: sql`${telemetryReconciliationState.eventsExamined} + ${events.length}`,
398
+ eventsInserted: sql`${telemetryReconciliationState.eventsInserted} + ${rows.length}`,
399
+ conflicts: sql`${telemetryReconciliationState.conflicts} + ${events.length - rows.length}`,
400
+ updatedAt: new Date(),
401
+ })
402
+ .where(eq(telemetryReconciliationState.jobName, JOB_NAME));
403
+ return { inserted: rows.length, conflicts: events.length - rows.length };
404
+ });
405
+ }
406
+
407
+ async function writeCheckpoint(
408
+ db: CartethyiaDatabase,
409
+ fileName: string,
410
+ byteOffset: number,
411
+ now: Date,
412
+ ): Promise<void> {
413
+ await db
414
+ .update(telemetryReconciliationState)
415
+ .set({ fileName, byteOffset, updatedAt: now })
416
+ .where(eq(telemetryReconciliationState.jobName, JOB_NAME));
417
+ }