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
@@ -29,10 +29,10 @@
29
29
  * idempotent. There is no unique constraint to lean on: `telemetry_events`
30
30
  * has a random PK, so the natural key is checked explicitly.
31
31
  */
32
- import { and, eq, sql } from "drizzle-orm";
33
- import { gzipSync, gunzipSync } from "node:zlib";
32
+ import { and, eq, gt, isNotNull, or, sql } from "drizzle-orm";
33
+ import { gzipSync, gunzipSync, createGzip } from "node:zlib";
34
34
  import { differsFrom } from "../../persistence/dialect";
35
- import { TELEMETRY_EVENT_DIR } from "../../observability/event-log";
35
+ import { TELEMETRY_EVENT_DIR, type TelemetryEventRecord } from "../../observability/event-log";
36
36
  import { queryTelemetryEvents as querySqlEvents } from "../../observability/event-query";
37
37
  import { appendFile, mkdir } from "node:fs/promises";
38
38
  import { getTableColumns, type Column, type SQL, type Table } from "drizzle-orm";
@@ -241,90 +241,343 @@ function ownershipOwnerColumn(parent: Table): Column {
241
241
  );
242
242
  }
243
243
 
244
- /** Reads every row of one table that `tenantId` owns, encoded for JSON. */
245
- async function exportTable(
244
+ /**
245
+ * Reads every row of one table that `tenantId` owns, encoded for JSON.
246
+ *
247
+ * Exports stream: the backup of a tenant can carry hundreds of thousands of
248
+ * rows (the dev deployment's `api_keys` held 331k minted share children), and
249
+ * materializing the whole table to JSON it in one gulp was what OOM-killed the
250
+ * process during a backup. Every reader is therefore paged.
251
+ */
252
+ const EXPORT_PAGE_ROWS = 500;
253
+
254
+ /**
255
+ * The composite keys of tables whose primary key is not declared column-wise.
256
+ * Row tables stream through a keyset walk, so the sort key must uniquely
257
+ * order rows — a truncated key would swallow tied rows at every page
258
+ * boundary.
259
+ */
260
+ const COMPOSITE_KEYSETS: Partial<Record<string, readonly string[]>> = {
261
+ telemetry_usage_totals: ["tenantId", "identityType", "entityId"],
262
+ cli_tool_settings: ["tenantId", "apiKeyId", "toolId"],
263
+ provider_routing_settings: ["providerId", "tenantId"],
264
+ };
265
+
266
+ /**
267
+ * The key a keyset walk of `table` pages by, or `null` for a table whose rows
268
+ * are contract-bounded to a handful (per-tenant settings singletons) and are
269
+ * read in one statement.
270
+ */
271
+ function keysetKey(table: Table): readonly Column[] | null {
272
+ const name = tableName(table);
273
+ const columns = getTableColumns(table) as Record<string, Column>;
274
+ const composite = COMPOSITE_KEYSETS[name];
275
+ if (composite !== undefined) {
276
+ return composite.map((key) => {
277
+ const column = columns[key];
278
+ if (column === undefined) throw new Error(`${name}: backup keyset names missing column ${key}`);
279
+ return column;
280
+ });
281
+ }
282
+ // Single keys: a uuid `id` first, then the per-table natural keys
283
+ // (`provider_oauth_states` → its account, `share_popups` → its template).
284
+ // `tenantId` is last: it is the primary key of nothing but the per-tenant
285
+ // settings singletons, so single-column `id` always wins where one exists.
286
+ for (const single of ["id", "providerAccountId", "apiKeyId", "tenantId"] as const) {
287
+ const column = columns[single];
288
+ if (column !== undefined) return [column];
289
+ }
290
+ return null;
291
+ }
292
+
293
+ /** Rows past the key of the previous page, in the key's own order. */
294
+ function keysetAfter(key: readonly Column[], last: readonly unknown[]): SQL {
295
+ return or(
296
+ ...key.map((column, index) =>
297
+ and(...key.slice(0, index).map((prior, at) => eq(prior, last[at])), gt(column, last[index])),
298
+ ),
299
+ ) as SQL;
300
+ }
301
+
302
+ /** Yields the owned rows of `table` in key order, one statement per page. */
303
+ async function* pagedRows(
246
304
  db: CartethyiaDatabase,
247
305
  table: Table,
248
306
  tenantId: string,
249
- ): Promise<BackupRow[]> {
307
+ ): AsyncGenerator<Record<string, unknown>, void, void> {
308
+ const filter = ownedByFilter(table, tenantId);
309
+ const key = keysetKey(table);
310
+ if (key === null) {
311
+ const rows = (await db
312
+ .select()
313
+ .from(table)
314
+ .where(filter)
315
+ .limit(MAX_ROWS_PER_TABLE)) as Array<Record<string, unknown>>;
316
+ yield* rows;
317
+ return;
318
+ }
319
+ const entries = Object.entries(getTableColumns(table));
320
+ const keyNames = key.map((column) => {
321
+ const entry = entries.find(([, candidate]) => candidate === column);
322
+ if (entry === undefined) throw new Error(`${tableName(table)}: key column is not part of the table`);
323
+ return entry[0];
324
+ });
325
+ let last: unknown[] = [];
326
+ while (true) {
327
+ const where = last.length === 0 ? filter : and(filter, keysetAfter(key, last));
328
+ const rows = (await db
329
+ .select()
330
+ .from(table)
331
+ .where(where)
332
+ .orderBy(...key.map((column) => sql`${column} asc`))
333
+ .limit(EXPORT_PAGE_ROWS)) as Array<Record<string, unknown>>;
334
+ if (rows.length === 0) return;
335
+ yield* rows;
336
+ if (rows.length < EXPORT_PAGE_ROWS) return;
337
+ const tail = rows[rows.length - 1] as Record<string, unknown>;
338
+ last = keyNames.map((name) => tail[name]);
339
+ }
340
+ }
341
+
342
+ /** Encodes one read row for the payload. */
343
+ function encodeRow(table: Table, row: Record<string, unknown>): BackupRow {
250
344
  const index = columnIndex(table);
251
345
  const sealed = sealedColumns(table);
252
- const rows = (await db
253
- .select()
254
- .from(table)
255
- .where(ownedByFilter(table, tenantId))
256
- .limit(MAX_ROWS_PER_TABLE)) as Array<Record<string, unknown>>;
257
- return rows.map((row) => {
258
- const out: BackupRow = {};
259
- for (const [name, { key, dataType }] of index) {
260
- // A sealed column is written decrypted, under the name of the secret it
261
- // holds — see `SEALED_COLUMNS` for why the column's own name would be a
262
- // lie in a file that no longer carries its ciphertext.
263
- out[payloadNameOf(table, name)] = sealed.has(name)
264
- ? decodeSealedForPayload(row[key])
265
- : encodeCell(row[key], dataType);
346
+ const out: BackupRow = {};
347
+ for (const [name, { key, dataType }] of index) {
348
+ // A sealed column is written decrypted, under the name of the secret it
349
+ // holds — see `SEALED_COLUMNS` for why the column's own name would be a
350
+ // lie in a file that no longer carries its ciphertext.
351
+ out[payloadNameOf(table, name)] = sealed.has(name)
352
+ ? decodeSealedForPayload(row[key])
353
+ : encodeCell(row[key], dataType);
354
+ }
355
+ return out;
356
+ }
357
+
358
+ /** Encodes one telemetry event row the way `exportEventFile` used to. */
359
+ function telemetryRowOf(event: TelemetryEventRecord): BackupRow {
360
+ const row: BackupRow = {};
361
+ for (const [key, value] of Object.entries(event)) {
362
+ if (value === null || value === undefined) continue;
363
+ row[camelToSnake(key)] = key === "createdAt" ? { __date: String(value) } : value;
364
+ }
365
+ return row;
366
+ }
367
+
368
+ async function* pagedTelemetryEvents(
369
+ db: CartethyiaDatabase,
370
+ tenantId: string,
371
+ ): AsyncGenerator<TelemetryEventRecord, void, void> {
372
+ // The SQL table is the durable record now, and it is tenant-scoped and
373
+ // retained by the same rule as the raw files — so the export walks it
374
+ // newest-first through its own keyset cursor instead of scanning every raw
375
+ // file and filtering by tenant afterwards.
376
+ let cursor: string | undefined;
377
+ while (true) {
378
+ const page = await querySqlEvents(db, {
379
+ tenantId,
380
+ limit: EXPORT_PAGE_ROWS,
381
+ ...(cursor === undefined ? {} : { cursor }),
382
+ });
383
+ yield* page.events;
384
+ if (page.nextCursor === undefined) return;
385
+ cursor = page.nextCursor;
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Streams the payload document: the requested tables, grouped into their
391
+ * sections, written membrane-incrementally so the process never holds a table
392
+ * — let alone every table and a second JSON copy — in memory. Consumed
393
+ * chunks concatenated are exactly the JSON the buffered variant used to
394
+ * stringify in one piece.
395
+ */
396
+ /**
397
+ * Streams the payload document: the requested tables, grouped into their
398
+ * sections, written incrementally so the process never holds a table — let
399
+ * alone every table and a second JSON copy — in memory. Consumed chunks
400
+ * concatenated are exactly the JSON the buffered variant used to stringify in
401
+ * one piece.
402
+ */
403
+ async function* backupJsonChunks(
404
+ db: CartethyiaDatabase,
405
+ tables: readonly Table[],
406
+ tenantId: string,
407
+ finishCounts: (counts: Record<string, number>) => void,
408
+ ): AsyncGenerator<string, void, void> {
409
+ const counts: Record<string, number> = {};
410
+
411
+ const configSet = new Set<Table>([...CONFIG_TABLES]);
412
+ const telemetrySet = new Set<Table>(TELEMETRY_TABLES);
413
+ const configTables = tables.filter((table) => configSet.has(table));
414
+ const telemetryTables = tables.filter((table) => telemetrySet.has(table));
415
+ const outside = tables.filter((table) => !configSet.has(table) && !telemetrySet.has(table));
416
+
417
+ // The OAuth projection is derived from the accounts block itself, so those
418
+ // two tables materialize (both are operator-sized, dozens of rows) instead
419
+ // of being read twice.
420
+ const materialized = new Map<Table, readonly BackupRow[]>();
421
+ const materializedRows = async (table: Table): Promise<readonly BackupRow[]> => {
422
+ let rows: readonly BackupRow[] | undefined = materialized.get(table);
423
+ if (rows === undefined) {
424
+ const built: BackupRow[] = [];
425
+ for await (const source of pagedRows(db, table, tenantId)) {
426
+ built.push(encodeRow(table, source));
427
+ }
428
+ rows = built;
429
+ materialized.set(table, rows);
266
430
  }
267
- return out;
268
- });
431
+ return rows;
432
+ };
433
+
434
+ /** Writes one table's array into the document, tracking the exported count. */
435
+ async function* writeTable(table: Table, sources?: readonly BackupRow[]): AsyncGenerator<string, void, void> {
436
+ const name = tableName(table);
437
+ let first = true;
438
+ let written = 0;
439
+ yield `"${name}":[`;
440
+ if (sources === undefined) {
441
+ for await (const source of pagedRows(db, table, tenantId)) {
442
+ if (written >= MAX_ROWS_PER_TABLE) break;
443
+ yield `${first ? "" : ","}${JSON.stringify(encodeRow(table, source))}`;
444
+ first = false;
445
+ written += 1;
446
+ }
447
+ } else {
448
+ for (const row of sources) {
449
+ yield `${first ? "" : ","}${JSON.stringify(row)}`;
450
+ first = false;
451
+ written += 1;
452
+ }
453
+ }
454
+ yield "]";
455
+ counts[name] = written;
456
+ }
457
+
458
+ try {
459
+ yield `{"app":${JSON.stringify(BACKUP_APP)},"version":${BACKUP_VERSION},"exportedAt":${JSON.stringify(new Date().toISOString())},"sections":{`;
460
+ if (configTables.length > 0) {
461
+ yield `"config":{`;
462
+ let configIndex = 0;
463
+ for (const table of configTables) {
464
+ const sources =
465
+ table === providerAccounts || table === providerOauthStates ? await materializedRows(table) : undefined;
466
+ yield configIndex > 0 ? "," : "";
467
+ yield* writeTable(table, sources);
468
+ configIndex += 1;
469
+ }
470
+ yield `}`;
471
+ }
472
+ if (telemetryTables.length > 0) {
473
+ let telemetryIndex = 0;
474
+ for (const table of telemetryTables) {
475
+ if (telemetryIndex === 0) {
476
+ yield `${configTables.length > 0 ? "," : ""}"telemetry":{`;
477
+ } else {
478
+ yield ",";
479
+ }
480
+ telemetryIndex += 1;
481
+ if (table === telemetryEvents) {
482
+ yield `"${tableName(table)}":[`;
483
+ let first = true;
484
+ let written = 0;
485
+ for await (const event of pagedTelemetryEvents(db, tenantId)) {
486
+ if (written >= MAX_ROWS_PER_TABLE) break;
487
+ yield `${first ? "" : ","}${JSON.stringify(telemetryRowOf(event))}`;
488
+ first = false;
489
+ written += 1;
490
+ }
491
+ yield "]";
492
+ counts[tableName(table)] = written;
493
+ } else {
494
+ yield* writeTable(table);
495
+ }
496
+ }
497
+ yield `}`;
498
+ }
499
+ yield `}`;
500
+ // Tables that belong to neither section (the tenant row) are counted but
501
+ // do not enter the payload — the same rule the buffered assembly kept.
502
+ for (const table of outside) {
503
+ let written = 0;
504
+ for await (const _source of pagedRows(db, table, tenantId)) {
505
+ written += 1;
506
+ }
507
+ counts[tableName(table)] = written;
508
+ }
509
+ // Always built: the row tables above now always carry the credentials
510
+ // decrypted, so the projection is always complete. It used to be
511
+ // conditional on the export being "raw", which made a plain JSON export
512
+ // carry a block of null tokens — a router migration from it produced
513
+ // accounts with no credential at all.
514
+ if (materialized.size > 0) {
515
+ const inputs: Record<string, readonly BackupRow[]> = {};
516
+ const accounts = materialized.get(providerAccounts);
517
+ const states = materialized.get(providerOauthStates);
518
+ if (accounts !== undefined) inputs["provider_accounts"] = accounts;
519
+ if (states !== undefined) inputs["provider_oauth_states"] = states;
520
+ const providerConnections = attachOauthSecrets(inputs);
521
+ if (providerConnections !== undefined) {
522
+ yield `,"providerConnections":${JSON.stringify(providerConnections)}`;
523
+ }
524
+ }
525
+ yield `}`;
526
+ } finally {
527
+ finishCounts(counts);
528
+ }
269
529
  }
270
530
 
271
531
  /**
272
- * Snapshots the requested tables, grouped into their sections.
532
+ * Snapshots the requested tables, grouped into their sections, as an
533
+ * incrementally-written JSON document.
273
534
  *
274
535
  * Only `tenantId`'s own rows are read. The shared rows a backup needs in order
275
536
  * to be restorable — the built-in providers and their catalog — are not carried
276
537
  * because the build already supplies them (see `seedBundledProviders` /
277
538
  * `seedBundledModels`), so a payload never contains another tenant's data.
278
539
  */
540
+ export interface BackupExportStream {
541
+ /** Concatenated chunks are the payload document, one string at a row-page granularity. */
542
+ readonly jsonChunks: AsyncGenerator<string, void, void>;
543
+ /**
544
+ * Row counts per table, resolved once the document has been consumed to
545
+ * completion — a streamed export counts what it actually wrote.
546
+ */
547
+ readonly counts: Promise<Record<string, number>>;
548
+ }
549
+
550
+ export function exportBackupStream(
551
+ db: CartethyiaDatabase,
552
+ tables: readonly Table[],
553
+ tenantId: string,
554
+ ): BackupExportStream {
555
+ let resolveCounts!: (counts: Record<string, number>) => void;
556
+ const counts = new Promise<Record<string, number>>((resolve) => {
557
+ resolveCounts = resolve;
558
+ });
559
+ return {
560
+ jsonChunks: backupJsonChunks(db, tables, tenantId, resolveCounts),
561
+ counts,
562
+ };
563
+ }
564
+
565
+ /**
566
+ * The buffered form of {@link exportBackupStream}: builds the whole payload
567
+ * object in memory. Export callers stream; this remains for callers that need
568
+ * the payload as data — the backup round-trip tests use it to feed restores.
569
+ */
279
570
  export async function exportBackup(
280
571
  db: CartethyiaDatabase,
281
572
  tables: readonly Table[],
282
573
  tenantId: string,
283
574
  ): Promise<BackupExportResult> {
284
- const config: Record<string, readonly BackupRow[]> = {};
285
- const telemetry: Record<string, readonly BackupRow[]> = {};
286
- const counts: Record<string, number> = {};
287
- const configSet = new Set<Table>([...CONFIG_TABLES]);
288
- const telemetrySet = new Set<Table>(TELEMETRY_TABLES);
289
-
290
- // Table reads are independent tenant-scoped `SELECT`s plus one disk read for
291
- // the event file; firing them concurrently cuts a many-table export from
292
- // one round-trip-at-a-time to the single slowest query. The DB driver pools
293
- // these; the table list is bounded by `CONFIG_TABLES + TELEMETRY_TABLES`
294
- // (small and known), so fan-out is bounded without an extra limiter.
295
- const reads = tables.map(async (table) =>
296
- [
297
- table,
298
- table === telemetryEvents ? await exportEventFile(db, tenantId) : await exportTable(db, table, tenantId),
299
- ] as const,
300
- );
301
- // Preserve the caller's `tables` order when writing into the output maps so
302
- // the payload's key order is deterministic across runs.
303
- const settled = await Promise.all(reads);
304
- for (const [table, rows] of settled) {
305
- const name = tableName(table);
306
- counts[name] = rows.length;
307
- if (configSet.has(table)) config[name] = rows;
308
- else if (telemetrySet.has(table)) telemetry[name] = rows;
309
- }
310
- // Always built: the row tables above now always carry the credentials
311
- // decrypted, so the projection is always complete. It used to be conditional
312
- // on the export being "raw", which made a plain JSON export carry a block of
313
- // null tokens — a router migration from it produced accounts with no
314
- // credential at all.
315
- const providerConnections = attachOauthSecrets(config);
316
- const payload: BackupPayload = {
317
- app: BACKUP_APP,
318
- version: BACKUP_VERSION,
319
- exportedAt: new Date().toISOString(),
320
- sections: {
321
- ...(Object.keys(config).length > 0 ? { config } : {}),
322
- ...(Object.keys(telemetry).length > 0 ? { telemetry } : {}),
323
- },
324
- ...(providerConnections === undefined ? {} : { providerConnections }),
325
- };
326
- return { counts, payload };
575
+ const stream = exportBackupStream(db, tables, tenantId);
576
+ let document = "";
577
+ for await (const chunk of stream.jsonChunks) document += chunk;
578
+ return { counts: await stream.counts, payload: JSON.parse(document) as BackupPayload };
327
579
  }
580
+
328
581
  /**
329
582
  * Gzips a backup payload.
330
583
  *
@@ -337,6 +590,30 @@ export function compressBackup(payload: unknown): Buffer {
337
590
  return gzipSync(Buffer.from(JSON.stringify(payload), "utf8"));
338
591
  }
339
592
 
593
+ /**
594
+ * Gzips a streamed payload assembly: one pass, no whole-document buffer of
595
+ * the raw JSON (the compressed document itself still fits one buffer, since
596
+ * the response contract wraps it as base64).
597
+ */
598
+ export async function compressBackupStream(chunks: AsyncGenerator<string, void, void>): Promise<Buffer> {
599
+ const gzip = createGzip();
600
+ const parts: Buffer[] = [];
601
+ const finished = new Promise<"done" | Error>((resolve) => {
602
+ gzip.on("data", (chunk: Buffer) => parts.push(chunk));
603
+ gzip.on("error", (error: Error) => resolve(error));
604
+ gzip.on("end", () => resolve("done"));
605
+ });
606
+ for await (const chunk of chunks) {
607
+ await new Promise<void>((resolve, reject) => {
608
+ gzip.write(Buffer.from(chunk, "utf8"), (error) => (error === null ? resolve() : reject(error)));
609
+ });
610
+ }
611
+ gzip.end();
612
+ const outcome = await finished;
613
+ if (outcome !== "done") throw outcome;
614
+ return Buffer.concat(parts);
615
+ }
616
+
340
617
  /** The gzip magic, so a file can be recognised without trusting its name. */
341
618
  const GZIP_MAGIC = [0x1f, 0x8b] as const;
342
619
 
@@ -369,28 +646,6 @@ export function decompressBackup(bytes: Uint8Array): string {
369
646
  }
370
647
  }
371
648
 
372
- /**
373
- * Exports per-request events from the file log, shaped like the table the
374
- * backup contract still names. The table is no longer written, so reading it
375
- * would export nothing.
376
- */
377
- async function exportEventFile(db: CartethyiaDatabase, tenantId: string): Promise<readonly BackupRow[]> {
378
- // The SQL table is the durable record now, and it is tenant-scoped and
379
- // retained by the same rule as the raw files — so the export reads it
380
- // instead of scanning every raw file and filtering by tenant afterwards.
381
- const page = await querySqlEvents(db, { tenantId, limit: MAX_ROWS_PER_TABLE });
382
- return page.events
383
- .slice(0, MAX_ROWS_PER_TABLE)
384
- .map((event) => {
385
- const row: BackupRow = {};
386
- for (const [key, value] of Object.entries(event)) {
387
- if (value === null || value === undefined) continue;
388
- row[camelToSnake(key)] = key === "createdAt" ? { __date: String(value) } : value;
389
- }
390
- return row;
391
- });
392
- }
393
-
394
649
  function camelToSnake(name: string): string {
395
650
  return name.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`);
396
651
  }
@@ -732,6 +987,40 @@ async function insertRows(
732
987
  return inserted;
733
988
  }
734
989
 
990
+ /**
991
+ * Nulls the 11 nullable policy columns on share children owned by `tenantId`.
992
+ *
993
+ * Auth resolves policy from the parent (`policy = parent ?? row`), so child
994
+ * copies of notes/limits/model_list/denylist are pure bloat. Migration 0029
995
+ * clears them once at boot; restore does not re-run numbered migrations, so
996
+ * an old backup that still carries populated children would reintroduce the
997
+ * bloat unless this runs inside the same restore transaction. Keep the SET
998
+ * list identical to migration 0029
999
+ * (`0029_null_share_child_policy_columns.sql` in both backends) and the write
1000
+ * path in `issueSharedApiKey` (which no longer copies them).
1001
+ */
1002
+ export async function normalizeShareChildPolicy(
1003
+ db: CartethyiaDatabase,
1004
+ tenantId: string,
1005
+ ): Promise<void> {
1006
+ await db
1007
+ .update(apiKeys)
1008
+ .set({
1009
+ notesTitle: null,
1010
+ notesSubtitle: null,
1011
+ notesBody: null,
1012
+ requestsPerMinute: null,
1013
+ dailyTokenLimit: null,
1014
+ monthlyTokenLimit: null,
1015
+ lifetimeTokenBudget: null,
1016
+ maxConcurrentRequests: null,
1017
+ modelPrefix: null,
1018
+ modelList: null,
1019
+ clientRouterDenylist: null,
1020
+ })
1021
+ .where(and(eq(apiKeys.tenantId, tenantId), isNotNull(apiKeys.parentKeyId)));
1022
+ }
1023
+
735
1024
  /**
736
1025
  * Applies a pre-validated restore inside one transaction.
737
1026
  *
@@ -865,6 +1154,13 @@ export async function applyRestore(
865
1154
  restored[tableName(table)] = await insertRows(scoped, table, entry.rows, { tenantId });
866
1155
  }
867
1156
  }
1157
+
1158
+ // Restore writes api_keys rows verbatim and does not re-run migration
1159
+ // 0029. An old backup can therefore reintroduce populated child policy
1160
+ // columns; re-apply the null-out for this tenant before committing.
1161
+ if (replaceTables.includes(apiKeys)) {
1162
+ await normalizeShareChildPolicy(scoped, tenantId);
1163
+ }
868
1164
  });
869
1165
 
870
1166
  return { restored, skipped };
@@ -413,6 +413,7 @@ export const TOOL_REGISTRY = {
413
413
  description: "OpenAI Codex CLI with Responses API, subagents, and review roles",
414
414
  configType: "custom" as const,
415
415
  surface: "responses" as const,
416
+ mappingMode: "remote" as const,
416
417
  settingsFile: "~/.codex/config.toml",
417
418
  docsUrl: "https://developers.openai.com/codex/config-reference",
418
419
  defaultModels: [
@@ -433,12 +434,17 @@ export const TOOL_REGISTRY = {
433
434
  defaultValue: "gpt-5.5",
434
435
  },
435
436
  {
436
- id: "gpt-5.6-terra",
437
- name: "GPT-5.6 Terra",
437
+ // Codex's Guardian auto-review agent always sends the hardcoded
438
+ // `codex-auto-review` model. It must be routable for approval reviews
439
+ // to work at all, so it is a first-class mapping slot here. The `id`
440
+ // is qualified (routable via the codex provider) while `defaultValue`
441
+ // carries the exact spelling Codex sends — the source the gateway maps.
442
+ id: "codex/codex-auto-review",
443
+ name: "Codex Auto Review",
438
444
  alias: "review",
439
- roleLabel: "Review model",
445
+ roleLabel: "Review (Guardian)",
440
446
  roleKind: "review",
441
- defaultValue: "gpt-5.6-terra",
447
+ defaultValue: "codex-auto-review",
442
448
  },
443
449
  ],
444
450
  },
@@ -36,6 +36,11 @@ export const DEFAULT_ENTRY_DOCUMENTS: Readonly<Record<string, string>> = {
36
36
  "/console": "index.html",
37
37
  };
38
38
 
39
+ function dashboardBuildHeader(html: string): Record<string, string> {
40
+ const buildId = /<meta\s+name="cartethyia-dashboard-build"\s+content="([a-f0-9]{16})"\s*\/?>/i.exec(html)?.[1];
41
+ return buildId === undefined ? {} : { "x-cartethyia-dashboard-build": buildId };
42
+ }
43
+
39
44
  /**
40
45
  * Open Graph / Twitter card values injected into a served document. The shared
41
46
  * index document ships the landing card, so the public share routes swap in
@@ -200,6 +205,7 @@ export function createStaticHandler(
200
205
  "cache-control": "no-store, no-cache, must-revalidate",
201
206
  pragma: "no-cache",
202
207
  expires: "0",
208
+ ...dashboardBuildHeader(html),
203
209
  },
204
210
  };
205
211
  };
@@ -274,6 +280,10 @@ export function createStaticHandler(
274
280
  // Regular assets without hash
275
281
  cacheControl = "public, max-age=3600";
276
282
  }
283
+ const buildHeaders =
284
+ mimeType.startsWith("text/html")
285
+ ? dashboardBuildHeader(new TextDecoder().decode(data))
286
+ : {};
277
287
 
278
288
  // A precompressed sibling is served when the client asked for gzip
279
289
  // and the build emitted one. The bytes were compressed at build time
@@ -293,6 +303,7 @@ export function createStaticHandler(
293
303
  // client that never offered gzip: the URL is identical either
294
304
  // way, so `accept-encoding` is the only thing that differs.
295
305
  vary: "accept-encoding",
306
+ ...buildHeaders,
296
307
  },
297
308
  };
298
309
  }
@@ -300,7 +311,11 @@ export function createStaticHandler(
300
311
  return {
301
312
  status: 200,
302
313
  body: data,
303
- headers: { "content-type": mimeType, "cache-control": cacheControl },
314
+ headers: {
315
+ "content-type": mimeType,
316
+ "cache-control": cacheControl,
317
+ ...buildHeaders,
318
+ },
304
319
  };
305
320
  }
306
321
  } catch {
@@ -25,6 +25,7 @@ import { createNetworkPoolRoutes } from "./routing/pools/routes";
25
25
  import { DrizzleModelRoutingStore } from "./routing/model/store";
26
26
  import { DrizzleNetworkPoolStore } from "./routing/pools/store";
27
27
  import { createRuntimeSettingsRoutes } from "./settings/routes";
28
+ import { createSystemPromptRoutes } from "./system-prompt/routes";
28
29
  import { createDataManagementRoutes } from "./data-management/routes";
29
30
  import { healthEventBuffer } from "../persistence/health-event-flush";
30
31
  import { countAllHealthEvents, deleteAllHealthEvents } from "../providers/operations/account-health-service";
@@ -306,6 +307,7 @@ export function registerConsoleDomains(
306
307
  relayFetch: createValidatedFetch({ policy: resolveSsrfPolicy() }),
307
308
  }));
308
309
  console.use(createRuntimeSettingsRoutes({ store: runtimeSettingsStore, accessResolver: ctx.accessResolver, auditSink: ctx.auditRecorder, snapshotInvalidator: ctx.routeSnapshotService }));
310
+ console.use(createSystemPromptRoutes({ db: ctx.db, accessResolver: ctx.accessResolver, auditSink: ctx.auditRecorder }));
309
311
  console.use(
310
312
  createDataManagementRoutes({
311
313
  telemetryDir: TELEMETRY_EVENT_DIR,
@@ -43,6 +43,10 @@ import {
43
43
  fetchQoderDailyCheckin,
44
44
  isQoderCheckinProvider,
45
45
  } from "../../providers/integrations/qoder/qoder-checkin";
46
+ import {
47
+ DrizzleQoderNativeIdentityStore,
48
+ type QoderNativeIdentityStore,
49
+ } from "../../providers/integrations/qoder/qoder-native-identity-store";
46
50
  import { buddyAccountUid } from "../../providers/integrations/buddy/buddy-oauth-shared";
47
51
  import {
48
52
  supportsAccountReset,
@@ -108,6 +112,7 @@ async function runAccountCheckin(
108
112
  account: { id: string; providerId: string },
109
113
  credential: string,
110
114
  forceClaim: boolean,
115
+ nativeIdentityStore?: QoderNativeIdentityStore,
111
116
  ): Promise<DailyCheckinResult> {
112
117
  if (isQoderCheckinProvider(account.providerId)) {
113
118
  return fetchQoderDailyCheckin({
@@ -115,6 +120,7 @@ async function runAccountCheckin(
115
120
  accountId: account.id,
116
121
  credential,
117
122
  fetcher: globalThis.fetch,
123
+ ...(nativeIdentityStore ? { nativeIdentityStore } : {}),
118
124
  });
119
125
  }
120
126
  return fetchDailyCheckin({
@@ -202,6 +208,7 @@ async function runDailyGrowthForAccount(
202
208
  accountId: account.id,
203
209
  credential,
204
210
  fetcher,
211
+ nativeIdentityStore: new DrizzleQoderNativeIdentityStore(refreshDeps.db),
205
212
  })
206
213
  : await fetchDailyCheckin({
207
214
  providerId,
@@ -884,7 +891,7 @@ export function registerAccountQuotaTenantRoutes(
884
891
  account.providerId as ProviderId,
885
892
  account.id,
886
893
  );
887
- result = await runAccountCheckin(account, credential, true);
894
+ result = await runAccountCheckin(account, credential, true, new DrizzleQoderNativeIdentityStore(db));
888
895
  } catch (error) {
889
896
  result = {
890
897
  providerId: account.providerId,