cartethyia 2.7.1 → 2.7.3

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 (263) hide show
  1. package/.env.example +2 -0
  2. package/dist/dashboard/assets/{BannedPage-D-J75hBM.js → BannedPage-2TJD37Hb.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-2TJD37Hb.js.gz +0 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js +1 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-xefJNRmJ.js.gz +0 -0
  6. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js +1 -0
  7. package/dist/dashboard/assets/CliToolsPage-Rj9iHQ3F.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-QoJ6foxo.js → CombosPage-JEbYJFxw.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-JEbYJFxw.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-DpNRay4C.js → ConsoleLogPage-C3xx_inM.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-C3xx_inM.js.gz +0 -0
  12. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js +1 -0
  13. package/dist/dashboard/assets/CustomizationPage-Dw306gJU.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-jaBo1TWV.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-DubdQcvX.js → LoginPage-C4w7vea-.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-C4w7vea-.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BtPcAfot.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-BtPcAfot.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-CGC5gNs1.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js +4 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-DQcthNGt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js +1 -0
  25. package/dist/dashboard/assets/ProvidersPage-0OsXRGXe.js.gz +0 -0
  26. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js +9 -0
  27. package/dist/dashboard/assets/ProxyPage-DdPc8slb.js.gz +0 -0
  28. package/dist/dashboard/assets/{QuotaPage-ATpaxrbl.js → QuotaPage-Ci5O4BnH.js} +1 -1
  29. package/dist/dashboard/assets/QuotaPage-Ci5O4BnH.js.gz +0 -0
  30. package/dist/dashboard/assets/{SettingsPage-CETophpx.js → SettingsPage-CDZjTt87.js} +1 -1
  31. package/dist/dashboard/assets/SettingsPage-CDZjTt87.js.gz +0 -0
  32. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js +1 -0
  33. package/dist/dashboard/assets/SetupPage-BQS0Rx_u.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-CAK-EN9z.js → SortableList-BAL5npBC.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-BAL5npBC.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-B7W2_RhT.js → StudioPage-TP7voAQ0.js} +3 -3
  37. package/dist/dashboard/assets/StudioPage-TP7voAQ0.js.gz +0 -0
  38. package/dist/dashboard/assets/{UsagePage-6T4EFDjW.js → UsagePage-Cj_fEzhg.js} +13 -13
  39. package/dist/dashboard/assets/UsagePage-Cj_fEzhg.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-CUi6UXsR.js → api-keys-MbI7e7rt.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-MbI7e7rt.js.gz +0 -0
  42. package/dist/dashboard/assets/app-B2vyPoII.css +2 -0
  43. package/dist/dashboard/assets/app-B2vyPoII.css.gz +0 -0
  44. package/dist/dashboard/assets/arrow-up-59ms1iiT.js +1 -0
  45. package/dist/dashboard/assets/arrow-up-59ms1iiT.js.gz +0 -0
  46. package/dist/dashboard/assets/{assert-67xXH5rf.js → assert-C0YYuZJ4.js} +1 -1
  47. package/dist/dashboard/assets/assert-C0YYuZJ4.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-B_DMpMJN.js} +1 -1
  49. package/dist/dashboard/assets/badge-B_DMpMJN.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DMrMPmth.js} +1 -1
  51. package/dist/dashboard/assets/banner-DMrMPmth.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-Cca1XC6G.js → card-Be5Uzamx.js} +1 -1
  53. package/dist/dashboard/assets/card-Be5Uzamx.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-KvsWa1Jl.js → cli-tools-muFRphDu.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-muFRphDu.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DOfrVmsi.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DOfrVmsi.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-C9omdjQt.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C9omdjQt.js.gz +0 -0
  60. package/dist/dashboard/assets/drawer-Bm8WKaGY.js +1 -0
  61. package/dist/dashboard/assets/drawer-Bm8WKaGY.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-_xHo5x6r.js → entry-DS90gUks.js} +1 -1
  63. package/dist/dashboard/assets/entry-DS90gUks.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-BLBLxX0h.js → entry-j40LJotO.js} +3 -3
  65. package/dist/dashboard/assets/entry-j40LJotO.js.gz +0 -0
  66. package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-q5HwZ8nN.js} +1 -1
  67. package/dist/dashboard/assets/entry-q5HwZ8nN.js.gz +0 -0
  68. package/dist/dashboard/assets/{concurrency-BDF0ok7y.js → fingerprint-pattern-BWuHtbW4.js} +1 -1
  69. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  70. package/dist/dashboard/assets/{formatters-mpBPqAQ8.js → formatters-BTScrQpe.js} +1 -1
  71. package/dist/dashboard/assets/formatters-BTScrQpe.js.gz +0 -0
  72. package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-Orjuzemr.js} +1 -1
  73. package/dist/dashboard/assets/github-badge-Orjuzemr.js.gz +0 -0
  74. package/dist/dashboard/assets/{index-D6QzEC_n.js → index-cPrX5vn6.js} +2 -2
  75. package/dist/dashboard/assets/index-cPrX5vn6.js.gz +0 -0
  76. package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-Dvux8w-0.js} +1 -1
  77. package/dist/dashboard/assets/inline-Dvux8w-0.js.gz +0 -0
  78. package/dist/dashboard/assets/{input-C_ILAvBb.js → input-CU1Rgf3p.js} +1 -1
  79. package/dist/dashboard/assets/input-CU1Rgf3p.js.gz +0 -0
  80. package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-C_7A6H55.js} +1 -1
  81. package/dist/dashboard/assets/layout-C_7A6H55.js.gz +0 -0
  82. package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-CzFvogbj.js} +1 -1
  83. package/dist/dashboard/assets/list-row-CzFvogbj.js.gz +0 -0
  84. package/dist/dashboard/assets/{live-D0mm--ME.js → live-KaG15HLQ.js} +1 -1
  85. package/dist/dashboard/assets/live-KaG15HLQ.js.gz +0 -0
  86. package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-JJGQm8TD.js} +1 -1
  87. package/dist/dashboard/assets/mutation-JJGQm8TD.js.gz +0 -0
  88. package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-BNDZnsUE.js} +1 -1
  89. package/dist/dashboard/assets/numbers-BNDZnsUE.js.gz +0 -0
  90. package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-CLaNz8Xz.js} +1 -1
  91. package/dist/dashboard/assets/query-keys-CLaNz8Xz.js.gz +0 -0
  92. package/dist/dashboard/assets/{routing-BnIpi1IJ.js → routing-BEH32QOC.js} +1 -1
  93. package/dist/dashboard/assets/routing-BEH32QOC.js.gz +0 -0
  94. package/dist/dashboard/assets/{select-Cj5Nw43g.js → select-B3iTetHN.js} +1 -1
  95. package/dist/dashboard/assets/select-B3iTetHN.js.gz +0 -0
  96. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
  97. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
  98. package/dist/dashboard/assets/{settings-BAFhDgX8.js → settings-Dt3Eqbnq.js} +1 -1
  99. package/dist/dashboard/assets/settings-Dt3Eqbnq.js.gz +0 -0
  100. package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-KrOrm7JZ.js} +1 -1
  101. package/dist/dashboard/assets/stack-KrOrm7JZ.js.gz +0 -0
  102. package/dist/dashboard/assets/{state-CWoo7ty5.js → state-DLS7IDMy.js} +1 -1
  103. package/dist/dashboard/assets/state-DLS7IDMy.js.gz +0 -0
  104. package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-42ItvbAs.js} +1 -1
  105. package/dist/dashboard/assets/switch-42ItvbAs.js.gz +0 -0
  106. package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-Dmnfh1Ue.js} +1 -1
  107. package/dist/dashboard/assets/useMutation-Dmnfh1Ue.js.gz +0 -0
  108. package/dist/dashboard/index.html +3 -2
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +117 -115
  111. package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
  112. package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +22 -0
  113. package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
  114. package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
  115. package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
  116. package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
  117. package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
  118. package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +22 -0
  119. package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
  120. package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
  121. package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
  122. package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
  123. package/package.json +1 -1
  124. package/src/config/network.ts +1 -1
  125. package/src/console/api-keys/bucket-spend.ts +7 -7
  126. package/src/console/api-keys/contracts.ts +423 -410
  127. package/src/console/api-keys/routes.ts +118 -19
  128. package/src/console/backup/export-endpoint.ts +8 -11
  129. package/src/console/backup/routes.ts +40 -8
  130. package/src/console/backup/service.ts +11 -6
  131. package/src/console/backup/store.ts +384 -88
  132. package/src/console/cli-tools/contracts.ts +10 -4
  133. package/src/console/dashboard-assets.ts +16 -1
  134. package/src/console/domain-registration.ts +2 -0
  135. package/src/console/quota/account-quota-tenant.ts +8 -1
  136. package/src/console/quota/account-quota.ts +4 -1
  137. package/src/console/quota/native-identity.ts +189 -0
  138. package/src/console/remote-support/contracts.ts +12 -6
  139. package/src/console/remote-support/service.ts +2 -2
  140. package/src/console/share/share-router.ts +39 -6
  141. package/src/console/share/share-stats.ts +64 -25
  142. package/src/console/share/share-usage.ts +24 -7
  143. package/src/console/system-prompt/contracts.ts +102 -0
  144. package/src/console/system-prompt/routes.ts +165 -0
  145. package/src/main.ts +11 -0
  146. package/src/network/outbound-fetch.ts +281 -226
  147. package/src/network/pool/agent.ts +7 -0
  148. package/src/observability/event-query.ts +118 -7
  149. package/src/persistence/db-pg.ts +42 -0
  150. package/src/persistence/schema.ts +102 -0
  151. package/src/persistence/stores/api-key-store.ts +753 -581
  152. package/src/persistence/stores/share-store.ts +65 -13
  153. package/src/persistence/stores/telemetry-store.ts +57 -1
  154. package/src/persistence/system-prompt-store.ts +104 -0
  155. package/src/protocol/reasoning.ts +65 -1
  156. package/src/protocol/request/chat.ts +1 -0
  157. package/src/providers/default-registry.ts +1 -0
  158. package/src/providers/integrations/antigravity/antigravity.ts +20 -23
  159. package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
  160. package/src/providers/integrations/buddy/buddy-media.ts +5 -1
  161. package/src/providers/integrations/buddy/codebuddy/provider.ts +5 -1
  162. package/src/providers/integrations/buddy/workbuddy/provider.ts +5 -1
  163. package/src/providers/integrations/cline/cline.ts +7 -0
  164. package/src/providers/integrations/github/github.ts +20 -1
  165. package/src/providers/integrations/hermes.ts +5 -1
  166. package/src/providers/integrations/kilo/kilo.ts +4 -0
  167. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  168. package/src/providers/integrations/openrouter/provider.ts +4 -0
  169. package/src/providers/integrations/qoder/qoder-checkin.ts +224 -51
  170. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +156 -0
  171. package/src/providers/integrations/qoder/qoder-oauth.ts +24 -4
  172. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  173. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  174. package/src/providers/integrations/qoder/qoder-umid.ts +288 -0
  175. package/src/providers/integrations/qoder/qoder.ts +1375 -1641
  176. package/src/providers/operations/account-error-classification.ts +1682 -1660
  177. package/src/providers/operations/account-health-service.ts +619 -621
  178. package/src/providers/operations/client-versions.ts +37 -22
  179. package/src/providers/operations/provider-catalog-service.ts +11 -1
  180. package/src/providers/provider-contracts.ts +15 -2
  181. package/src/security/access-control.ts +19 -0
  182. package/src/security/api-key-auth.ts +14 -1
  183. package/src/security/cli-client-fingerprint.ts +9 -2
  184. package/src/transport/dispatch/empty-stream.ts +224 -224
  185. package/src/transport/dispatch/proxy-request.ts +31 -2
  186. package/src/transport/dispatch/streaming/stream.ts +16 -1
  187. package/src/transport/dispatch/upstream.ts +10 -0
  188. package/src/transport/failure-policy.ts +33 -0
  189. package/src/transport/request/preparer.ts +17 -1
  190. package/src/transport/request/system-prompt-injection.ts +89 -0
  191. package/src/transport/request/tenant-preferences.ts +43 -0
  192. package/src/transport/routing/admission.ts +13 -3
  193. package/src/transport/routing/route-catalog.ts +41 -12
  194. package/src/transport/routing/route-model.ts +53 -0
  195. package/src/transport/routing/router.ts +306 -228
  196. package/src/transport/surface/responses/encode.ts +2 -3
  197. package/src/transport/surface/responses/parse.ts +49 -7
  198. package/src/types/undici.d.ts +33 -0
  199. package/src/workers/daily-checkin.ts +2 -0
  200. package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
  201. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js +0 -1
  202. package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
  203. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js +0 -1
  204. package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
  205. package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
  206. package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
  207. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js +0 -1
  208. package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
  209. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +0 -1
  210. package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
  211. package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
  212. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  213. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +0 -1
  214. package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
  215. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +0 -4
  216. package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js.gz +0 -0
  217. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js +0 -1
  218. package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
  219. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +0 -9
  220. package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
  221. package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
  222. package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
  223. package/dist/dashboard/assets/SetupPage-BdgECjqq.js +0 -1
  224. package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
  225. package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
  226. package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
  227. package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
  228. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  229. package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
  230. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  231. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  232. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  233. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  234. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  235. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  236. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  237. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  238. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  239. package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
  240. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  241. package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
  242. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  243. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  244. package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
  245. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  246. package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
  247. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  248. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  249. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  250. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  251. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  252. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  253. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  254. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  255. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  256. package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
  257. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  258. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  259. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  260. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  261. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  262. /package/dist/dashboard/assets/{app-BaWd0puM.js → app-wvwCi4Eh.js} +0 -0
  263. /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-wvwCi4Eh.js.gz} +0 -0
@@ -1,621 +1,619 @@
1
- import { randomUUID } from "node:crypto";
2
- import { healthEventBuffer } from "../../persistence/health-event-flush";
3
- import { and, count, desc, eq, inArray, isNotNull, lte, sql } from "drizzle-orm";
4
- import { affectedRows, pruneExpiredCooldowns } from "../../persistence/dialect";
5
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
6
- import { healthEvents, providerAccounts } from "../../persistence/schema";
7
- import {
8
- COOLDOWN_RECOVERY_BATCH,
9
- accountDeadlineMs,
10
- classifyAccountError,
11
- escalateFallbackCooldown,
12
- latestDeadlineMs,
13
- unsupportedModelCooldownMs,
14
- } from "./account-error-classification";
15
- import type { AccountErrorClassification, AccountFailureEvidence } from "./account-error-classification";
16
-
17
- export { classifyAccountError, unsupportedModelCooldownMs } from "./account-error-classification";
18
- export type {
19
- AccountErrorCategory,
20
- AccountErrorClassification,
21
- AccountFailureEvidence,
22
- AccountFailureOrigin,
23
- AccountFailureScope,
24
- } from "./account-error-classification";
25
-
26
- export interface AccountHealthEventRecord {
27
- readonly id: string;
28
- readonly accountId: string;
29
- readonly fromStatus: string | null;
30
- readonly toStatus: string;
31
- readonly reason: string | null;
32
- readonly errorCategory: string | null;
33
- readonly modelId: string | null;
34
- readonly createdAt: string;
35
- }
36
-
37
- /** Persists one classified failure to the account row and appends a health event. */
38
- export async function recordAccountFailure(
39
- db: CartethyiaDatabase,
40
- accountId: string,
41
- error: unknown,
42
- options?: AccountFailureEvidence,
43
- ): Promise<AccountErrorClassification | null> {
44
- const classification = classifyAccountError(error, options);
45
- /**
46
- * Logged even when the classification does not mutate the account.
47
- *
48
- * `rate_limit_transient` seen on a stream frame carries no credential
49
- * detail, so it classifies at provider scope and `mutatesAccount` is false
50
- * — nothing is cooled and the account stays `active`. That part is correct:
51
- * the upstream's own remedy ("switch to the other models") only works if the
52
- * account keeps serving. But this returned without writing anything, so a
53
- * rate-limited request showed up in the request log while the account's
54
- * health log stayed empty — a symptom with no recorded cause.
55
- *
56
- * The row is the whole point of this branch: it records the failure without
57
- * moving the account. Both status sides are the classification's own, so the
58
- * dialog names the cause without claiming a transition that never happened.
59
- */
60
- if (!classification.mutatesAccount) {
61
- void writeHealthEvent(db, {
62
- entityKind: "account",
63
- accountId,
64
- fromStatus: classification.status,
65
- toStatus: classification.status,
66
- reason: classification.reason,
67
- errorCategory: classification.category,
68
- modelId: options?.modelId ?? null,
69
- createdAt: new Date(),
70
- }).catch(() => undefined);
71
- return classification;
72
- }
73
-
74
- const persist = async (client: CartethyiaDatabase): Promise<AccountErrorClassification | null> => {
75
- // Projected, not a bare `select()`: this runs on *every* attempt outcome,
76
- // and a full row drags `credential_ciphertext` (the largest thing stored
77
- // per account) and `auth_state` over the wire to read a status and a
78
- // counter. `RouteCatalogRepository` already projects for the same reason.
79
- const accountRows = await client
80
- .select({
81
- status: providerAccounts.status,
82
- staticToken: providerAccounts.staticToken,
83
- consecutiveFailures: providerAccounts.consecutiveFailures,
84
- modelCooldowns: providerAccounts.modelCooldowns,
85
- // Read only for the unsupported-model ladder; NULL means "never
86
- // counted", which is the common case and must stay cheap.
87
- modelBlockHits: providerAccounts.modelBlockHits,
88
- cooldownUntil: providerAccounts.cooldownUntil,
89
- })
90
- .from(providerAccounts)
91
- .where(eq(providerAccounts.id, accountId))
92
- .limit(1);
93
- const account = accountRows[0];
94
- if (!account || account.status === "disabled") return null;
95
-
96
- // Re-classify with the account's own static-token flag. The caller's
97
- // evidence is request-scoped and does not carry it, but an auth rejection
98
- // against a static token must cool the account down rather than park it —
99
- // there is no refresh to run, and the token may still be valid.
100
- const effective = account.staticToken
101
- ? classifyAccountError(error, { ...options, staticToken: true })
102
- : classification;
103
-
104
- const fromStatus = account.status;
105
- const now = new Date();
106
-
107
- const modelId = options?.modelId;
108
- // Every category here is a verdict about one (account, model) pair rather
109
- // than about the credential, so all three back off that pair and leave the
110
- // account routable for every other model. `model_unreachable` joins the two
111
- // throttles on the same reasoning: an upstream that reports the model does
112
- // not exist for this account will report it again on the next request.
113
- const isThrottle =
114
- effective.category === "rate_limit_transient" ||
115
- effective.category === "model_capacity" ||
116
- effective.category === "model_unreachable";
117
- const shouldModelCooldown = modelId !== undefined && isThrottle;
118
- // The account-wide streak, escalated for a provider-stated window. Read once
119
- // so the model-scoped and account-wide paths below cannot disagree about it:
120
- // this is the count *before* this failure, which the UPDATE below increments.
121
- const streak = Math.max(0, account.consecutiveFailures ?? 0);
122
- const escalatedCooldownMs = escalateFallbackCooldown(
123
- effective.cooldownMs,
124
- effective.providerStated === true,
125
- streak,
126
- );
127
-
128
- // The unsupported-model ladder, computed before the branch: the classifier
129
- // could only state the base window because the authoritative hit count
130
- // lives on the row, and it is read here. `hits` is the new total for this
131
- // pair, so the first report is 1 and gets the base window.
132
- const blockHits = (account.modelBlockHits as Record<string, number> | null) ?? {};
133
- const unsupportedHits =
134
- effective.modelUnsupported === true && modelId !== undefined
135
- ? Math.max(1, (blockHits[modelId] ?? 0) + 1)
136
- : undefined;
137
- // An unsupported-model window is stated by *us*, not by the provider, so it
138
- // would otherwise be multiplied a second time by `escalateFallbackCooldown`
139
- // (which reads `consecutiveFailures`, an account-wide counter). The two
140
- // ladders are different things: one counts how many times this pair was
141
- // reported unsupported, the other how many failures this account produced.
142
- const unsupportedCooldownMs =
143
- unsupportedHits === undefined ? undefined : unsupportedModelCooldownMs(unsupportedHits);
144
-
145
- if (shouldModelCooldown) {
146
- const existing = (account.modelCooldowns as Record<string, string> | null) ?? {};
147
- // Monotonic: never move an in-force window earlier (see `latestDeadlineMs`).
148
- // A provider-stated deadline wins outright because it is already the later
149
- // of the two whenever the provider really parked the pair longer.
150
- const nextUntil = new Date(
151
- latestDeadlineMs(
152
- effective.retryAt?.getTime(),
153
- Date.now() + (unsupportedCooldownMs ?? escalatedCooldownMs),
154
- existing[modelId] ? Date.parse(existing[modelId]!) : undefined,
155
- ),
156
- ).toISOString();
157
- const previousUntil = existing[modelId];
158
- // A re-statement is not an event: what the audit row reports is the
159
- // *entry* into a cooling episode, and the deadline itself is not a
160
- // column — so a row can only ever say "this pair started cooling for
161
- // this reason". The upstream answers the same "Try again in 11h" on
162
- // every retry (and an unstated reset is recomputed from now each time),
163
- // so writing per failure produced seven identical rows in twenty minutes
164
- // and read as an account flapping when nothing had changed. The entry is
165
- // still refreshed below — its error fields are what the dialog explains
166
- // the cooldown with — while an entry that is still in force suppresses
167
- // the duplicate row.
168
- const alreadyCooling =
169
- previousUntil !== undefined && new Date(previousUntil).getTime() > Date.now();
170
- // `status`/`cooldownUntil` stay untouched: only this (account, model)
171
- // pair backs off, and the account remains routable for every other model.
172
- // The error fields ARE written, because they are what provider detail and
173
- // the health dialog read to explain *why* a model is cooling — without
174
- // them the row showed "1 model cooling" with no reason at all.
175
- //
176
- // The streak advances here too. Without it the most common throttle — a
177
- // model-scoped rate limit — never escalated, because the counter was only
178
- // incremented on the account-wide path below, and `consecutiveFailures`
179
- // stays honest about what it always claimed to count: how many failures in
180
- // a row this account has just produced.
181
- await client
182
- .update(providerAccounts)
183
- .set({
184
- modelCooldowns: { ...existing, [modelId]: nextUntil },
185
- // Counted only for the unsupported-model ladder, and only when this
186
- // failure is that verdict — so a rate limit or a capacity refusal
187
- // never advances it. `model_cooldowns`' own shape is untouched, so
188
- // routing and the console keep parsing it exactly as before.
189
- ...(unsupportedHits === undefined
190
- ? {}
191
- : { modelBlockHits: { ...blockHits, [modelId]: unsupportedHits } }),
192
- consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1`,
193
- lastError: effective.reason,
194
- lastErrorCategory: effective.category,
195
- lastErrorAt: now,
196
- })
197
- .where(eq(providerAccounts.id, accountId));
198
- if (!alreadyCooling) {
199
- await writeHealthEvent(client, {
200
- entityKind: "account",
201
- accountId,
202
- // The account's own status, on both sides: a model-scoped throttle
203
- // never moved it, and writing the classification's status here
204
- // claimed an `active → cooldown` transition that never happened — the
205
- // dialog showed "Account status: ACTIVE" above a list of transitions
206
- // to `cooldown`. The row still names the model and the reason, which
207
- // is what makes it worth keeping.
208
- fromStatus: fromStatus as "active" | "cooldown" | "disabled",
209
- toStatus: fromStatus as "active" | "cooldown" | "disabled",
210
- reason: effective.reason,
211
- errorCategory: effective.category,
212
- modelId: modelId ?? null,
213
- createdAt: now,
214
- });
215
- }
216
- // `status` and `cooldownUntil` are untouched on this path — only the
217
- // (account, model) pair backed off — so the snapshot is still accurate
218
- // and must not be dropped.
219
- return { ...classification, routesVisible: false };
220
- }
221
-
222
- await client
223
- .update(providerAccounts)
224
- .set({
225
- status: effective.status,
226
- consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1`,
227
- lastError: effective.reason,
228
- lastErrorCategory: effective.category,
229
- lastErrorAt: now,
230
- // Monotonic against the account's own in-force deadline, for the same
231
- // reason as the model path: an unclassified 60-second failure must not
232
- // release an account the provider parked for hours.
233
- //
234
- // A `0` duration contributes no candidate rather than stamping *now*
235
- // (see `accountDeadlineMs`): the one state that asks for it is a
236
- // terminal automatic disable, and an elapsed deadline there would hand
237
- // the row straight back to the recovery sweep.
238
- cooldownUntil: accountDeadlineMs([
239
- effective.retryAt?.getTime(),
240
- escalatedCooldownMs > 0 ? Date.now() + escalatedCooldownMs : undefined,
241
- account.cooldownUntil?.getTime?.(),
242
- ]),
243
- })
244
- .where(eq(providerAccounts.id, accountId));
245
-
246
- {
247
- await writeHealthEvent(client, {
248
- entityKind: "account",
249
- accountId,
250
- fromStatus: fromStatus as "active" | "cooldown" | "disabled",
251
- toStatus: effective.status,
252
- reason: effective.reason,
253
- errorCategory: effective.category,
254
- modelId: modelId ?? null,
255
- createdAt: now,
256
- });
257
- }
258
- // This path wrote `status` and `cooldownUntil`, both of which the snapshot
259
- // projects, so routing has to see it.
260
- return { ...classification, routesVisible: true };
261
- };
262
-
263
- try {
264
- // Kept as a capability probe, not a correctness guard: this function is
265
- // reachable with a partially-implemented database handle (the dispatch
266
- // tests pass a narrow double), and a transaction is an optimization here,
267
- // not a requirement — the same writes are correct without one.
268
- if (typeof db.transaction === "function") {
269
- return await db.transaction((tx) => persist(tx as CartethyiaDatabase));
270
- }
271
- return await persist(db);
272
- } catch {
273
- return null;
274
- }
275
- }
276
-
277
- /** Clears failure state after a successful dispatch; true when the account changed. */
278
- export async function recordAccountSuccess(
279
- db: CartethyiaDatabase,
280
- accountId: string,
281
- ): Promise<boolean> {
282
- const mutate = async (client: CartethyiaDatabase): Promise<boolean> => {
283
- // `recordAccountSuccess` runs on every successful dispatch, and for a
284
- // healthy account `hadFailures` is false — the row was fetched purely to
285
- // decide nothing. Projected so that no-op path does not pull the
286
- // credential ciphertext across the wire.
287
- const rows = await client
288
- .select({
289
- status: providerAccounts.status,
290
- consecutiveFailures: providerAccounts.consecutiveFailures,
291
- // Read only to decide whether there is anything to clear: the common
292
- // case (no unsupported-model hit ever recorded) must not write.
293
- modelBlockHits: providerAccounts.modelBlockHits,
294
- })
295
- .from(providerAccounts)
296
- .where(eq(providerAccounts.id, accountId))
297
- .limit(1);
298
- const account = rows[0];
299
- if (!account || account.status === "disabled") return false;
300
- const fromStatus = account.status;
301
- const hadFailures = (account.consecutiveFailures ?? 0) > 0 || account.status !== "active";
302
- const now = new Date();
303
- // The unsupported-model escalation restarts after a success: a model that
304
- // just served is not "not supported", so the next report of it must start
305
- // from the base window again rather than from wherever the ladder had
306
- // reached. `modelCooldowns` is deliberately NOT cleared — an in-force
307
- // window the provider stated survives, which is the existing behavior and
308
- // the reference gateways' too.
309
- const blockHits = account.modelBlockHits as Record<string, number> | null;
310
- const hasBlockHits = blockHits !== null && Object.keys(blockHits).length > 0;
311
- await client
312
- .update(providerAccounts)
313
- .set({
314
- status: "active",
315
- consecutiveFailures: 0,
316
- cooldownUntil: null,
317
- lastSuccessAt: now,
318
- ...(hadFailures ? { lastRecoveredAt: now } : {}),
319
- ...(hasBlockHits ? { modelBlockHits: {} } : {}),
320
- })
321
- .where(eq(providerAccounts.id, accountId));
322
- if (hadFailures && typeof client.insert === "function") {
323
- await client.insert(healthEvents).values({
324
- id: randomUUID(),
325
- entityKind: "account",
326
- accountId,
327
- fromStatus: fromStatus as "active" | "cooldown" | "disabled",
328
- toStatus: "active",
329
- reason: "Request dispatched successfully",
330
- errorCategory: null,
331
- createdAt: now,
332
- });
333
- }
334
- return hadFailures;
335
- };
336
- try {
337
- if (typeof db.transaction === "function") {
338
- return await db.transaction((tx) => mutate(tx as CartethyiaDatabase));
339
- }
340
- return await mutate(db);
341
- } catch {
342
- return false;
343
- }
344
- }
345
-
346
- /** Operator-initiated recovery: forces the account back to active and logs the transition. */
347
- export async function recoverAccount(
348
- db: CartethyiaDatabase,
349
- accountId: string,
350
- reason = "manual_operator_recovery",
351
- ): Promise<boolean> {
352
- const recover = async (client: CartethyiaDatabase): Promise<boolean> => {
353
- const accountRows = await client
354
- .select()
355
- .from(providerAccounts)
356
- .where(eq(providerAccounts.id, accountId))
357
- .limit(1);
358
- const account = accountRows[0];
359
- if (!account) return false;
360
- const fromStatus = account.status;
361
- const now = new Date();
362
- await client
363
- .update(providerAccounts)
364
- .set({
365
- status: "active",
366
- consecutiveFailures: 0,
367
- cooldownUntil: null,
368
- lastRecoveredAt: now,
369
- // Recovery is the operator asserting the account works now, so it must
370
- // clear EVERY routing exclusion, not just the account-wide ones. A
371
- // per-model cooldown entry excludes the (account, model) pair in
372
- // `route-catalog.ts`, so leaving it behind kept the account blocked for
373
- // that model through `/v1` even though the account read `active` and a
374
- // direct probe (which addresses the account by id, bypassing routing
375
- // eligibility) succeeded. Clearing the error fields for the same reason:
376
- // a stale reason on a recovered row misreports the current state.
377
- modelCooldowns: {},
378
- lastError: null,
379
- lastErrorCategory: null,
380
- lastErrorAt: null,
381
- })
382
- .where(eq(providerAccounts.id, accountId));
383
- {
384
- await writeHealthEvent(client, {
385
- entityKind: "account",
386
- accountId,
387
- fromStatus: fromStatus as "active" | "cooldown" | "disabled",
388
- toStatus: "active",
389
- reason,
390
- errorCategory: null,
391
- createdAt: now,
392
- });
393
- }
394
- return true;
395
- };
396
- try {
397
- if (typeof db.transaction === "function") {
398
- return await db.transaction((tx) => recover(tx as CartethyiaDatabase));
399
- }
400
- return await recover(db);
401
- } catch {
402
- return false;
403
- }
404
- }
405
-
406
- /** Recovers accounts whose cooldown elapsed and prunes expired per-model keys. */
407
- export async function sweepExpiredCooldowns(db: CartethyiaDatabase): Promise<number> {
408
- const recover = async (client: CartethyiaDatabase): Promise<number> => {
409
- const now = new Date();
410
- const nowIso = now.toISOString();
411
- const expired = await client
412
- .select({
413
- id: providerAccounts.id,
414
- status: providerAccounts.status,
415
- })
416
- .from(providerAccounts)
417
- .where(
418
- // An ordinary cooldown, recovered as soon as its window elapses.
419
- and(
420
- eq(providerAccounts.status, "cooldown"),
421
- isNotNull(providerAccounts.cooldownUntil),
422
- lte(providerAccounts.cooldownUntil, now),
423
- ),
424
- )
425
- // One pass recovers a bounded batch. The sweep runs every 30s, so a
426
- // backlog larger than this drains over successive ticks instead of one
427
- // statement covering every expired row — that statement holds its locks
428
- // for as long as the backlog takes.
429
- .limit(COOLDOWN_RECOVERY_BATCH);
430
- if (expired.length > 0) {
431
- await client
432
- .update(providerAccounts)
433
- .set({
434
- status: "active",
435
- consecutiveFailures: 0,
436
- cooldownUntil: null,
437
- lastRecoveredAt: now,
438
- // Cleared with the state it described. A recovered row that kept
439
- // `auth_invalidated` still rendered the console's "Re-login required"
440
- // pill above an `active` status, so the account read healthy to
441
- // routing and broken to the operator at the same time.
442
- lastErrorCategory: null,
443
- })
444
- .where(inArray(providerAccounts.id, expired.map((account) => account.id)));
445
- if (typeof client.insert === "function") {
446
- for (let offset = 0; offset < expired.length; offset += 500) {
447
- await client.insert(healthEvents).values(
448
- expired.slice(offset, offset + 500).map((account) => ({
449
- id: randomUUID(),
450
- entityKind: "account" as const,
451
- accountId: account.id,
452
- fromStatus: account.status,
453
- toStatus: "active" as const,
454
- reason: "Cooldown period elapsed — auto-recovered",
455
- errorCategory: null,
456
- createdAt: now,
457
- })),
458
- );
459
- }
460
- }
461
- }
462
-
463
- // Prune per-model cooldowns: remove entries whose ISO timestamp has passed.
464
- //
465
- // One set-based UPDATE rather than a SELECT followed by an UPDATE per row.
466
- // The per-row form issued one statement per account holding any cooldown —
467
- // on a deployment where many accounts are throttled at once that is the
468
- // sweep's dominant cost, and it all runs inside this transaction, holding
469
- // its locks for the whole pass. Recomputing the object in SQL does the same
470
- // work in one statement.
471
- //
472
- // `jsonb_typeof(...) = 'object'` guards the shape: a malformed value must
473
- // not abort the whole sweep. `IS DISTINCT FROM` keeps the update from
474
- // rewriting rows it
475
- // did not change, so the returned row count is the number of accounts whose
476
- // cooldown set actually shrank — the same thing the loop counted.
477
- const pruning = pruneExpiredCooldowns(sql`${providerAccounts.modelCooldowns}`, sql`${nowIso}`);
478
- const pruned = await client
479
- .update(providerAccounts)
480
- .set({ modelCooldowns: pruning.value })
481
- .where(pruning.changed);
482
- return expired.length + affectedRows(pruned);
483
- };
484
- try {
485
- if (typeof db.transaction === "function") {
486
- return await db.transaction((tx) => recover(tx as CartethyiaDatabase));
487
- }
488
- return await recover(db);
489
- } catch {
490
- return 0;
491
- }
492
- }
493
-
494
- /**
495
- * Single attempt health reporter: one home for the success/failure writes
496
- * every dispatch attempt ends with. Success records recovery; failure
497
- * records with caller-supplied evidence (origin/scope classification plus
498
- * model id). A mutating write invalidates the route snapshot so the next
499
- * plan sees fresh health. Never throws — health must not fail requests.
500
- */
501
- export interface AttemptHealthReport {
502
- readonly accountId: string | undefined;
503
- readonly modelId?: string;
504
- readonly error?: unknown;
505
- readonly evidence?: AccountFailureEvidence;
506
- readonly snapshotService?: { invalidate(): unknown };
507
- }
508
-
509
- /**
510
- * Writes one health audit row, off the request path when a buffer is installed.
511
- *
512
- * `health_events` is append-only and read by nobody in the request path, so it
513
- * does not need to land before the request finishes — unlike the
514
- * `provider_accounts` UPDATE beside it, which routing reads and which therefore
515
- * stays synchronous and transactional.
516
- */
517
- async function writeHealthEvent(
518
- client: CartethyiaDatabase,
519
- row: {
520
- readonly entityKind: "account";
521
- readonly accountId: string;
522
- readonly fromStatus: string;
523
- readonly toStatus: string;
524
- readonly reason: string;
525
- readonly errorCategory: string | null;
526
- readonly modelId?: string | null;
527
- readonly createdAt: Date;
528
- },
529
- ): Promise<void> {
530
- const buffer = healthEventBuffer();
531
- if (buffer !== undefined) {
532
- buffer.enqueue({ ...row, modelId: row.modelId ?? null });
533
- return;
534
- }
535
- if (typeof client.insert !== "function") return;
536
- await client.insert(healthEvents).values({ id: randomUUID(), ...row } as never);
537
- }
538
-
539
- export async function reportAttemptOutcome(
540
- db: CartethyiaDatabase,
541
- report: AttemptHealthReport,
542
- ): Promise<void> {
543
- if (!report.accountId) return;
544
- if (report.error === undefined) {
545
- const recovered = await recordAccountSuccess(db, report.accountId).catch(() => false);
546
- if (recovered) await report.snapshotService?.invalidate();
547
- return;
548
- }
549
- const classification = await recordAccountFailure(db, report.accountId, report.error, {
550
- ...report.evidence,
551
- ...(report.modelId === undefined ? {} : { modelId: report.modelId }),
552
- }).catch(() => null);
553
- // Gated on `routesVisible`, not on `mutatesAccount`: a failure that only
554
- // cooled one (account, model) pair, or only advanced the streak, changed
555
- // nothing routing reads. Invalidating for those rebuilt an 11-SELECT snapshot
556
- // on the next request, and under a failure wave each rebuild fed the next
557
- // invalidation.
558
- if (classification?.routesVisible) await report.snapshotService?.invalidate();
559
- }
560
-
561
- export async function listAccountHealthEvents(
562
- db: CartethyiaDatabase,
563
- accountId: string,
564
- limit = 50,
565
- ): Promise<readonly AccountHealthEventRecord[]> {
566
- try {
567
- const rows = await db
568
- .select()
569
- .from(healthEvents)
570
- .where(and(eq(healthEvents.entityKind, "account"), eq(healthEvents.accountId, accountId)))
571
- .orderBy(desc(healthEvents.createdAt))
572
- .limit(limit);
573
-
574
- return rows.map((r) => ({
575
- id: r.id,
576
- accountId: r.accountId as string,
577
- fromStatus: r.fromStatus,
578
- toStatus: r.toStatus,
579
- modelId: r.modelId ?? null,
580
- reason: r.reason,
581
- errorCategory: r.errorCategory,
582
- createdAt: r.createdAt.toISOString(),
583
- }));
584
- } catch {
585
- return [];
586
- }
587
- }
588
-
589
- /** Every stored row across accounts and pools — the size the purge offers to delete. */
590
- export async function countAllHealthEvents(db: CartethyiaDatabase): Promise<number> {
591
- try {
592
- const rows = await db.select({ total: count() }).from(healthEvents);
593
- return rows[0]?.total ?? 0;
594
- } catch {
595
- return 0;
596
- }
597
- }
598
-
599
- /**
600
- * Deletes stored health & error rows.
601
- *
602
- * Unlike the read above, a failed delete surfaces: an operator who purged
603
- * believes the trail is gone, so "it failed" must not read as "it succeeded
604
- * with zero rows". Callers own the flush-buffer barrier (`drain`) so a queued
605
- * write cannot land after these rows are gone.
606
- */
607
- export async function deleteAccountHealthEvents(
608
- db: CartethyiaDatabase,
609
- accountId: string,
610
- ): Promise<number> {
611
- const rows = await db
612
- .delete(healthEvents)
613
- .where(and(eq(healthEvents.entityKind, "account"), eq(healthEvents.accountId, accountId)))
614
- .returning({ id: healthEvents.id });
615
- return rows.length;
616
- }
617
-
618
- export async function deleteAllHealthEvents(db: CartethyiaDatabase): Promise<number> {
619
- const rows = await db.delete(healthEvents).returning({ id: healthEvents.id });
620
- return rows.length;
621
- }
1
+ import { randomUUID } from "node:crypto";
2
+ import { healthEventBuffer } from "../../persistence/health-event-flush";
3
+ import { and, count, desc, eq, inArray, isNotNull, lte, sql } from "drizzle-orm";
4
+ import { affectedRows, pruneExpiredCooldowns } from "../../persistence/dialect";
5
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
6
+ import { healthEvents, providerAccounts } from "../../persistence/schema";
7
+ import {
8
+ COOLDOWN_RECOVERY_BATCH,
9
+ accountDeadlineMs,
10
+ classifyAccountError,
11
+ escalateFallbackCooldown,
12
+ latestDeadlineMs,
13
+ unsupportedModelCooldownMs,
14
+ } from "./account-error-classification";
15
+ import type { AccountErrorClassification, AccountFailureEvidence } from "./account-error-classification";
16
+
17
+ export { classifyAccountError, unsupportedModelCooldownMs } from "./account-error-classification";
18
+ export type {
19
+ AccountErrorCategory,
20
+ AccountErrorClassification,
21
+ AccountFailureEvidence,
22
+ AccountFailureOrigin,
23
+ AccountFailureScope,
24
+ } from "./account-error-classification";
25
+
26
+ export interface AccountHealthEventRecord {
27
+ readonly id: string;
28
+ readonly accountId: string;
29
+ readonly fromStatus: string | null;
30
+ readonly toStatus: string;
31
+ readonly reason: string | null;
32
+ readonly errorCategory: string | null;
33
+ readonly modelId: string | null;
34
+ readonly createdAt: string;
35
+ }
36
+
37
+ /** Persists one classified failure to the account row and appends a health event. */
38
+ export async function recordAccountFailure(
39
+ db: CartethyiaDatabase,
40
+ accountId: string,
41
+ error: unknown,
42
+ options?: AccountFailureEvidence,
43
+ ): Promise<AccountErrorClassification | null> {
44
+ const classification = classifyAccountError(error, options);
45
+ /**
46
+ * Logged even when the classification does not mutate the account.
47
+ *
48
+ * `rate_limit_transient` seen on a stream frame carries no credential
49
+ * detail, so it classifies at provider scope and `mutatesAccount` is false
50
+ * — nothing is cooled and the account stays `active`. That part is correct:
51
+ * the upstream's own remedy ("switch to the other models") only works if the
52
+ * account keeps serving. But this returned without writing anything, so a
53
+ * rate-limited request showed up in the request log while the account's
54
+ * health log stayed empty — a symptom with no recorded cause.
55
+ *
56
+ * The row is the whole point of this branch: it records the failure without
57
+ * moving the account. Both status sides are the classification's own, so the
58
+ * dialog names the cause without claiming a transition that never happened.
59
+ */
60
+ if (!classification.mutatesAccount) {
61
+ void writeHealthEvent(db, {
62
+ entityKind: "account",
63
+ accountId,
64
+ fromStatus: classification.status,
65
+ toStatus: classification.status,
66
+ reason: classification.reason,
67
+ errorCategory: classification.category,
68
+ modelId: options?.modelId ?? null,
69
+ createdAt: new Date(),
70
+ }).catch(() => undefined);
71
+ return classification;
72
+ }
73
+
74
+ const persist = async (client: CartethyiaDatabase): Promise<AccountErrorClassification | null> => {
75
+ // Projected, not a bare `select()`: this runs on *every* attempt outcome,
76
+ // and a full row drags `credential_ciphertext` (the largest thing stored
77
+ // per account) and `auth_state` over the wire to read a status and a
78
+ // counter. `RouteCatalogRepository` already projects for the same reason.
79
+ const accountRows = await client
80
+ .select({
81
+ status: providerAccounts.status,
82
+ staticToken: providerAccounts.staticToken,
83
+ consecutiveFailures: providerAccounts.consecutiveFailures,
84
+ modelCooldowns: providerAccounts.modelCooldowns,
85
+ // Read only for the unsupported-model ladder; NULL means "never
86
+ // counted", which is the common case and must stay cheap.
87
+ modelBlockHits: providerAccounts.modelBlockHits,
88
+ cooldownUntil: providerAccounts.cooldownUntil,
89
+ })
90
+ .from(providerAccounts)
91
+ .where(eq(providerAccounts.id, accountId))
92
+ .limit(1);
93
+ const account = accountRows[0];
94
+ if (!account || account.status === "disabled") return null;
95
+
96
+ // Re-classify with the account's own static-token flag. The caller's
97
+ // evidence is request-scoped and does not carry it, but an auth rejection
98
+ // against a static token must cool the account down rather than park it —
99
+ // there is no refresh to run, and the token may still be valid.
100
+ const effective = account.staticToken
101
+ ? classifyAccountError(error, { ...options, staticToken: true })
102
+ : classification;
103
+
104
+ const fromStatus = account.status;
105
+ const now = new Date();
106
+
107
+ const modelId = options?.modelId;
108
+ // Every category here is a verdict about one (account, model) pair rather
109
+ // than about the credential, so all three back off that pair and leave the
110
+ // account routable for every other model. `model_unreachable` joins the two
111
+ // throttles on the same reasoning: an upstream that reports the model does
112
+ // not exist for this account will report it again on the next request.
113
+ const isThrottle =
114
+ effective.category === "rate_limit_transient" ||
115
+ effective.category === "model_capacity" ||
116
+ effective.category === "model_unreachable";
117
+ const shouldModelCooldown = modelId !== undefined && isThrottle;
118
+ // The account-wide streak, escalated for a provider-stated window. Read once
119
+ // so the model-scoped and account-wide paths below cannot disagree about it:
120
+ // this is the count *before* this failure, which the UPDATE below increments.
121
+ const streak = Math.max(0, account.consecutiveFailures ?? 0);
122
+ const escalatedCooldownMs = escalateFallbackCooldown(
123
+ effective.cooldownMs,
124
+ effective.providerStated === true,
125
+ streak,
126
+ );
127
+
128
+ // The unsupported-model ladder, computed before the branch: the classifier
129
+ // could only state the base window because the authoritative hit count
130
+ // lives on the row, and it is read here. `hits` is the new total for this
131
+ // pair, so the first report is 1 and gets the base window.
132
+ const blockHits = (account.modelBlockHits as Record<string, number> | null) ?? {};
133
+ const unsupportedHits =
134
+ effective.modelUnsupported === true && modelId !== undefined
135
+ ? Math.max(1, (blockHits[modelId] ?? 0) + 1)
136
+ : undefined;
137
+ // An unsupported-model window is stated by *us*, not by the provider, so it
138
+ // would otherwise be multiplied a second time by `escalateFallbackCooldown`
139
+ // (which reads `consecutiveFailures`, an account-wide counter). The two
140
+ // ladders are different things: one counts how many times this pair was
141
+ // reported unsupported, the other how many failures this account produced.
142
+ const unsupportedCooldownMs =
143
+ unsupportedHits === undefined ? undefined : unsupportedModelCooldownMs(unsupportedHits);
144
+
145
+ if (shouldModelCooldown) {
146
+ const existing = (account.modelCooldowns as Record<string, string> | null) ?? {};
147
+ // Monotonic: never move an in-force window earlier (see `latestDeadlineMs`).
148
+ // A provider-stated deadline wins outright because it is already the later
149
+ // of the two whenever the provider really parked the pair longer.
150
+ const previousDeadline = existing[modelId] === undefined ? undefined : Date.parse(existing[modelId]);
151
+ const nextDeadline = latestDeadlineMs(
152
+ effective.retryAt?.getTime(),
153
+ Date.now() + (unsupportedCooldownMs ?? escalatedCooldownMs),
154
+ previousDeadline,
155
+ );
156
+ const nextUntil = new Date(nextDeadline).toISOString();
157
+ const previousUntil = existing[modelId];
158
+ // A re-statement is not an event: what the audit row reports is the
159
+ // *entry* into a cooling episode, and the deadline itself is not a
160
+ // column — so a row can only ever say "this pair started cooling for
161
+ // this reason". The upstream answers the same "Try again in 11h" on
162
+ // every retry (and an unstated reset is recomputed from now each time),
163
+ // so writing per failure produced seven identical rows in twenty minutes
164
+ // and read as an account flapping when nothing had changed. The entry is
165
+ // still refreshed below — its error fields are what the dialog explains
166
+ // the cooldown with — while an entry that is still in force suppresses
167
+ // the duplicate row.
168
+ const alreadyCooling =
169
+ previousUntil !== undefined && new Date(previousUntil).getTime() > Date.now();
170
+ // `status`/`cooldownUntil` stay untouched: only this (account, model)
171
+ // pair backs off, and the account remains routable for every other model.
172
+ // The error fields ARE written, because they are what provider detail and
173
+ // the health dialog read to explain *why* a model is cooling — without
174
+ // them the row showed "1 model cooling" with no reason at all.
175
+ //
176
+ // The streak advances here too. Without it the most common throttle — a
177
+ // model-scoped rate limit — never escalated, because the counter was only
178
+ // incremented on the account-wide path below, and `consecutiveFailures`
179
+ // stays honest about what it always claimed to count: how many failures in
180
+ // a row this account has just produced.
181
+ await client
182
+ .update(providerAccounts)
183
+ .set({
184
+ modelCooldowns: { ...existing, [modelId]: nextUntil },
185
+ // Counted only for the unsupported-model ladder, and only when this
186
+ // failure is that verdict — so a rate limit or a capacity refusal
187
+ // never advances it. `model_cooldowns`' own shape is untouched, so
188
+ // routing and the console keep parsing it exactly as before.
189
+ ...(unsupportedHits === undefined
190
+ ? {}
191
+ : { modelBlockHits: { ...blockHits, [modelId]: unsupportedHits } }),
192
+ consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1`,
193
+ lastError: effective.reason,
194
+ lastErrorCategory: effective.category,
195
+ lastErrorAt: now,
196
+ })
197
+ .where(eq(providerAccounts.id, accountId));
198
+ if (!alreadyCooling) {
199
+ await writeHealthEvent(client, {
200
+ entityKind: "account",
201
+ accountId,
202
+ // The account's own status, on both sides: a model-scoped throttle
203
+ // never moved it, and writing the classification's status here
204
+ // claimed an `active → cooldown` transition that never happened — the
205
+ // dialog showed "Account status: ACTIVE" above a list of transitions
206
+ // to `cooldown`. The row still names the model and the reason, which
207
+ // is what makes it worth keeping.
208
+ fromStatus: fromStatus as "active" | "cooldown" | "disabled",
209
+ toStatus: fromStatus as "active" | "cooldown" | "disabled",
210
+ reason: effective.reason,
211
+ errorCategory: effective.category,
212
+ modelId: modelId ?? null,
213
+ createdAt: now,
214
+ });
215
+ }
216
+ // The route catalog projects modelCooldowns and stamps this candidate as
217
+ // model_cooldown. Invalidate when its deadline changes, or the cached
218
+ // snapshot keeps routing this pair until some unrelated write rebuilds it.
219
+ return { ...classification, routesVisible: previousDeadline !== nextDeadline };
220
+ }
221
+
222
+ await client
223
+ .update(providerAccounts)
224
+ .set({
225
+ status: effective.status,
226
+ consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1`,
227
+ lastError: effective.reason,
228
+ lastErrorCategory: effective.category,
229
+ lastErrorAt: now,
230
+ // Monotonic against the account's own in-force deadline, for the same
231
+ // reason as the model path: an unclassified 60-second failure must not
232
+ // release an account the provider parked for hours.
233
+ //
234
+ // A `0` duration contributes no candidate rather than stamping *now*
235
+ // (see `accountDeadlineMs`): the one state that asks for it is a
236
+ // terminal automatic disable, and an elapsed deadline there would hand
237
+ // the row straight back to the recovery sweep.
238
+ cooldownUntil: accountDeadlineMs([
239
+ effective.retryAt?.getTime(),
240
+ escalatedCooldownMs > 0 ? Date.now() + escalatedCooldownMs : undefined,
241
+ account.cooldownUntil?.getTime?.(),
242
+ ]),
243
+ })
244
+ .where(eq(providerAccounts.id, accountId));
245
+
246
+ {
247
+ await writeHealthEvent(client, {
248
+ entityKind: "account",
249
+ accountId,
250
+ fromStatus: fromStatus as "active" | "cooldown" | "disabled",
251
+ toStatus: effective.status,
252
+ reason: effective.reason,
253
+ errorCategory: effective.category,
254
+ modelId: modelId ?? null,
255
+ createdAt: now,
256
+ });
257
+ }
258
+ // This path wrote `status` and `cooldownUntil`, both of which the snapshot
259
+ // projects, so routing has to see it.
260
+ return { ...classification, routesVisible: true };
261
+ };
262
+
263
+ try {
264
+ // Kept as a capability probe, not a correctness guard: this function is
265
+ // reachable with a partially-implemented database handle (the dispatch
266
+ // tests pass a narrow double), and a transaction is an optimization here,
267
+ // not a requirement — the same writes are correct without one.
268
+ if (typeof db.transaction === "function") {
269
+ return await db.transaction((tx) => persist(tx as CartethyiaDatabase));
270
+ }
271
+ return await persist(db);
272
+ } catch {
273
+ return null;
274
+ }
275
+ }
276
+
277
+ /** Clears failure state after a successful dispatch; true when the account changed. */
278
+ export async function recordAccountSuccess(
279
+ db: CartethyiaDatabase,
280
+ accountId: string,
281
+ ): Promise<boolean> {
282
+ const mutate = async (client: CartethyiaDatabase): Promise<boolean> => {
283
+ // `recordAccountSuccess` runs on every successful dispatch, and for a
284
+ // healthy account `hadFailures` is false — the row was fetched purely to
285
+ // decide nothing. Projected so that no-op path does not pull the
286
+ // credential ciphertext across the wire.
287
+ const rows = await client
288
+ .select({
289
+ status: providerAccounts.status,
290
+ consecutiveFailures: providerAccounts.consecutiveFailures,
291
+ // Read only to decide whether there is anything to clear: the common
292
+ // case (no unsupported-model hit ever recorded) must not write.
293
+ modelBlockHits: providerAccounts.modelBlockHits,
294
+ })
295
+ .from(providerAccounts)
296
+ .where(eq(providerAccounts.id, accountId))
297
+ .limit(1);
298
+ const account = rows[0];
299
+ if (!account || account.status === "disabled") return false;
300
+ const fromStatus = account.status;
301
+ const hadFailures = (account.consecutiveFailures ?? 0) > 0 || account.status !== "active";
302
+ const now = new Date();
303
+ // The unsupported-model escalation restarts after a success: a model that
304
+ // just served is not "not supported", so the next report of it must start
305
+ // from the base window again rather than from wherever the ladder had
306
+ // reached. `modelCooldowns` is deliberately NOT cleared — an in-force
307
+ // window the provider stated survives, which is the existing behavior and
308
+ // the reference gateways' too.
309
+ const blockHits = account.modelBlockHits as Record<string, number> | null;
310
+ const hasBlockHits = blockHits !== null && Object.keys(blockHits).length > 0;
311
+ await client
312
+ .update(providerAccounts)
313
+ .set({
314
+ status: "active",
315
+ consecutiveFailures: 0,
316
+ cooldownUntil: null,
317
+ lastSuccessAt: now,
318
+ ...(hadFailures ? { lastRecoveredAt: now } : {}),
319
+ ...(hasBlockHits ? { modelBlockHits: {} } : {}),
320
+ })
321
+ .where(eq(providerAccounts.id, accountId));
322
+ if (hadFailures && typeof client.insert === "function") {
323
+ await client.insert(healthEvents).values({
324
+ id: randomUUID(),
325
+ entityKind: "account",
326
+ accountId,
327
+ fromStatus: fromStatus as "active" | "cooldown" | "disabled",
328
+ toStatus: "active",
329
+ reason: "Request dispatched successfully",
330
+ errorCategory: null,
331
+ createdAt: now,
332
+ });
333
+ }
334
+ return hadFailures;
335
+ };
336
+ try {
337
+ if (typeof db.transaction === "function") {
338
+ return await db.transaction((tx) => mutate(tx as CartethyiaDatabase));
339
+ }
340
+ return await mutate(db);
341
+ } catch {
342
+ return false;
343
+ }
344
+ }
345
+
346
+ /** Operator-initiated recovery: forces the account back to active and logs the transition. */
347
+ export async function recoverAccount(
348
+ db: CartethyiaDatabase,
349
+ accountId: string,
350
+ reason = "manual_operator_recovery",
351
+ ): Promise<boolean> {
352
+ const recover = async (client: CartethyiaDatabase): Promise<boolean> => {
353
+ const accountRows = await client
354
+ .select()
355
+ .from(providerAccounts)
356
+ .where(eq(providerAccounts.id, accountId))
357
+ .limit(1);
358
+ const account = accountRows[0];
359
+ if (!account) return false;
360
+ const fromStatus = account.status;
361
+ const now = new Date();
362
+ await client
363
+ .update(providerAccounts)
364
+ .set({
365
+ status: "active",
366
+ consecutiveFailures: 0,
367
+ cooldownUntil: null,
368
+ lastRecoveredAt: now,
369
+ // Recovery is the operator asserting the account works now, so it must
370
+ // clear EVERY routing exclusion, not just the account-wide ones. A
371
+ // per-model cooldown entry excludes the (account, model) pair in
372
+ // `route-catalog.ts`, so leaving it behind kept the account blocked for
373
+ // that model through `/v1` even though the account read `active` and a
374
+ // direct probe (which addresses the account by id, bypassing routing
375
+ // eligibility) succeeded. Clearing the error fields for the same reason:
376
+ // a stale reason on a recovered row misreports the current state.
377
+ modelCooldowns: {},
378
+ lastError: null,
379
+ lastErrorCategory: null,
380
+ lastErrorAt: null,
381
+ })
382
+ .where(eq(providerAccounts.id, accountId));
383
+ {
384
+ await writeHealthEvent(client, {
385
+ entityKind: "account",
386
+ accountId,
387
+ fromStatus: fromStatus as "active" | "cooldown" | "disabled",
388
+ toStatus: "active",
389
+ reason,
390
+ errorCategory: null,
391
+ createdAt: now,
392
+ });
393
+ }
394
+ return true;
395
+ };
396
+ try {
397
+ if (typeof db.transaction === "function") {
398
+ return await db.transaction((tx) => recover(tx as CartethyiaDatabase));
399
+ }
400
+ return await recover(db);
401
+ } catch {
402
+ return false;
403
+ }
404
+ }
405
+
406
+ /** Recovers accounts whose cooldown elapsed and prunes expired per-model keys. */
407
+ export async function sweepExpiredCooldowns(db: CartethyiaDatabase): Promise<number> {
408
+ const recover = async (client: CartethyiaDatabase): Promise<number> => {
409
+ const now = new Date();
410
+ const nowIso = now.toISOString();
411
+ const expired = await client
412
+ .select({
413
+ id: providerAccounts.id,
414
+ status: providerAccounts.status,
415
+ })
416
+ .from(providerAccounts)
417
+ .where(
418
+ // An ordinary cooldown, recovered as soon as its window elapses.
419
+ and(
420
+ eq(providerAccounts.status, "cooldown"),
421
+ isNotNull(providerAccounts.cooldownUntil),
422
+ lte(providerAccounts.cooldownUntil, now),
423
+ ),
424
+ )
425
+ // One pass recovers a bounded batch. The sweep runs every 30s, so a
426
+ // backlog larger than this drains over successive ticks instead of one
427
+ // statement covering every expired row — that statement holds its locks
428
+ // for as long as the backlog takes.
429
+ .limit(COOLDOWN_RECOVERY_BATCH);
430
+ if (expired.length > 0) {
431
+ await client
432
+ .update(providerAccounts)
433
+ .set({
434
+ status: "active",
435
+ consecutiveFailures: 0,
436
+ cooldownUntil: null,
437
+ lastRecoveredAt: now,
438
+ // Cleared with the state it described. A recovered row that kept
439
+ // `auth_invalidated` still rendered the console's "Re-login required"
440
+ // pill above an `active` status, so the account read healthy to
441
+ // routing and broken to the operator at the same time.
442
+ lastErrorCategory: null,
443
+ })
444
+ .where(inArray(providerAccounts.id, expired.map((account) => account.id)));
445
+ if (typeof client.insert === "function") {
446
+ for (let offset = 0; offset < expired.length; offset += 500) {
447
+ await client.insert(healthEvents).values(
448
+ expired.slice(offset, offset + 500).map((account) => ({
449
+ id: randomUUID(),
450
+ entityKind: "account" as const,
451
+ accountId: account.id,
452
+ fromStatus: account.status,
453
+ toStatus: "active" as const,
454
+ reason: "Cooldown period elapsed — auto-recovered",
455
+ errorCategory: null,
456
+ createdAt: now,
457
+ })),
458
+ );
459
+ }
460
+ }
461
+ }
462
+
463
+ // Prune per-model cooldowns: remove entries whose ISO timestamp has passed.
464
+ //
465
+ // One set-based UPDATE rather than a SELECT followed by an UPDATE per row.
466
+ // The per-row form issued one statement per account holding any cooldown —
467
+ // on a deployment where many accounts are throttled at once that is the
468
+ // sweep's dominant cost, and it all runs inside this transaction, holding
469
+ // its locks for the whole pass. Recomputing the object in SQL does the same
470
+ // work in one statement.
471
+ //
472
+ // `jsonb_typeof(...) = 'object'` guards the shape: a malformed value must
473
+ // not abort the whole sweep. `IS DISTINCT FROM` keeps the update from
474
+ // rewriting rows it
475
+ // did not change, so the returned row count is the number of accounts whose
476
+ // cooldown set actually shrank — the same thing the loop counted.
477
+ const pruning = pruneExpiredCooldowns(sql`${providerAccounts.modelCooldowns}`, sql`${nowIso}`);
478
+ const pruned = await client
479
+ .update(providerAccounts)
480
+ .set({ modelCooldowns: pruning.value })
481
+ .where(pruning.changed);
482
+ return expired.length + affectedRows(pruned);
483
+ };
484
+ try {
485
+ if (typeof db.transaction === "function") {
486
+ return await db.transaction((tx) => recover(tx as CartethyiaDatabase));
487
+ }
488
+ return await recover(db);
489
+ } catch {
490
+ return 0;
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Single attempt health reporter: one home for the success/failure writes
496
+ * every dispatch attempt ends with. Success records recovery; failure
497
+ * records with caller-supplied evidence (origin/scope classification plus
498
+ * model id). A mutating write invalidates the route snapshot so the next
499
+ * plan sees fresh health. Never throws — health must not fail requests.
500
+ */
501
+ export interface AttemptHealthReport {
502
+ readonly accountId: string | undefined;
503
+ readonly modelId?: string;
504
+ readonly error?: unknown;
505
+ readonly evidence?: AccountFailureEvidence;
506
+ readonly snapshotService?: { invalidate(): unknown };
507
+ }
508
+
509
+ /**
510
+ * Writes one health audit row, off the request path when a buffer is installed.
511
+ *
512
+ * `health_events` is append-only and read by nobody in the request path, so it
513
+ * does not need to land before the request finishes — unlike the
514
+ * `provider_accounts` UPDATE beside it, which routing reads and which therefore
515
+ * stays synchronous and transactional.
516
+ */
517
+ async function writeHealthEvent(
518
+ client: CartethyiaDatabase,
519
+ row: {
520
+ readonly entityKind: "account";
521
+ readonly accountId: string;
522
+ readonly fromStatus: string;
523
+ readonly toStatus: string;
524
+ readonly reason: string;
525
+ readonly errorCategory: string | null;
526
+ readonly modelId?: string | null;
527
+ readonly createdAt: Date;
528
+ },
529
+ ): Promise<void> {
530
+ const buffer = healthEventBuffer();
531
+ if (buffer !== undefined) {
532
+ buffer.enqueue({ ...row, modelId: row.modelId ?? null });
533
+ return;
534
+ }
535
+ if (typeof client.insert !== "function") return;
536
+ await client.insert(healthEvents).values({ id: randomUUID(), ...row } as never);
537
+ }
538
+
539
+ export async function reportAttemptOutcome(
540
+ db: CartethyiaDatabase,
541
+ report: AttemptHealthReport,
542
+ ): Promise<void> {
543
+ if (!report.accountId) return;
544
+ if (report.error === undefined) {
545
+ const recovered = await recordAccountSuccess(db, report.accountId).catch(() => false);
546
+ if (recovered) await report.snapshotService?.invalidate();
547
+ return;
548
+ }
549
+ const classification = await recordAccountFailure(db, report.accountId, report.error, {
550
+ ...report.evidence,
551
+ ...(report.modelId === undefined ? {} : { modelId: report.modelId }),
552
+ }).catch(() => null);
553
+ // Gated on `routesVisible`, not on `mutatesAccount`: a model cooldown changes
554
+ // routing, while a repeated report with the same deadline or a streak-only
555
+ // write does not.
556
+ if (classification?.routesVisible) await report.snapshotService?.invalidate();
557
+ }
558
+
559
+ export async function listAccountHealthEvents(
560
+ db: CartethyiaDatabase,
561
+ accountId: string,
562
+ limit = 50,
563
+ ): Promise<readonly AccountHealthEventRecord[]> {
564
+ try {
565
+ const rows = await db
566
+ .select()
567
+ .from(healthEvents)
568
+ .where(and(eq(healthEvents.entityKind, "account"), eq(healthEvents.accountId, accountId)))
569
+ .orderBy(desc(healthEvents.createdAt))
570
+ .limit(limit);
571
+
572
+ return rows.map((r) => ({
573
+ id: r.id,
574
+ accountId: r.accountId as string,
575
+ fromStatus: r.fromStatus,
576
+ toStatus: r.toStatus,
577
+ modelId: r.modelId ?? null,
578
+ reason: r.reason,
579
+ errorCategory: r.errorCategory,
580
+ createdAt: r.createdAt.toISOString(),
581
+ }));
582
+ } catch {
583
+ return [];
584
+ }
585
+ }
586
+
587
+ /** Every stored row across accounts and pools — the size the purge offers to delete. */
588
+ export async function countAllHealthEvents(db: CartethyiaDatabase): Promise<number> {
589
+ try {
590
+ const rows = await db.select({ total: count() }).from(healthEvents);
591
+ return rows[0]?.total ?? 0;
592
+ } catch {
593
+ return 0;
594
+ }
595
+ }
596
+
597
+ /**
598
+ * Deletes stored health & error rows.
599
+ *
600
+ * Unlike the read above, a failed delete surfaces: an operator who purged
601
+ * believes the trail is gone, so "it failed" must not read as "it succeeded
602
+ * with zero rows". Callers own the flush-buffer barrier (`drain`) so a queued
603
+ * write cannot land after these rows are gone.
604
+ */
605
+ export async function deleteAccountHealthEvents(
606
+ db: CartethyiaDatabase,
607
+ accountId: string,
608
+ ): Promise<number> {
609
+ const rows = await db
610
+ .delete(healthEvents)
611
+ .where(and(eq(healthEvents.entityKind, "account"), eq(healthEvents.accountId, accountId)))
612
+ .returning({ id: healthEvents.id });
613
+ return rows.length;
614
+ }
615
+
616
+ export async function deleteAllHealthEvents(db: CartethyiaDatabase): Promise<number> {
617
+ const rows = await db.delete(healthEvents).returning({ id: healthEvents.id });
618
+ return rows.length;
619
+ }