cartethyia 2.5.9 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (386) hide show
  1. package/.env.example +15 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
  38. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  39. package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
  40. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  41. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  42. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  43. package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
  44. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  46. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  48. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
  50. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
  52. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
  54. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
  60. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
  62. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  66. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  68. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
  72. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
  74. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
  76. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  77. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
  78. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
  80. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  82. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  86. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  92. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
  94. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  96. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
  98. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
  100. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
  102. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  105. package/dist/dashboard/index.html +1 -1
  106. package/dist/dashboard/index.html.gz +0 -0
  107. package/dist/dashboard/precompressed-assets.json +51 -51
  108. package/migrations/README.md +52 -52
  109. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  110. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  111. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  112. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  113. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  114. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  115. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  116. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  117. package/package.json +96 -97
  118. package/scripts/cli/admin.ts +126 -125
  119. package/scripts/cli/autostart.ts +237 -237
  120. package/scripts/cli/background.ts +120 -110
  121. package/scripts/cli/bin/carte.cjs +8 -8
  122. package/scripts/cli/bin/cartethyia.cjs +167 -167
  123. package/scripts/cli/console-menu.ts +308 -275
  124. package/scripts/cli/controller.ts +145 -0
  125. package/scripts/cli/doctor.ts +1 -1
  126. package/scripts/cli/env.ts +99 -99
  127. package/scripts/cli/gateway.ts +264 -261
  128. package/scripts/{health.ts → cli/health.ts} +4 -4
  129. package/scripts/cli/instance.ts +234 -224
  130. package/scripts/cli/main.ts +188 -164
  131. package/scripts/cli/platform.ts +64 -64
  132. package/scripts/cli/prompt.ts +275 -107
  133. package/scripts/cli/services.ts +88 -88
  134. package/scripts/cli/setup.ts +445 -445
  135. package/scripts/cli/start.ts +85 -82
  136. package/scripts/cli/startup-signal.ts +23 -23
  137. package/scripts/cli/telemetry.ts +70 -0
  138. package/scripts/cli/tray/index.ts +263 -263
  139. package/scripts/cli/tray/windows.ps1 +107 -107
  140. package/scripts/cli/tray.ts +192 -208
  141. package/scripts/cli/update.ts +39 -39
  142. package/scripts/cli/version.ts +67 -67
  143. package/scripts/cli/wordmark.ts +58 -58
  144. package/src/boot/app.ts +16 -1
  145. package/src/boot/dependencies.ts +128 -25
  146. package/src/config/package-paths.ts +24 -0
  147. package/src/config/resources.ts +137 -0
  148. package/src/config/runtime.ts +133 -76
  149. package/src/config/spec.ts +30 -29
  150. package/src/config/telemetry.ts +0 -25
  151. package/src/console/api-keys/bucket-spend.ts +14 -17
  152. package/src/console/api-keys/contracts.ts +410 -410
  153. package/src/console/api-keys/routes.ts +12 -0
  154. package/src/console/audit/store.ts +2 -3
  155. package/src/console/auth/setup-service.ts +8 -1
  156. package/src/console/backup/routes.ts +7 -0
  157. package/src/console/backup/service.ts +463 -463
  158. package/src/console/backup/store.ts +16 -11
  159. package/src/console/backup/validate.ts +1 -4
  160. package/src/console/cli-tools/fs-ops.ts +18 -2
  161. package/src/console/data-management/contracts.ts +1 -8
  162. package/src/console/data-management/operations.ts +61 -48
  163. package/src/console/data-management/routes.ts +4 -2
  164. package/src/console/domain-registration.ts +25 -3
  165. package/src/console/inferhub/client.ts +139 -0
  166. package/src/console/inferhub/key-store.ts +129 -0
  167. package/src/console/inferhub/normalize.ts +347 -0
  168. package/src/console/inferhub/routes.ts +411 -0
  169. package/src/console/inferhub/store.ts +109 -0
  170. package/src/console/inferhub/types.ts +183 -0
  171. package/src/console/observability/contracts.ts +2 -41
  172. package/src/console/observability/store.ts +176 -371
  173. package/src/console/providers/catalog/public-model-store.ts +0 -0
  174. package/src/console/providers/catalog/store.ts +23 -36
  175. package/src/console/quota/account-quota-shared.ts +52 -52
  176. package/src/console/quota/account-quota-tenant.ts +16 -24
  177. package/src/console/quota/refresh.ts +441 -441
  178. package/src/console/remote-support/service.ts +270 -114
  179. package/src/console/routing/pools/import-parse.ts +206 -206
  180. package/src/console/routing/pools/operations.ts +543 -543
  181. package/src/console/routing/pools/probe-verdict.ts +132 -132
  182. package/src/console/routing/pools/routes.ts +181 -181
  183. package/src/console/routing/pools/store.ts +1 -1
  184. package/src/console/settings/contracts.ts +42 -40
  185. package/src/console/settings/store.ts +25 -25
  186. package/src/console/share/share-stats.ts +22 -4
  187. package/src/console/share/share-usage.ts +30 -4
  188. package/src/console/shared/cursor.ts +15 -0
  189. package/src/console/updates/repository-updates.ts +40 -39
  190. package/src/console/updates/routes.ts +12 -0
  191. package/src/main.ts +13 -5
  192. package/src/network/pool/resolver.ts +1104 -1104
  193. package/src/observability/client-name.ts +22 -0
  194. package/src/observability/event-log.ts +5 -29
  195. package/src/observability/event-query.ts +690 -0
  196. package/src/observability/log-ring.ts +26 -2
  197. package/src/observability/log-sanitize.ts +118 -0
  198. package/src/observability/logger.ts +17 -13
  199. package/src/observability/metrics.ts +35 -0
  200. package/src/observability/payload-capture.ts +114 -41
  201. package/src/observability/payload-store.ts +249 -24
  202. package/src/observability/raw-event-writer.ts +118 -0
  203. package/src/observability/runtime-metrics.ts +71 -1
  204. package/src/observability/telemetry-buffer.ts +100 -17
  205. package/src/observability/telemetry-reconciliation.ts +417 -0
  206. package/src/persistence/boot.ts +20 -9
  207. package/src/persistence/db-pg.ts +13 -0
  208. package/src/persistence/db-sqlite.ts +569 -497
  209. package/src/persistence/dialect.ts +3 -2
  210. package/src/persistence/migrate.ts +181 -181
  211. package/src/persistence/schema.ts +1496 -1406
  212. package/src/persistence/shared-cache.ts +5 -1
  213. package/src/persistence/stores/api-key-store.ts +581 -581
  214. package/src/persistence/stores/telemetry-store.ts +124 -63
  215. package/src/protocol/reasoning-text.ts +30 -0
  216. package/src/protocol/registry.ts +2 -0
  217. package/src/protocol/response/chat.ts +33 -7
  218. package/src/protocol/response/codex.ts +14 -2
  219. package/src/protocol/response/messages.ts +5 -2
  220. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  221. package/src/providers/bundled-discovery-providers.ts +122 -122
  222. package/src/providers/default-registry.ts +14 -0
  223. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  224. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  225. package/src/providers/discovery/probe-phases.ts +43 -7
  226. package/src/providers/discovery/probing-service.ts +70 -32
  227. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  228. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  231. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  232. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  233. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  234. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  235. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  236. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  237. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  238. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  239. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  240. package/src/providers/integrations/claude/claude.ts +3 -4
  241. package/src/providers/integrations/cline/cline.ts +94 -8
  242. package/src/providers/integrations/codex/codex.ts +61 -37
  243. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  244. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  245. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  246. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  247. package/src/providers/integrations/cursor/cursor.ts +29 -17
  248. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  249. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  250. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  251. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  252. package/src/providers/integrations/github/github.ts +3 -1
  253. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  254. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  255. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  256. package/src/providers/integrations/hermes.ts +7 -1
  257. package/src/providers/integrations/inferhub.ts +201 -73
  258. package/src/providers/integrations/kilo/kilo.ts +152 -152
  259. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  260. package/src/providers/integrations/opencode/provider.ts +2 -0
  261. package/src/providers/integrations/openrouter/provider.ts +129 -129
  262. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  263. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  264. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  265. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  266. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  267. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  268. package/src/providers/integrations/trae/trae.ts +22 -24
  269. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  270. package/src/providers/integrations/zcode/oauth.ts +1 -1
  271. package/src/providers/operations/account-health-service.ts +605 -279
  272. package/src/providers/provider-metadata.ts +12 -1
  273. package/src/providers/provider-model-definition.ts +373 -373
  274. package/src/providers/provider-probe.ts +3 -0
  275. package/src/providers/quota/quota-contracts.ts +3 -7
  276. package/src/providers/search/search-provider.ts +2 -6
  277. package/src/security/admission/contracts.ts +10 -0
  278. package/src/security/admission/in-memory-store.ts +89 -14
  279. package/src/security/admission/lua.ts +191 -182
  280. package/src/security/admission/redis-store.ts +30 -0
  281. package/src/security/admission/service.ts +18 -0
  282. package/src/security/api-key-auth-cache.ts +96 -0
  283. package/src/security/api-key-auth.ts +11 -100
  284. package/src/security/client-router-fingerprint.ts +3 -7
  285. package/src/security/ip-boundary.ts +7 -3
  286. package/src/shared/coerce.ts +67 -0
  287. package/src/shared/hash.ts +6 -0
  288. package/src/shared/time.ts +7 -0
  289. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  290. package/src/transport/dispatch/attempt-loop.ts +103 -81
  291. package/src/transport/dispatch/capture-policy.ts +78 -0
  292. package/src/transport/dispatch/proxy-request.ts +4 -2
  293. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  294. package/src/transport/dispatch/streaming/stream.ts +19 -2
  295. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  296. package/src/transport/failure-policy.ts +27 -0
  297. package/src/transport/guards/abuse-guard.ts +2 -2
  298. package/src/transport/guards/auth-guard.ts +17 -0
  299. package/src/transport/guards/body-policy.ts +3 -2
  300. package/src/transport/guards/concurrency-guard.ts +89 -13
  301. package/src/transport/guards/early-rejection-capture.ts +43 -3
  302. package/src/transport/guards/error-lifecycle.ts +10 -2
  303. package/src/transport/guards/guard-errors.ts +23 -0
  304. package/src/transport/guards/headroom-guard.ts +41 -7
  305. package/src/transport/guards/pipeline.ts +24 -0
  306. package/src/transport/guards/request-context.ts +2 -1
  307. package/src/transport/routing/admission.ts +51 -9
  308. package/src/transport/routing/bounded-lru.ts +49 -0
  309. package/src/transport/routing/candidate-index.ts +88 -0
  310. package/src/transport/routing/reservations.ts +121 -7
  311. package/src/transport/routing/round-robin.ts +10 -22
  312. package/src/transport/routing/route-catalog.ts +26 -13
  313. package/src/transport/routing/route-model.ts +22 -2
  314. package/src/transport/routing/router.ts +53 -31
  315. package/src/transport/surface/chat/encode.ts +2 -3
  316. package/src/transport/surface/chat/parse.ts +2 -1
  317. package/src/transport/surface/dialects.ts +11 -3
  318. package/src/transport/surface/messages/encode.ts +2 -1
  319. package/src/transport/surface/messages/parse.ts +3 -1
  320. package/src/transport/surface/messages/stream.ts +2 -6
  321. package/src/workers/maintenance-lease.ts +200 -0
  322. package/src/workers/scheduler.ts +101 -1
  323. package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
  324. package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
  325. package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
  326. package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
  327. package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
  328. package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
  329. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
  330. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
  331. package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
  332. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  333. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
  334. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
  335. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
  336. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
  337. package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
  338. package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
  339. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
  340. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
  341. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
  342. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
  343. package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
  344. package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
  345. package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
  346. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  347. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  348. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  349. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  350. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  351. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  352. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  353. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  354. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  355. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  356. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  357. package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
  358. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
  360. package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
  361. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  363. package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  365. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  367. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  368. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  369. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  370. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  371. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  372. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  373. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  374. package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
  375. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  376. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  377. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  378. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  379. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  380. package/scripts/build/aot.ts +0 -143
  381. package/scripts/build/build.ts +0 -24
  382. package/scripts/build/compile.ts +0 -88
  383. package/scripts/build/restart.ts +0 -120
  384. package/scripts/build/start.ts +0 -25
  385. package/src/observability/event-compaction.ts +0 -317
  386. package/src/observability/event-day-index.ts +0 -440
@@ -1,261 +1,264 @@
1
- import { openSync, statSync } from "node:fs";
2
- import {
3
- clearInstanceLock,
4
- fingerprint,
5
- pidAlive,
6
- readInstanceLock,
7
- writeInstanceLock,
8
- } from "./instance";
9
- import { join } from "node:path";
10
- import { PACKAGE_ROOT, resolvePackagePath } from "../../src/config/package-paths";
11
- import { compiledBinaryPath } from "../build/compile";
12
- import { spawn as spawnDetached, type ChildProcess } from "node:child_process";
13
- import {
14
- blockedPortMessage,
15
- gatewayLogPath,
16
- listenPortErrorMessage,
17
- probeListenPort,
18
- startupFailureMessage,
19
- } from "./port";
20
-
21
- function detachedSubprocess(executable: readonly string[], cwd: string, env: NodeJS.ProcessEnv, logFd: number | null): Bun.Subprocess {
22
- const child: ChildProcess = spawnDetached(executable[0]!, [...executable.slice(1)], {
23
- cwd,
24
- env,
25
- detached: true,
26
- windowsHide: true,
27
- stdio: ["ignore", logFd ?? "ignore", logFd ?? "ignore"],
28
- });
29
- child.unref();
30
- const exited = new Promise<number>((resolve) => {
31
- child.once("exit", (code) => resolve(code ?? 1));
32
- });
33
- return {
34
- pid: child.pid!,
35
- exited,
36
- exitCode: null,
37
- signalCode: null,
38
- kill: () => child.kill(),
39
- unref: () => child.unref(),
40
- } as unknown as Bun.Subprocess;
41
- }
42
-
43
- /**
44
- * Frees the port when what holds it is this install's own gateway.
45
- *
46
- * Returns `null` when the port is free (or was successfully taken), and a
47
- * message to fail with otherwise. It never kills a process it cannot identify:
48
- * a stranger's service on this port is reported, not drained.
49
- *
50
- * The probe is two-step on purpose. `/health` alone is answered by anything, so
51
- * it only says "something is there"; the drain request is what distinguishes
52
- * ours from someone else's, because it is token-gated — and a 401 means another
53
- * Cartethyia install, which this one must not stop.
54
- */
55
- async function claimPort(port: number, drainToken: string): Promise<string | null> {
56
- // A lock naming this install's own gateway is the strongest evidence
57
- // available, so prefer it over the network probe when it is live.
58
- const lock = readInstanceLock();
59
- const ours = lock !== null && lock.port === port && lock.tokenFingerprint === fingerprint(drainToken);
60
-
61
- const response = await fetch(`http://127.0.0.1:${port}/admin/drain`, {
62
- method: "POST",
63
- headers: { "x-drain-token": drainToken },
64
- signal: AbortSignal.timeout(3_000),
65
- }).catch(() => null);
66
-
67
- if (response === null) {
68
- // Nothing answered a drain request. If our own lock names a dead pid, the
69
- // marker is stale and nothing is holding the port — clear it and continue.
70
- if (ours && !pidAlive(lock.pid)) {
71
- clearInstanceLock();
72
- return null;
73
- }
74
- return `Port ${port} is busy and nothing there answers a drain request. Stop whatever holds it, or set a different PORT.`;
75
- }
76
- if (response.status === 401) {
77
- return `Another Cartethyia gateway is already running on port ${port} under a different install. Stop it from its own terminal or tray — this install will not stop a gateway it does not own.`;
78
- }
79
- if (response.status === 403) {
80
- return `Port ${port} refused a drain from loopback. Stop what holds it, then start again.`;
81
- }
82
- if (response.status !== 202) {
83
- return `Port ${port} is busy and refused to drain (HTTP ${response.status}). Stop it manually, then start again.`;
84
- }
85
- // Drain accepted. Wait for the port to actually free: the gateway flushes its
86
- // 202 before closing sockets, so acceptance is not release.
87
- const deadline = Date.now() + 30_000;
88
- while (Date.now() < deadline) {
89
- const stillUp = await fetch(`http://127.0.0.1:${port}/health`, {
90
- signal: AbortSignal.timeout(500),
91
- }).catch(() => null);
92
- if (stillUp === null) {
93
- clearInstanceLock();
94
- return null;
95
- }
96
- await Bun.sleep(200);
97
- }
98
- return `The gateway on port ${port} accepted a drain but did not release the port within 30 seconds. Stop it manually, then start again.`;
99
- }
100
-
101
- /**
102
- * Starts the gateway and resolves once it answers its own health probe.
103
- *
104
- * The child is attached by default. A detached child cannot be killed from an
105
- * `exit` handler on Windows (measured: `kill()` runs, the gateway keeps
106
- * serving), and a terminal's Ctrl-C only reaches children in the same process
107
- * group. Staying attached means Ctrl-C and the `exit` backstop both stop the
108
- * gateway. "Hide to tray" passes `detached: true` because that gateway must
109
- * outlive this process.
110
- */
111
- export async function startGateway(
112
- port: number,
113
- drainToken: string,
114
- options: { readonly detached?: boolean } = {},
115
- ): Promise<Bun.Subprocess> {
116
- // A dead marker from the previous process must not turn its now-reused port
117
- // into an ownership claim. Only identity + drain authorization may stop one.
118
- const lock = readInstanceLock();
119
- if (lock && !pidAlive(lock.pid)) clearInstanceLock();
120
- const probe = await probeListenPort(port);
121
- if (probe.status === "blocked") {
122
- if (probe.code === "EADDRINUSE") {
123
- const claimed = await claimPort(port, drainToken);
124
- if (claimed !== null) {
125
- if (claimed.startsWith("Port") && claimed.includes("nothing there answers")) {
126
- throw new Error(blockedPortMessage(port));
127
- }
128
- throw new Error(claimed);
129
- }
130
- const released = await probeListenPort(port);
131
- if (released.status === "blocked") throw new Error(blockedPortMessage(port));
132
- } else {
133
- throw new Error(listenPortErrorMessage(port, probe));
134
- }
135
- }
136
- // The gateway logs a startup phase line per stage. In menu mode those lines
137
- // would scroll through the same terminal the menu is drawing on, so they go
138
- // to a file instead and the operator sees only the menu.
139
- const configDir = process.env.CARTETHYIA_CONFIG_DIR;
140
- let logFd: number | null = null;
141
- if (configDir) {
142
- try {
143
- logFd = openSync(join(configDir, "gateway.log"), "a");
144
- } catch {
145
- // A read-only config directory costs only the log, not startup.
146
- }
147
- }
148
- const logPath = gatewayLogPath(configDir);
149
- let logOffset = 0;
150
- if (logPath) {
151
- try { logOffset = statSync(logPath).size; } catch { logOffset = 0; }
152
- }
153
- const configuredBinary = process.env.CARTETHYIA_GATEWAY_BINARY?.trim();
154
- const defaultBinary = resolvePackagePath(compiledBinaryPath());
155
- const binary = configuredBinary || defaultBinary;
156
- const executable = await Bun.file(binary).exists()
157
- ? [binary]
158
- : configuredBinary
159
- ? (() => { throw new Error(`Configured gateway binary does not exist: ${configuredBinary}`); })()
160
- : [process.execPath, "run", resolvePackagePath("src", "main.ts")];
161
- const child = options.detached
162
- ? detachedSubprocess(executable, PACKAGE_ROOT, { ...process.env, CARTETHYIA_DRAIN_TOKEN: drainToken }, logFd)
163
- : Bun.spawn(executable, {
164
- cwd: PACKAGE_ROOT,
165
- env: { ...process.env, CARTETHYIA_DRAIN_TOKEN: drainToken },
166
- // "ignore", not "inherit": the gateway does not read stdin, and in menu
167
- // mode the menu is the only stdin consumer. With "inherit" the gateway
168
- // held the fd and swallowed every keypress after the first, so the menu
169
- // rendered but never received Enter.
170
- stdin: "ignore",
171
- stdout: logFd ?? "ignore",
172
- // stderr goes to the same file: "inherit" ties the gateway to the
173
- // terminal, so closing that terminal took the gateway down with it and
174
- // "Hide to tray" left nothing running.
175
- stderr: logFd ?? "ignore",
176
- detached: false,
177
- });
178
- const startupController = new AbortController();
179
- const ready = (async (): Promise<void> => {
180
- const deadline = Date.now() + 30_000;
181
- while (!startupController.signal.aborted && Date.now() < deadline) {
182
- try {
183
- const response = await fetch(`http://127.0.0.1:${port}/health`, {
184
- signal: AbortSignal.timeout(500),
185
- });
186
- if (response.ok) {
187
- const body: unknown = await response.json();
188
- if (body !== null && typeof body === "object" && "status" in body && body.status === "ok") return;
189
- }
190
- } catch {
191
- // The gateway is not accepting health requests yet.
192
- }
193
- await Bun.sleep(250);
194
- }
195
- if (startupController.signal.aborted) throw new Error("Cartethyia exited before becoming ready");
196
- throw new Error("Cartethyia did not open its configured port within 30 seconds");
197
- })();
198
- const exitedEarly = child.exited.then((code) => {
199
- startupController.abort();
200
- return { status: "exit" as const, code };
201
- });
202
- const readyResult = ready.then(
203
- () => ({ status: "ready" as const }),
204
- (error: unknown) => ({ status: "failure" as const, error }),
205
- );
206
- try {
207
- const result = await Promise.race([readyResult, exitedEarly]);
208
- if (result.status === "exit") {
209
- throw new Error(startupFailureMessage(port, result.code, logPath, logOffset));
210
- }
211
- if (result.status === "failure") throw result.error;
212
- // Recorded only once the child is actually serving: a marker written before
213
- // readiness would name a gateway that never came up, and the next start
214
- // would treat a live port as ours when it is not.
215
- writeInstanceLock(port, child.pid, drainToken, executable[0]!);
216
- if (options.detached) child.unref();
217
- return child;
218
- } catch (error) {
219
- child.kill();
220
- await child.exited;
221
- throw error;
222
- }
223
- }
224
-
225
- /** Stops a child gateway, draining in-flight responses before termination. */
226
- export async function stopGateway(
227
- current: Bun.Subprocess,
228
- port: number,
229
- drainToken: string,
230
- ): Promise<void> {
231
- let drainAccepted = false;
232
- try {
233
- const response = await fetch(`http://127.0.0.1:${port}/admin/drain`, {
234
- method: "POST",
235
- headers: { "x-drain-token": drainToken },
236
- signal: AbortSignal.timeout(2_000),
237
- });
238
- drainAccepted = response.status === 202;
239
- } catch {
240
- // The process may already be exiting.
241
- }
242
- if (!drainAccepted) {
243
- console.error("Could not request a graceful gateway drain; terminating the gateway process.");
244
- current.kill();
245
- await current.exited;
246
- return;
247
- }
248
- let timer: Timer | undefined;
249
- const drained = await Promise.race([
250
- current.exited,
251
- new Promise<null>((resolveExit) => {
252
- timer = setTimeout(() => resolveExit(null), 30_000);
253
- }),
254
- ]);
255
- clearTimeout(timer);
256
- if (drained === null) {
257
- console.error("Graceful drain timed out; terminating the gateway process.");
258
- current.kill();
259
- await current.exited;
260
- }
261
- }
1
+ import { openSync, statSync } from "node:fs";
2
+ import {
3
+ clearInstanceLock,
4
+ fingerprint,
5
+ pidAlive,
6
+ readInstanceLock,
7
+ writeInstanceLock,
8
+ } from "./instance";
9
+ import { join } from "node:path";
10
+ import { PACKAGE_ROOT, compiledBinaryPath, resolvePackagePath } from "../../src/config/package-paths";
11
+ import { spawn as spawnDetached, type ChildProcess } from "node:child_process";
12
+ import {
13
+ blockedPortMessage,
14
+ gatewayLogPath,
15
+ listenPortErrorMessage,
16
+ probeListenPort,
17
+ startupFailureMessage,
18
+ } from "./port";
19
+
20
+ /** Resolve a configured gateway without mistaking a Bun launcher for a compiled app. */
21
+ export async function gatewayExecutable(configured: string | undefined): Promise<readonly string[]> {
22
+ const trimmed = configured?.trim();
23
+ const binary = trimmed ? resolvePackagePath(trimmed) : resolvePackagePath(compiledBinaryPath());
24
+ if (await Bun.file(binary).exists()) {
25
+ return binary.endsWith(".ts") ? [process.execPath, "run", binary] : [binary];
26
+ }
27
+ if (trimmed) throw new Error(`Configured gateway binary does not exist: ${trimmed}`);
28
+ return [process.execPath, "run", resolvePackagePath("src", "main.ts")];
29
+ }
30
+
31
+ function detachedSubprocess(executable: readonly string[], cwd: string, env: NodeJS.ProcessEnv, logFd: number | null): Bun.Subprocess {
32
+ const child: ChildProcess = spawnDetached(executable[0]!, [...executable.slice(1)], {
33
+ cwd,
34
+ env,
35
+ detached: true,
36
+ windowsHide: true,
37
+ stdio: ["ignore", logFd ?? "ignore", logFd ?? "ignore"],
38
+ });
39
+ child.unref();
40
+ const exited = new Promise<number>((resolve) => {
41
+ child.once("exit", (code) => resolve(code ?? 1));
42
+ });
43
+ return {
44
+ pid: child.pid!,
45
+ exited,
46
+ exitCode: null,
47
+ signalCode: null,
48
+ kill: () => child.kill(),
49
+ unref: () => child.unref(),
50
+ } as unknown as Bun.Subprocess;
51
+ }
52
+
53
+ /**
54
+ * Frees the port when what holds it is this install's own gateway.
55
+ *
56
+ * Returns `null` when the port is free (or was successfully taken), and a
57
+ * message to fail with otherwise. It never kills a process it cannot identify:
58
+ * a stranger's service on this port is reported, not drained.
59
+ *
60
+ * The probe is two-step on purpose. `/health` alone is answered by anything, so
61
+ * it only says "something is there"; the drain request is what distinguishes
62
+ * ours from someone else's, because it is token-gated — and a 401 means another
63
+ * Cartethyia install, which this one must not stop.
64
+ */
65
+ async function claimPort(port: number, drainToken: string): Promise<string | null> {
66
+ // A lock naming this install's own gateway is the strongest evidence
67
+ // available, so prefer it over the network probe when it is live.
68
+ const lock = readInstanceLock();
69
+ const ours = lock !== null && lock.port === port && lock.tokenFingerprint === fingerprint(drainToken);
70
+
71
+ const response = await fetch(`http://127.0.0.1:${port}/admin/drain`, {
72
+ method: "POST",
73
+ headers: { "x-drain-token": drainToken },
74
+ signal: AbortSignal.timeout(3_000),
75
+ }).catch(() => null);
76
+
77
+ if (response === null) {
78
+ // Nothing answered a drain request. If our own lock names a dead pid, the
79
+ // marker is stale and nothing is holding the port — clear it and continue.
80
+ if (ours && !pidAlive(lock.pid)) {
81
+ clearInstanceLock();
82
+ return null;
83
+ }
84
+ return `Port ${port} is busy and nothing there answers a drain request. Stop whatever holds it, or set a different PORT.`;
85
+ }
86
+ if (response.status === 401) {
87
+ return `Another Cartethyia gateway is already running on port ${port} under a different install. Stop it from its own terminal or tray — this install will not stop a gateway it does not own.`;
88
+ }
89
+ if (response.status === 403) {
90
+ return `Port ${port} refused a drain from loopback. Stop what holds it, then start again.`;
91
+ }
92
+ if (response.status !== 202) {
93
+ return `Port ${port} is busy and refused to drain (HTTP ${response.status}). Stop it manually, then start again.`;
94
+ }
95
+ // Drain accepted. Wait for the port to actually free: the gateway flushes its
96
+ // 202 before closing sockets, so acceptance is not release.
97
+ const deadline = Date.now() + 30_000;
98
+ while (Date.now() < deadline) {
99
+ const stillUp = await fetch(`http://127.0.0.1:${port}/health`, {
100
+ signal: AbortSignal.timeout(500),
101
+ }).catch(() => null);
102
+ if (stillUp === null) {
103
+ clearInstanceLock();
104
+ return null;
105
+ }
106
+ await Bun.sleep(200);
107
+ }
108
+ return `The gateway on port ${port} accepted a drain but did not release the port within 30 seconds. Stop it manually, then start again.`;
109
+ }
110
+
111
+ /**
112
+ * Starts the gateway and resolves once it answers its own health probe.
113
+ *
114
+ * The child is attached by default. A detached child cannot be killed from an
115
+ * `exit` handler on Windows (measured: `kill()` runs, the gateway keeps
116
+ * serving), and a terminal's Ctrl-C only reaches children in the same process
117
+ * group. Staying attached means Ctrl-C and the `exit` backstop both stop the
118
+ * gateway. "Hide to tray" passes `detached: true` because that gateway must
119
+ * outlive this process.
120
+ */
121
+ export async function startGateway(
122
+ port: number,
123
+ drainToken: string,
124
+ options: { readonly detached?: boolean } = {},
125
+ ): Promise<Bun.Subprocess> {
126
+ // A dead marker from the previous process must not turn its now-reused port
127
+ // into an ownership claim. Only identity + drain authorization may stop one.
128
+ const lock = readInstanceLock();
129
+ if (lock && !pidAlive(lock.pid)) clearInstanceLock();
130
+ const probe = await probeListenPort(port);
131
+ if (probe.status === "blocked") {
132
+ if (probe.code === "EADDRINUSE") {
133
+ const claimed = await claimPort(port, drainToken);
134
+ if (claimed !== null) {
135
+ if (claimed.startsWith("Port") && claimed.includes("nothing there answers")) {
136
+ throw new Error(blockedPortMessage(port));
137
+ }
138
+ throw new Error(claimed);
139
+ }
140
+ const released = await probeListenPort(port);
141
+ if (released.status === "blocked") throw new Error(blockedPortMessage(port));
142
+ } else {
143
+ throw new Error(listenPortErrorMessage(port, probe));
144
+ }
145
+ }
146
+ // The gateway logs a startup phase line per stage. In menu mode those lines
147
+ // would scroll through the same terminal the menu is drawing on, so they go
148
+ // to a file instead and the operator sees only the menu.
149
+ const configDir = process.env.CARTETHYIA_CONFIG_DIR;
150
+ let logFd: number | null = null;
151
+ if (configDir) {
152
+ try {
153
+ logFd = openSync(join(configDir, "gateway.log"), "a");
154
+ } catch {
155
+ // A read-only config directory costs only the log, not startup.
156
+ }
157
+ }
158
+ const logPath = gatewayLogPath(configDir);
159
+ let logOffset = 0;
160
+ if (logPath) {
161
+ try { logOffset = statSync(logPath).size; } catch { logOffset = 0; }
162
+ }
163
+ const executable = [...await gatewayExecutable(process.env.CARTETHYIA_GATEWAY_BINARY)];
164
+ const child = options.detached
165
+ ? detachedSubprocess(executable, PACKAGE_ROOT, { ...process.env, CARTETHYIA_DRAIN_TOKEN: drainToken }, logFd)
166
+ : Bun.spawn(executable, {
167
+ cwd: PACKAGE_ROOT,
168
+ env: { ...process.env, CARTETHYIA_DRAIN_TOKEN: drainToken },
169
+ // "ignore", not "inherit": the gateway does not read stdin, and in menu
170
+ // mode the menu is the only stdin consumer. With "inherit" the gateway
171
+ // held the fd and swallowed every keypress after the first, so the menu
172
+ // rendered but never received Enter.
173
+ stdin: "ignore",
174
+ stdout: logFd ?? "ignore",
175
+ // stderr goes to the same file: "inherit" ties the gateway to the
176
+ // terminal, so closing that terminal took the gateway down with it and
177
+ // "Hide to tray" left nothing running.
178
+ stderr: logFd ?? "ignore",
179
+ detached: false,
180
+ });
181
+ const startupController = new AbortController();
182
+ const ready = (async (): Promise<void> => {
183
+ const deadline = Date.now() + 30_000;
184
+ while (!startupController.signal.aborted && Date.now() < deadline) {
185
+ try {
186
+ const response = await fetch(`http://127.0.0.1:${port}/health`, {
187
+ signal: AbortSignal.timeout(500),
188
+ });
189
+ if (response.ok) {
190
+ const body: unknown = await response.json();
191
+ if (body !== null && typeof body === "object" && "status" in body && body.status === "ok") return;
192
+ }
193
+ } catch {
194
+ // The gateway is not accepting health requests yet.
195
+ }
196
+ await Bun.sleep(250);
197
+ }
198
+ if (startupController.signal.aborted) throw new Error("Cartethyia exited before becoming ready");
199
+ throw new Error("Cartethyia did not open its configured port within 30 seconds");
200
+ })();
201
+ const exitedEarly = child.exited.then((code) => {
202
+ startupController.abort();
203
+ return { status: "exit" as const, code };
204
+ });
205
+ const readyResult = ready.then(
206
+ () => ({ status: "ready" as const }),
207
+ (error: unknown) => ({ status: "failure" as const, error }),
208
+ );
209
+ try {
210
+ const result = await Promise.race([readyResult, exitedEarly]);
211
+ if (result.status === "exit") {
212
+ throw new Error(startupFailureMessage(port, result.code, logPath, logOffset));
213
+ }
214
+ if (result.status === "failure") throw result.error;
215
+ // Recorded only once the child is actually serving: a marker written before
216
+ // readiness would name a gateway that never came up, and the next start
217
+ // would treat a live port as ours when it is not.
218
+ writeInstanceLock(port, child.pid, drainToken, executable[0]!);
219
+ if (options.detached) child.unref();
220
+ return child;
221
+ } catch (error) {
222
+ child.kill();
223
+ await child.exited;
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ /** Stops a child gateway, draining in-flight responses before termination. */
229
+ export async function stopGateway(
230
+ current: Bun.Subprocess,
231
+ port: number,
232
+ drainToken: string,
233
+ ): Promise<void> {
234
+ let drainAccepted = false;
235
+ try {
236
+ const response = await fetch(`http://127.0.0.1:${port}/admin/drain`, {
237
+ method: "POST",
238
+ headers: { "x-drain-token": drainToken },
239
+ signal: AbortSignal.timeout(2_000),
240
+ });
241
+ drainAccepted = response.status === 202;
242
+ } catch {
243
+ // The process may already be exiting.
244
+ }
245
+ if (!drainAccepted) {
246
+ console.error("Could not request a graceful gateway drain; terminating the gateway process.");
247
+ current.kill();
248
+ await current.exited;
249
+ return;
250
+ }
251
+ let timer: Timer | undefined;
252
+ const drained = await Promise.race([
253
+ current.exited,
254
+ new Promise<null>((resolveExit) => {
255
+ timer = setTimeout(() => resolveExit(null), 30_000);
256
+ }),
257
+ ]);
258
+ clearTimeout(timer);
259
+ if (drained === null) {
260
+ console.error("Graceful drain timed out; terminating the gateway process.");
261
+ current.kill();
262
+ await current.exited;
263
+ }
264
+ }
@@ -3,10 +3,10 @@ import { constants } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { Client } from "pg";
5
5
  import Redis from "ioredis";
6
- import { hasPlaceholderSecrets, readEnvFile } from "./cli/env";
7
- import { parseServiceUrl, probeTcpService } from "./cli/services";
8
- import { resolveDataDir, resolveDbMode } from "../src/persistence/db-mode";
9
- import { bunVersionAtLeast, readRequiredBunVersion } from "./cli/version";
6
+ import { hasPlaceholderSecrets, readEnvFile } from "./env";
7
+ import { parseServiceUrl, probeTcpService } from "./services";
8
+ import { resolveDataDir, resolveDbMode } from "../../src/persistence/db-mode";
9
+ import { bunVersionAtLeast, readRequiredBunVersion } from "./version";
10
10
 
11
11
  export type CheckPhase = "preflight" | "configuration" | "runtime";
12
12
  export type CheckStatus = "pass" | "warn" | "fail" | "skip";