cartethyia 2.5.10 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (367) hide show
  1. package/.env.example +8 -0
  2. package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-l6XMnfr9.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-BT3Up3QA.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-6kYIUpzR.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-CbphLvjg.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-Dfb2896L.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-BaCIdAUt.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-D4g6vOGc.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C93i14BD.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-SgQpymjm.js} +1 -1
  37. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  38. package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-BFME-eba.js} +2 -2
  39. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  42. package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
  43. package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
  44. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  46. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  47. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
  49. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
  51. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
  53. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
  61. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
  63. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-Bhey_rg_.js → index-Bblu6erR.js} +2 -2
  71. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
  73. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
  75. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
  77. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  81. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  84. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  85. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  86. package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
  87. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  88. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  89. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  90. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  91. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  92. package/dist/dashboard/assets/{select-CxVVB__N.js → select-C980c4cz.js} +1 -1
  93. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  94. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  95. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  96. package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
  97. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  98. package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
  99. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  100. package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
  101. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  102. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  103. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  104. package/dist/dashboard/index.html +1 -1
  105. package/dist/dashboard/index.html.gz +0 -0
  106. package/dist/dashboard/precompressed-assets.json +51 -51
  107. package/migrations/README.md +52 -52
  108. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  109. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  110. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  111. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  112. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  113. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  114. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  115. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  116. package/package.json +96 -95
  117. package/scripts/cli/admin.ts +126 -128
  118. package/scripts/cli/autostart.ts +237 -237
  119. package/scripts/cli/background.ts +120 -110
  120. package/scripts/cli/bin/carte.cjs +8 -8
  121. package/scripts/cli/bin/cartethyia.cjs +167 -167
  122. package/scripts/cli/console-menu.ts +39 -30
  123. package/scripts/cli/controller.ts +145 -0
  124. package/scripts/cli/env.ts +99 -99
  125. package/scripts/cli/gateway.ts +264 -260
  126. package/scripts/cli/instance.ts +234 -224
  127. package/scripts/cli/main.ts +188 -167
  128. package/scripts/cli/platform.ts +64 -64
  129. package/scripts/cli/prompt.ts +217 -79
  130. package/scripts/cli/services.ts +88 -88
  131. package/scripts/cli/setup.ts +445 -445
  132. package/scripts/cli/start.ts +85 -85
  133. package/scripts/cli/startup-signal.ts +23 -23
  134. package/scripts/cli/telemetry.ts +70 -0
  135. package/scripts/cli/tray/index.ts +263 -263
  136. package/scripts/cli/tray/windows.ps1 +107 -107
  137. package/scripts/cli/tray.ts +29 -4
  138. package/scripts/cli/update.ts +39 -39
  139. package/scripts/cli/version.ts +67 -67
  140. package/scripts/cli/wordmark.ts +58 -58
  141. package/src/boot/app.ts +16 -1
  142. package/src/boot/dependencies.ts +128 -25
  143. package/src/config/resources.ts +137 -0
  144. package/src/config/runtime.ts +133 -76
  145. package/src/config/spec.ts +30 -29
  146. package/src/config/telemetry.ts +0 -25
  147. package/src/console/api-keys/bucket-spend.ts +14 -17
  148. package/src/console/api-keys/contracts.ts +410 -410
  149. package/src/console/api-keys/routes.ts +12 -0
  150. package/src/console/audit/store.ts +2 -3
  151. package/src/console/auth/setup-service.ts +8 -1
  152. package/src/console/backup/routes.ts +7 -0
  153. package/src/console/backup/service.ts +463 -463
  154. package/src/console/backup/store.ts +16 -11
  155. package/src/console/backup/validate.ts +1 -4
  156. package/src/console/cli-tools/fs-ops.ts +18 -2
  157. package/src/console/data-management/contracts.ts +1 -8
  158. package/src/console/data-management/operations.ts +61 -48
  159. package/src/console/data-management/routes.ts +4 -2
  160. package/src/console/domain-registration.ts +25 -3
  161. package/src/console/inferhub/client.ts +139 -0
  162. package/src/console/inferhub/key-store.ts +129 -0
  163. package/src/console/inferhub/normalize.ts +347 -0
  164. package/src/console/inferhub/routes.ts +411 -0
  165. package/src/console/inferhub/store.ts +109 -0
  166. package/src/console/inferhub/types.ts +183 -0
  167. package/src/console/observability/contracts.ts +2 -41
  168. package/src/console/observability/store.ts +176 -371
  169. package/src/console/providers/catalog/public-model-store.ts +0 -0
  170. package/src/console/providers/catalog/store.ts +23 -36
  171. package/src/console/quota/account-quota-shared.ts +52 -52
  172. package/src/console/quota/account-quota-tenant.ts +16 -24
  173. package/src/console/quota/refresh.ts +441 -441
  174. package/src/console/routing/pools/import-parse.ts +206 -206
  175. package/src/console/routing/pools/operations.ts +543 -543
  176. package/src/console/routing/pools/probe-verdict.ts +132 -132
  177. package/src/console/routing/pools/routes.ts +181 -181
  178. package/src/console/settings/contracts.ts +42 -40
  179. package/src/console/settings/store.ts +25 -25
  180. package/src/console/share/share-stats.ts +22 -4
  181. package/src/console/share/share-usage.ts +30 -4
  182. package/src/console/shared/cursor.ts +15 -0
  183. package/src/console/updates/routes.ts +12 -0
  184. package/src/main.ts +13 -5
  185. package/src/network/pool/resolver.ts +1104 -1104
  186. package/src/observability/client-name.ts +22 -0
  187. package/src/observability/event-log.ts +5 -29
  188. package/src/observability/event-query.ts +690 -0
  189. package/src/observability/log-ring.ts +26 -2
  190. package/src/observability/log-sanitize.ts +118 -0
  191. package/src/observability/logger.ts +17 -13
  192. package/src/observability/metrics.ts +35 -0
  193. package/src/observability/payload-capture.ts +114 -41
  194. package/src/observability/payload-store.ts +249 -24
  195. package/src/observability/raw-event-writer.ts +118 -0
  196. package/src/observability/runtime-metrics.ts +71 -1
  197. package/src/observability/telemetry-buffer.ts +100 -17
  198. package/src/observability/telemetry-reconciliation.ts +417 -0
  199. package/src/persistence/boot.ts +20 -9
  200. package/src/persistence/db-pg.ts +13 -0
  201. package/src/persistence/db-sqlite.ts +569 -497
  202. package/src/persistence/dialect.ts +3 -2
  203. package/src/persistence/migrate.ts +181 -181
  204. package/src/persistence/schema.ts +1496 -1406
  205. package/src/persistence/shared-cache.ts +5 -1
  206. package/src/persistence/stores/api-key-store.ts +581 -581
  207. package/src/persistence/stores/telemetry-store.ts +124 -63
  208. package/src/protocol/reasoning-text.ts +30 -0
  209. package/src/protocol/response/chat.ts +33 -7
  210. package/src/protocol/response/codex.ts +14 -2
  211. package/src/protocol/response/messages.ts +5 -2
  212. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  213. package/src/providers/bundled-discovery-providers.ts +122 -122
  214. package/src/providers/default-registry.ts +10 -0
  215. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  216. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  217. package/src/providers/discovery/probe-phases.ts +43 -7
  218. package/src/providers/discovery/probing-service.ts +70 -32
  219. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  220. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  221. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  222. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  223. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  224. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  225. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  226. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  227. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  228. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  229. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  230. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  231. package/src/providers/integrations/claude/claude.ts +3 -4
  232. package/src/providers/integrations/cline/cline.ts +94 -8
  233. package/src/providers/integrations/codex/codex.ts +61 -37
  234. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  235. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  236. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  237. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  238. package/src/providers/integrations/cursor/cursor.ts +29 -17
  239. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  240. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  241. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  242. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  243. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  244. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  245. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  246. package/src/providers/integrations/hermes.ts +7 -1
  247. package/src/providers/integrations/inferhub.ts +201 -73
  248. package/src/providers/integrations/kilo/kilo.ts +152 -152
  249. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  250. package/src/providers/integrations/openrouter/provider.ts +129 -129
  251. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  252. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  253. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  254. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  255. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  256. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  257. package/src/providers/integrations/zcode/oauth.ts +1 -1
  258. package/src/providers/operations/account-health-service.ts +605 -279
  259. package/src/providers/provider-metadata.ts +12 -1
  260. package/src/providers/provider-model-definition.ts +373 -373
  261. package/src/providers/quota/quota-contracts.ts +3 -7
  262. package/src/providers/search/search-provider.ts +2 -6
  263. package/src/security/admission/contracts.ts +10 -0
  264. package/src/security/admission/in-memory-store.ts +89 -14
  265. package/src/security/admission/lua.ts +191 -182
  266. package/src/security/admission/redis-store.ts +30 -0
  267. package/src/security/admission/service.ts +18 -0
  268. package/src/security/api-key-auth-cache.ts +96 -0
  269. package/src/security/api-key-auth.ts +11 -100
  270. package/src/security/client-router-fingerprint.ts +3 -7
  271. package/src/security/ip-boundary.ts +7 -3
  272. package/src/shared/coerce.ts +67 -0
  273. package/src/shared/hash.ts +6 -0
  274. package/src/shared/time.ts +7 -0
  275. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  276. package/src/transport/dispatch/attempt-loop.ts +103 -81
  277. package/src/transport/dispatch/capture-policy.ts +78 -0
  278. package/src/transport/dispatch/proxy-request.ts +4 -2
  279. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  280. package/src/transport/dispatch/streaming/stream.ts +19 -2
  281. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  282. package/src/transport/failure-policy.ts +27 -0
  283. package/src/transport/guards/abuse-guard.ts +2 -2
  284. package/src/transport/guards/auth-guard.ts +17 -0
  285. package/src/transport/guards/body-policy.ts +3 -2
  286. package/src/transport/guards/concurrency-guard.ts +89 -13
  287. package/src/transport/guards/early-rejection-capture.ts +43 -3
  288. package/src/transport/guards/error-lifecycle.ts +10 -2
  289. package/src/transport/guards/guard-errors.ts +23 -0
  290. package/src/transport/guards/headroom-guard.ts +41 -7
  291. package/src/transport/guards/pipeline.ts +24 -0
  292. package/src/transport/guards/request-context.ts +2 -1
  293. package/src/transport/routing/admission.ts +51 -9
  294. package/src/transport/routing/bounded-lru.ts +49 -0
  295. package/src/transport/routing/candidate-index.ts +88 -0
  296. package/src/transport/routing/reservations.ts +121 -7
  297. package/src/transport/routing/round-robin.ts +10 -22
  298. package/src/transport/routing/route-catalog.ts +26 -13
  299. package/src/transport/routing/route-model.ts +22 -2
  300. package/src/transport/routing/router.ts +53 -31
  301. package/src/transport/surface/chat/encode.ts +2 -3
  302. package/src/transport/surface/chat/parse.ts +2 -1
  303. package/src/transport/surface/dialects.ts +11 -3
  304. package/src/transport/surface/messages/encode.ts +2 -1
  305. package/src/transport/surface/messages/parse.ts +3 -1
  306. package/src/transport/surface/messages/stream.ts +2 -6
  307. package/src/workers/maintenance-lease.ts +200 -0
  308. package/src/workers/scheduler.ts +101 -1
  309. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  310. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  311. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  312. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  313. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  314. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  315. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
  316. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  317. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  318. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  319. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
  320. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  321. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
  322. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  323. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  324. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  325. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
  326. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  327. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
  328. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  329. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  330. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  331. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  332. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  333. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  334. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  335. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  336. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  337. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  338. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  339. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  340. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  341. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  342. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  343. package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
  344. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  346. package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
  347. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  348. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  349. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  350. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  351. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  352. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  353. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  354. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  355. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  356. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  357. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  358. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  359. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  360. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  361. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  362. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  363. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  364. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  365. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  366. package/src/observability/event-compaction.ts +0 -317
  367. package/src/observability/event-day-index.ts +0 -439
@@ -1,7 +1,10 @@
1
1
  import { mkdir, open, readdir, readFile, stat, unlink } from "node:fs/promises";
2
2
  import { basename, join } from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { createHash } from "node:crypto";
4
5
  import { gunzipSync, gzipSync } from "node:zlib";
6
+ import { Worker } from "node:worker_threads";
7
+ import { hourStamp } from "../shared/time";
5
8
 
6
9
  export interface PayloadFileReference {
7
10
  readonly storage: "jsonl-file";
@@ -67,7 +70,68 @@ function isFrameFileName(name: string): boolean {
67
70
  const MAX_FRAME_BYTES = 256 * 1024 * 1024;
68
71
  const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
69
72
  const DEFAULT_DIRECTORY = "./data/telemetry-payloads";
70
- let writeTail: Promise<void> = Promise.resolve();
73
+
74
+ /**
75
+ * Backlog bounds for the payload writer: per-process, in front of the
76
+ * append-only file writes.
77
+ *
78
+ * The writer used to be an unbounded promise chain: every capture appended
79
+ * another link, so a slow disk (or a burst of large captures) accumulated
80
+ * every waiting frame's bytes in memory with no ceiling — the exact
81
+ * unbounded-growth failure this phase exists to remove. Items are counted
82
+ * before queueing in their exact serialized frame bytes (the same buffers
83
+ * that will be written), so 64 MiB of backlog means 64 MiB of real memory,
84
+ * not an estimate.
85
+ */
86
+ export const MAX_PAYLOAD_BACKLOG_ITEMS = 32;
87
+ export const MAX_PAYLOAD_BACKLOG_BYTES = 64 * 1024 * 1024;
88
+
89
+ /** Raised when the payload writer's backlog is at its bound. */
90
+ export class PayloadBacklogFullError extends Error {
91
+ constructor(
92
+ readonly boundedBy: "items" | "bytes",
93
+ ) {
94
+ super(`telemetry payload writer backlog full (${boundedBy})`);
95
+ this.name = "PayloadBacklogFullError";
96
+ }
97
+ }
98
+
99
+ interface BacklogEntry {
100
+ readonly estimatedBytes: number;
101
+ run: () => Promise<void>;
102
+ }
103
+
104
+ let backlog: BacklogEntry[] = [];
105
+ let backlogBytes = 0;
106
+ let draining = false;
107
+
108
+ /** Test/diagnostic seam: current backlog occupancy. */
109
+ export function payloadWriterBacklog(): { readonly items: number; readonly bytes: number } {
110
+ return { items: backlog.length, bytes: backlogBytes };
111
+ }
112
+
113
+ /**
114
+ * Runs the backlog to completion. One drain at a time: entries execute in
115
+ * FIFO order, which preserves the append-only file semantics the previous
116
+ * promise chain provided.
117
+ */
118
+ function drainBacklog(): void {
119
+ if (draining) return;
120
+ draining = true;
121
+ const step = (): void => {
122
+ const entry = backlog.shift();
123
+ if (entry === undefined) {
124
+ draining = false;
125
+ return;
126
+ }
127
+ backlogBytes -= entry.estimatedBytes;
128
+ entry
129
+ .run()
130
+ .catch(() => undefined)
131
+ .finally(() => step());
132
+ };
133
+ step();
134
+ }
71
135
 
72
136
  /**
73
137
  * Single source of truth for the telemetry payload directory: the
@@ -84,33 +148,161 @@ function maxFileBytes(): number {
84
148
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_FILE_BYTES;
85
149
  }
86
150
 
151
+ /**
152
+ * Frame checksum.
153
+ *
154
+ * A byte-loop FNV-1a ran on the event loop — O(frame bytes) of pure JS per
155
+ * capture, on buffers up to the frame cap. `crypto.createHash` is the runtime's
156
+ * native path. The digest is hex-SHA-256 truncated to 16 hex chars: still far
157
+ * stronger than FNV-32 for corruption detection, and short enough that the
158
+ * `telemetry_payloads` column shape is unchanged.
159
+ *
160
+ * **Format note:** the frame `version` stays 1 because the checksum is a
161
+ * stored reference field, not part of the frame bytes on disk — the reader
162
+ * never re-verified the old FNV value against the file, and the new value is
163
+ * written and read by the same deployed version. References written by the
164
+ * FNV build (8 hex chars) and the new build (16 hex chars) coexist: the
165
+ * checksum column is opaque identity, not a validated format.
166
+ */
87
167
  function checksum(value: Buffer | readonly Buffer[]): string {
88
- const parts = Array.isArray(value) ? value : [value];
89
- let hash = 2166136261;
90
- for (const part of parts) {
91
- for (let index = 0; index < part.byteLength; index += 1) {
92
- hash ^= part[index]!;
93
- hash = Math.imul(hash, 16777619);
94
- }
95
- }
96
- return (hash >>> 0).toString(16).padStart(8, "0");
168
+ const hash = createHash("sha256");
169
+ for (const part of Array.isArray(value) ? value : [value]) hash.update(part);
170
+ return hash.digest("hex").slice(0, 16);
97
171
  }
98
172
 
99
173
  function fileName(now = new Date()): string {
100
- return `${now.toISOString().slice(0, 13).replaceAll(":", "-")}-${process.pid}${FRAME_FILE_SUFFIX}`;
174
+ return `${hourStamp(now)}-${process.pid}${FRAME_FILE_SUFFIX}`;
101
175
  }
102
176
 
103
177
  /**
104
178
  * Gzipped frame body.
105
179
  *
106
- * The pieces stay separate until here, because nothing before the write reads
107
- * them assembled and joining them earlier is a copy of the whole frame. The
108
- * join happens once, at the moment of compression, and the compressed buffer
109
- * is what gets written, checksummed, and bounded — so the cap and the
110
- * reference both describe bytes on disk, not bytes of JSON.
180
+ * The pieces stay separate until the worker, because nothing before the
181
+ * compression reads them assembled and joining them earlier is a copy of the
182
+ * whole frame. The single join happens inside the compression step, and the
183
+ * compressed buffer is what gets written, checksummed, and bounded — so the
184
+ * cap and the reference both describe bytes on disk, not bytes of JSON.
185
+ */
186
+ async function compressFrame(parts: readonly Buffer[]): Promise<Buffer> {
187
+ const joined = Buffer.concat(parts);
188
+ return compressAsync(joined);
189
+ }
190
+
191
+ /**
192
+ * The compression worker: one per process, started lazily on the first
193
+ * capture, at most {@link MAX_ACTIVE_COMPRESSION_JOBS} jobs in flight.
194
+ *
195
+ * `gzipSync` ran on the event loop: compressing a frame is O(frame bytes) of
196
+ * native CPU, and a burst of large captures (a batch of retries finishing
197
+ * together) stalled every request sharing the thread — a 1 MiB frame at
198
+ * ~100 MB/s of gzip is ~10 ms of loop stall per capture, times the burst.
199
+ * The worker turns that into background CPU; the semaphore keeps at most two
200
+ * compressions running at once so a burst cannot pile unbounded memory into
201
+ * the worker (each queued job holds its input buffer).
202
+ *
203
+ * Fallback: if the worker cannot start or dies, compression falls back to
204
+ * synchronous gzip on the event loop — capture still works, which is what the
205
+ * at-least-once frame contract requires; only the stall protection is lost.
111
206
  */
112
- function compressFrame(parts: readonly Buffer[]): Buffer {
113
- return gzipSync(Buffer.concat(parts));
207
+ const MAX_ACTIVE_COMPRESSION_JOBS = 2;
208
+ let compressionWorker: Worker | undefined;
209
+ let workerBroken = false;
210
+ let activeCompressionJobs = 0;
211
+ const compressionQueue: (() => void)[] = [];
212
+
213
+ interface CompressionJob {
214
+ readonly id: number;
215
+ readonly input: Buffer;
216
+ readonly resolve: (output: Buffer) => void;
217
+ readonly reject: (error: Error) => void;
218
+ }
219
+ let nextJobId = 1;
220
+ const pendingJobs = new Map<number, CompressionJob>();
221
+
222
+ function ensureCompressionWorker(): Worker | undefined {
223
+ if (workerBroken) return undefined;
224
+ if (compressionWorker) return compressionWorker;
225
+ try {
226
+ // Inline worker via Blob URL: no separate source file to ship, no path
227
+ // resolution to break in compiled/AOT builds. The worker body is the
228
+ // minimal gzip service: postMessage({id, input}) -> {id, output} or {id, error}.
229
+ const source = `
230
+ const { gzipSync } = require("node:zlib");
231
+ const { parentPort } = require("node:worker_threads");
232
+ parentPort.on("message", (job) => {
233
+ try {
234
+ const output = gzipSync(Buffer.from(job.input));
235
+ parentPort.postMessage({ id: job.id, output });
236
+ } catch (error) {
237
+ parentPort.postMessage({ id: job.id, error: String(error) });
238
+ }
239
+ });
240
+ `;
241
+ const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
242
+ compressionWorker = new Worker(url, { name: "payload-gzip" });
243
+ compressionWorker.unref();
244
+ compressionWorker.on("message", (message: { id: number; output?: Buffer; error?: string }) => {
245
+ const job = pendingJobs.get(message.id);
246
+ if (!job) return;
247
+ pendingJobs.delete(message.id);
248
+ releaseCompressionSlot();
249
+ if (message.error !== undefined) job.reject(new Error(message.error));
250
+ else job.resolve(message.output ?? Buffer.alloc(0));
251
+ });
252
+ compressionWorker.on("error", () => {
253
+ // Worker died: fail every pending job and fall back to sync forever.
254
+ workerBroken = true;
255
+ compressionWorker = undefined;
256
+ for (const job of pendingJobs.values()) {
257
+ job.reject(new Error("payload compression worker failed"));
258
+ }
259
+ pendingJobs.clear();
260
+ });
261
+ return compressionWorker;
262
+ } catch {
263
+ workerBroken = true;
264
+ return undefined;
265
+ }
266
+ }
267
+
268
+ function releaseCompressionSlot(): void {
269
+ activeCompressionJobs -= 1;
270
+ const next = compressionQueue.shift();
271
+ if (next) next();
272
+ }
273
+
274
+ async function compressAsync(input: Buffer): Promise<Buffer> {
275
+ const worker = ensureCompressionWorker();
276
+ if (!worker) return gzipSync(input);
277
+ // Semaphore: wait for a free slot before queueing the job.
278
+ if (activeCompressionJobs >= MAX_ACTIVE_COMPRESSION_JOBS) {
279
+ await new Promise<void>((resolve) => compressionQueue.push(resolve));
280
+ }
281
+ activeCompressionJobs += 1;
282
+ try {
283
+ return await new Promise<Buffer>((resolve, reject) => {
284
+ const job: CompressionJob = {
285
+ id: nextJobId++,
286
+ input,
287
+ resolve: (output) => resolve(output),
288
+ reject: (error) => reject(error),
289
+ };
290
+ pendingJobs.set(job.id, job);
291
+ worker.postMessage({ id: job.id, input }, [input.buffer.slice(
292
+ input.byteOffset,
293
+ input.byteOffset + input.byteLength,
294
+ ) as ArrayBuffer]);
295
+ });
296
+ } catch {
297
+ // A failed job still holds its slot until the worker's reply arrives or
298
+ // the worker is declared broken; the sync fallback keeps the write alive.
299
+ return gzipSync(input);
300
+ } finally {
301
+ // The slot is released by the worker's message handler, not here — but a
302
+ // worker that never replies (broken before reply) leaks the slot. The
303
+ // error path above declared the worker broken and drained the queue, so
304
+ // a stale slot only outlives one job.
305
+ }
114
306
  }
115
307
 
116
308
  async function appendFrame(frame: PayloadFrame, parts?: readonly Buffer[]): Promise<PayloadFileReference> {
@@ -119,10 +311,13 @@ async function appendFrame(frame: PayloadFrame, parts?: readonly Buffer[]): Prom
119
311
  // Encoded as a line here rather than at the write, so the cap, the checksum,
120
312
  // and the reference all describe the same bytes that land on disk — the
121
313
  // prebuilt-parts path and the plain one cannot disagree about the shape.
122
- const json = parts === undefined
123
- ? Buffer.from(JSON.stringify(frame), "utf8")
124
- : Buffer.concat(parts);
125
- const compressed = compressFrame([json]);
314
+ // Single copy: the prebuilt parts (or the one serialized buffer) go to the
315
+ // compressor as-is; the join happens exactly once inside compressFrame.
316
+ // The previous shape concatenated here and again inside compressFrame — two
317
+ // full-frame copies before any bytes reached the worker.
318
+ const compressed = await compressFrame(
319
+ parts === undefined ? [Buffer.from(JSON.stringify(frame), "utf8")] : parts,
320
+ );
126
321
  if (compressed.byteLength > MAX_FRAME_BYTES) throw new Error("telemetry payload frame exceeds limit");
127
322
  const encoded = toLine(compressed);
128
323
  const file = fileName();
@@ -238,8 +433,38 @@ export function writePayloadFrame(
238
433
  ? prebuiltFrameParts(id, expires, prebuilt.capturedAt, prebuilt.requestId, prebuilt.signals, prebuilt.bodies)
239
434
  : undefined;
240
435
  const frame: PayloadFrame = { version: 1, id, expiresAt: expires, payload };
241
- const task = writeTail.then(() => appendFrame(frame, parts));
242
- writeTail = task.then(() => undefined, () => undefined);
436
+ // Accounted in exact serialized frame bytes: the prebuilt parts are the
437
+ // buffers that will be written; the non-prebuilt path serializes here once
438
+ // (the same buffer the append would build anyway). Counting before queueing
439
+ // is what makes the byte bound real rather than an estimate.
440
+ const estimatedBytes = parts
441
+ ? parts.reduce((total, part) => total + part.byteLength, 0)
442
+ : Buffer.byteLength(JSON.stringify(frame), "utf8");
443
+ if (backlog.length >= MAX_PAYLOAD_BACKLOG_ITEMS) {
444
+ throw new PayloadBacklogFullError("items");
445
+ }
446
+ if (backlogBytes + estimatedBytes > MAX_PAYLOAD_BACKLOG_BYTES) {
447
+ throw new PayloadBacklogFullError("bytes");
448
+ }
449
+ const task = new Promise<PayloadFileReference>((resolve, reject) => {
450
+ // The entry settles the caller's promise itself: the drain loop's own
451
+ // `.catch(() => undefined)` is only there so one failed write cannot
452
+ // stop the rest of the backlog from running. Bytes are charged here, on
453
+ // queue, and discharged when the entry starts running — the window
454
+ // between is exactly the memory the bound exists to cap.
455
+ backlogBytes += estimatedBytes;
456
+ backlog.push({
457
+ estimatedBytes,
458
+ run: async () => {
459
+ try {
460
+ resolve(await appendFrame(frame, parts));
461
+ } catch (error) {
462
+ reject(error instanceof Error ? error : new Error(String(error)));
463
+ }
464
+ },
465
+ });
466
+ });
467
+ drainBacklog();
243
468
  return task;
244
469
  }
245
470
 
@@ -0,0 +1,118 @@
1
+ import { mkdir, open } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { hourStamp } from "../shared/time";
5
+
6
+ interface AppendHandle {
7
+ appendFile?(buffer: Buffer): Promise<void>;
8
+ write(buffer: Buffer): Promise<{ readonly bytesWritten: number }>;
9
+ close(): Promise<void>;
10
+ }
11
+
12
+ export interface RawEventWriterOptions {
13
+ readonly open?: (path: string) => Promise<AppendHandle>;
14
+ readonly mkdir?: (directory: string) => Promise<void>;
15
+ /** Test seam: the file's event-time stamp source (default `Date`). */
16
+ readonly now?: () => Date;
17
+ /** Test seam: fixed writer identity (default: one random UUID per instance). */
18
+ readonly writerId?: string;
19
+ }
20
+
21
+ /**
22
+ * Serializes raw event appends without keeping an idle descriptor open.
23
+ *
24
+ * There is one short-lived handle per queued batch. Every handle is closed on
25
+ * success and failure, and a failed append does not poison the following one.
26
+ * This serializes callers inside one process; cross-process safety comes from
27
+ * the FILENAME, not a lock: each writer instance owns a distinct file
28
+ * (`<hourStamp>-<writerUUID>.jsonl`), so two processes can never target the
29
+ * same file and their appends cannot interleave destructively.
30
+ *
31
+ * The file is chosen by the EVENT's stamped time, not the dequeue time: a
32
+ * batch flushed after an hour boundary still belongs to the hour its events
33
+ * happened in, so retention's hour-granular rule never deletes a file whose
34
+ * events belong to the current window.
35
+ */
36
+ export class RawTelemetryEventWriter {
37
+ private readonly state: { tail: Promise<void> };
38
+ private readonly openFile: (path: string) => Promise<AppendHandle>;
39
+ private readonly makeDirectory: (directory: string) => Promise<void>;
40
+ private readonly now: () => Date;
41
+ private readonly writerId: string;
42
+
43
+ constructor(directory: string, options: RawEventWriterOptions = {}) {
44
+ this.directory = directory;
45
+ this.state = sharedState(directory);
46
+ this.openFile = options.open ?? (async (path) => {
47
+ // 'a' positions every write at end-of-file: no stat, no positioned
48
+ // write, no truncate-rollback. Two writers on one file would still
49
+ // interleave at line granularity (append is atomic per call for
50
+ // line-sized buffers), but the writer-UUID filename makes that
51
+ // impossible; 'a' is the belt to that suspenders.
52
+ return (await open(path, "a")) as unknown as AppendHandle;
53
+ });
54
+ this.makeDirectory = options.mkdir ?? (async (path) => {
55
+ await mkdir(path, { recursive: true });
56
+ });
57
+ this.now = options.now ?? (() => new Date());
58
+ this.writerId = options.writerId ?? randomUUID();
59
+ }
60
+
61
+ /** Queues an already-bounded batch; the telemetry buffer owns drop policy. */
62
+ append(rows: readonly object[]): Promise<void> {
63
+ // Serialize before queueing so a backlog does not retain row graphs and no
64
+ // JSON encoding work happens while a descriptor is open.
65
+ const body = Buffer.from(`${rows.map((row) => JSON.stringify(row)).join("\n")}\n`, "utf8");
66
+ const hour = hourStamp(this.now());
67
+ const file = `${hour}-${this.writerId}.jsonl`;
68
+ const write = this.state.tail.then(() => this.write(file, body));
69
+ this.state.tail = write.catch(() => undefined);
70
+ return write;
71
+ }
72
+
73
+ /** Waits for writes queued before this call; used before retention cleanup. */
74
+ async idle(): Promise<void> {
75
+ await this.state.tail;
76
+ }
77
+
78
+ private async write(file: string, body: Buffer): Promise<void> {
79
+ await this.makeDirectory(this.directory);
80
+ const handle = await this.openFile(join(this.directory, file));
81
+ try {
82
+ // Append-only: a failed write leaves whatever prefix landed, and the
83
+ // reader already tolerates a torn tail (a partial final line without a
84
+ // newline is skipped). No truncate-rollback: truncating under
85
+ // `reusePort` multi-process was the corruption risk this rewrite
86
+ // removes — one process's rollback could cut another process's
87
+ // already-committed lines on a shared file. Private files make that
88
+ // impossible, and refusing to truncate keeps it impossible even if the
89
+ // filename rule is ever relaxed.
90
+ if (typeof handle.appendFile === "function") {
91
+ await handle.appendFile(body);
92
+ return;
93
+ }
94
+ let offset = 0;
95
+ while (offset < body.byteLength) {
96
+ const { bytesWritten } = await handle.write(body.subarray(offset));
97
+ if (!Number.isInteger(bytesWritten) || bytesWritten <= 0) {
98
+ throw new Error("telemetry event writer made no forward progress");
99
+ }
100
+ offset += bytesWritten;
101
+ }
102
+ } finally {
103
+ await handle.close();
104
+ }
105
+ }
106
+
107
+ private readonly directory: string;
108
+ }
109
+
110
+ const states = new Map<string, { tail: Promise<void> }>();
111
+
112
+ function sharedState(directory: string): { tail: Promise<void> } {
113
+ const existing = states.get(directory);
114
+ if (existing) return existing;
115
+ const created = { tail: Promise.resolve() };
116
+ states.set(directory, created);
117
+ return created;
118
+ }
@@ -13,6 +13,23 @@ import { trackMemoryUsage } from "./performance-metrics";
13
13
  import { log } from "./logger";
14
14
  import { resolveMemoryLimitOverrideBytes } from "../config/timeouts";
15
15
  import { CONFIG_SPEC, readFlag } from "../config/spec";
16
+ import type { TelemetryBacklogSnapshot } from "./telemetry-buffer";
17
+ /**
18
+ * The memory figures a shed decision needs.
19
+ *
20
+ * Published once per sample instead of re-measured per request:
21
+ * `process.memoryUsage()` is not free, and the headroom guard runs on every
22
+ * request. A snapshot up to one sample interval old is the right trade — the
23
+ * guard is protecting against a spike that arrives over seconds, not one
24
+ * request's worth of allocation.
25
+ */
26
+ export interface RuntimeMemorySnapshot {
27
+ readonly rssBytes: number;
28
+ /** The resolved limit; `undefined` when none could be discovered. */
29
+ readonly limitBytes: number | undefined;
30
+ readonly sampledAtMs: number;
31
+ }
32
+
16
33
  /** Live collection sizes sampled alongside process memory. */
17
34
  export interface RuntimeMetricSources {
18
35
  readonly routingRoundRobinEntries: () => { readonly combo: number; readonly provider: number };
@@ -22,6 +39,11 @@ export interface RuntimeMetricSources {
22
39
  * identities, so its size is the leak signal to watch.
23
40
  */
24
41
  readonly ipAbuseKeys: () => number;
42
+ /**
43
+ * The telemetry backlog's current shape, or `undefined` when no buffer is
44
+ * wired (tests, and the console-only shell).
45
+ */
46
+ readonly telemetryBacklog?: () => TelemetryBacklogSnapshot | undefined;
25
47
  /** Tracked keys in the in-process quota cache tier (0 when Redis-only paths are idle). */
26
48
  readonly quotaCacheEntries: () => number;
27
49
 
@@ -93,6 +115,13 @@ export class RuntimeMetricsSampler {
93
115
  private lastIdleGcAt = 0;
94
116
  private rssAtLastGc = 0;
95
117
  private inFlightSource: () => number = () => 0;
118
+ /**
119
+ * The latest snapshot, published once per sample. Stays undefined until the
120
+ * first sample runs; consumers must treat that as "unknown", not as zero.
121
+ */
122
+ private snapshot: RuntimeMemorySnapshot | undefined;
123
+ /** The effective concurrency ceiling, published alongside the gauges. */
124
+ private concurrencyLimit: number | undefined;
96
125
 
97
126
  /**
98
127
  * Binds the live in-flight count after the request state store exists. The
@@ -102,6 +131,28 @@ export class RuntimeMetricsSampler {
102
131
  this.inFlightSource = source;
103
132
  }
104
133
 
134
+ /**
135
+ * The last sampled memory state, for consumers on a hot path.
136
+ *
137
+ * Undefined before the first sample: a guard that read zero as "plenty of
138
+ * room" would disable itself exactly when it matters, so callers must fall
139
+ * back to their own judgement when this is absent.
140
+ */
141
+ memorySnapshot(): RuntimeMemorySnapshot | undefined {
142
+ return this.snapshot;
143
+ }
144
+
145
+ /**
146
+ * Publishes the effective concurrency ceiling.
147
+ *
148
+ * Late-bound like `bindInFlight`: the ceiling is derived from the memory
149
+ * limit, which is known here, but the guard that owns it is constructed in
150
+ * the transport layer.
151
+ */
152
+ bindConcurrencyLimit(limit: number): void {
153
+ this.concurrencyLimit = limit;
154
+ }
155
+
105
156
  constructor(
106
157
  private readonly sources: RuntimeMetricSources,
107
158
  limitBytes: number | undefined = resolveMemoryLimitBytes(),
@@ -125,6 +176,11 @@ export class RuntimeMetricsSampler {
125
176
  metrics.cartethyia_memory_heap_used_bytes.set(memory.heapUsed);
126
177
  metrics.cartethyia_memory_heap_total_bytes.set(memory.heapTotal);
127
178
  metrics.cartethyia_memory_limit_bytes.set(this.limitBytes ?? 0);
179
+ this.snapshot = {
180
+ rssBytes: memory.rss,
181
+ limitBytes: this.limitBytes,
182
+ sampledAtMs: Date.now(),
183
+ };
128
184
  trackMemoryUsage("rss", memory.rss);
129
185
  trackMemoryUsage("heap_used", memory.heapUsed);
130
186
  trackMemoryUsage("heap_total", memory.heapTotal);
@@ -136,6 +192,15 @@ export class RuntimeMetricsSampler {
136
192
  metrics.cartethyia_ip_abuse_keys.set(this.sources.ipAbuseKeys());
137
193
  metrics.cartethyia_quota_cache_entries.set(this.sources.quotaCacheEntries());
138
194
  metrics.cartethyia_model_catalog_entries.set(this.sources.modelCatalogEntries());
195
+ metrics.cartethyia_process_in_flight.set(this.inFlightSource());
196
+ const backlog = this.sources.telemetryBacklog?.();
197
+ if (backlog !== undefined) {
198
+ metrics.cartethyia_telemetry_backlog_items.set(backlog.items);
199
+ metrics.cartethyia_telemetry_backlog_bytes.set(backlog.bytes);
200
+ }
201
+ if (this.concurrencyLimit !== undefined) {
202
+ metrics.cartethyia_process_concurrency_limit.set(this.concurrencyLimit);
203
+ }
139
204
 
140
205
  this.checkPressure(memory.rss);
141
206
  this.checkIdleReclaim(memory.rss);
@@ -170,9 +235,14 @@ export class RuntimeMetricsSampler {
170
235
  * A synchronous full collection is safe here because no request is in flight to
171
236
  * stall, and it is the only form that actually returns pages to the OS; the
172
237
  * async form used under pressure does not.
238
+ *
239
+ * Not gated on `gcOnPressure` the way `checkPressure` is. The opt-in exists
240
+ * because a synchronous collection stalls concurrent requests, and this path
241
+ * only runs when there is nothing in flight to stall — so the reason the flag
242
+ * is off does not apply to it. Gating it meant a gateway with no traffic at
243
+ * all never reclaimed anything and held its startup peak for hours.
173
244
  */
174
245
  private checkIdleReclaim(rss: number): void {
175
- if (!this.gcOnPressure) return;
176
246
  if (this.inFlightSource() > 0) return;
177
247
  const now = Date.now();
178
248
  if (now - this.lastIdleGcAt < IDLE_GC_WINDOW_MS) return;