cartethyia 2.5.4 → 2.5.6

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 (395) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-CADDq4IU.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-DuO1Z7dq.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-BR5HHvbi.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-mUfcknqm.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-V5faV9V2.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-V5faV9V2.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-uPiGVTTK.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-UB690SE2.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-BLujdX7N.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-D5C2raAc.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  60. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  61. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  62. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
  64. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  66. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
  68. package/dist/dashboard/assets/entry-DxBHnauy.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-js6TJvdr.js → index-CmxZr14F.js} +2 -2
  72. package/dist/dashboard/assets/index-CmxZr14F.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
  74. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
  76. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  77. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  78. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
  80. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
  82. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
  86. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  87. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  88. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
  92. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-WUCXorXw.js → select-BYR_5K2c.js} +1 -1
  94. package/dist/dashboard/assets/select-BYR_5K2c.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
  96. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
  98. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
  100. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
  102. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  103. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  104. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  105. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  106. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  109. package/dist/dashboard/index.html +1 -1
  110. package/dist/dashboard/index.html.gz +0 -0
  111. package/dist/dashboard/precompressed-assets.json +54 -54
  112. package/migrations/README.md +52 -52
  113. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  114. package/package.json +96 -95
  115. package/scripts/cli/admin.ts +125 -0
  116. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  117. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
  118. package/scripts/cli/doctor.ts +37 -0
  119. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  120. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  121. package/scripts/cli/instance.ts +224 -0
  122. package/scripts/cli/main.ts +61 -18
  123. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  124. package/scripts/cli/port.ts +100 -0
  125. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  126. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  127. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  128. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  129. package/scripts/cli/tray/index.ts +62 -12
  130. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  131. package/scripts/cli/{command/update.ts → update.ts} +39 -39
  132. package/scripts/cli/version.ts +67 -0
  133. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  134. package/src/boot/app.ts +697 -693
  135. package/src/boot/dependencies.ts +647 -646
  136. package/src/boot/timeout.ts +2 -13
  137. package/src/config/cooldowns.ts +186 -138
  138. package/src/config/network.ts +8 -14
  139. package/src/config/providers.ts +7 -10
  140. package/src/config/runtime.ts +76 -76
  141. package/src/config/security.ts +15 -0
  142. package/src/config/spec.ts +20 -30
  143. package/src/config/telemetry.ts +0 -20
  144. package/src/config/timeouts.ts +0 -6
  145. package/src/console/admin/model-abuse/contracts.ts +28 -6
  146. package/src/console/admin/model-abuse/routes.ts +56 -3
  147. package/src/console/api-keys/contracts.ts +410 -414
  148. package/src/console/api-keys/routes.ts +881 -880
  149. package/src/console/auth/credentials.ts +3 -3
  150. package/src/console/auth/setup-service.ts +2 -2
  151. package/src/console/backup/export-endpoint.ts +6 -2
  152. package/src/console/backup/nine-router.ts +2 -5
  153. package/src/console/backup/service.ts +4 -0
  154. package/src/console/backup/store.ts +192 -29
  155. package/src/console/backup/validate.ts +3 -45
  156. package/src/console/cli-tools/fs-ops.ts +487 -487
  157. package/src/console/console-router-guards.ts +1 -1
  158. package/src/console/data-management/contracts.ts +13 -6
  159. package/src/console/data-management/operations.ts +23 -18
  160. package/src/console/data-management/routes.ts +0 -1
  161. package/src/console/domain-registration.ts +10 -5
  162. package/src/console/observability/live.ts +22 -0
  163. package/src/console/observability/store.ts +39 -16
  164. package/src/console/observability/usage-dimensions.ts +4 -4
  165. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  166. package/src/console/providers/catalog/model-operations.ts +5 -2
  167. package/src/console/providers/catalog/provider-operations.ts +4 -4
  168. package/src/console/providers/catalog/public-model-store.ts +8 -11
  169. package/src/console/providers/catalog/store.ts +1553 -1553
  170. package/src/console/providers/oauth/routes.ts +8 -12
  171. package/src/console/quota/account-quota-tenant.ts +6 -6
  172. package/src/console/quota/quota-view.ts +1 -1
  173. package/src/console/remote-support/contracts.ts +61 -0
  174. package/src/console/remote-support/routes.ts +65 -0
  175. package/src/console/remote-support/service.ts +207 -0
  176. package/src/console/response-compression.ts +0 -3
  177. package/src/console/routing/pools/probe-result.ts +3 -2
  178. package/src/console/routing/pools/probe-verdict.ts +132 -132
  179. package/src/console/routing/pools/store.ts +8 -8
  180. package/src/console/settings/contracts.ts +58 -2
  181. package/src/console/settings/store.ts +30 -3
  182. package/src/console/shared/cursor.ts +8 -8
  183. package/src/console/shared/errors.ts +1 -3
  184. package/src/network/http2-bidi.ts +2 -4
  185. package/src/network/pool/agent.ts +478 -478
  186. package/src/network/pool/selector.ts +5 -4
  187. package/src/network/retry.ts +1 -2
  188. package/src/network/ssrf.ts +5 -4
  189. package/src/observability/event-compaction.ts +80 -78
  190. package/src/observability/event-day-index.ts +13 -15
  191. package/src/observability/event-log.ts +4 -4
  192. package/src/observability/logger.ts +6 -6
  193. package/src/observability/payload-capture.ts +151 -33
  194. package/src/observability/payload-store.ts +397 -440
  195. package/src/observability/redaction.ts +5 -6
  196. package/src/observability/request-notify.ts +43 -0
  197. package/src/observability/telemetry-status.ts +8 -9
  198. package/src/observability/token-speed.ts +5 -4
  199. package/src/persistence/db-mode.ts +7 -7
  200. package/src/persistence/readiness.ts +1 -1
  201. package/src/persistence/schema.ts +1406 -1402
  202. package/src/persistence/shared-cache.ts +1 -2
  203. package/src/persistence/stores/api-key-store.ts +18 -6
  204. package/src/persistence/stores/share-store.ts +4 -3
  205. package/src/persistence/stores/telemetry-store.ts +236 -237
  206. package/src/protocol/canonical-model.ts +11 -3
  207. package/src/protocol/codecs.ts +138 -4
  208. package/src/protocol/gateway-error.ts +6 -4
  209. package/src/protocol/request/chat.ts +21 -1
  210. package/src/protocol/request/codex.ts +10 -1
  211. package/src/protocol/request/messages.ts +4 -5
  212. package/src/protocol/request/responses.ts +9 -1
  213. package/src/protocol/response/chat.ts +34 -4
  214. package/src/protocol/response/codex.ts +7 -4
  215. package/src/protocol/response/dsml.ts +140 -28
  216. package/src/protocol/response/responses.ts +11 -5
  217. package/src/protocol/stream-error-frames.ts +4 -5
  218. package/src/providers/authentication/jwt-validator.ts +9 -10
  219. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  220. package/src/providers/configured-openai-providers.ts +134 -136
  221. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  222. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  223. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  224. package/src/providers/discovery/probe-phases.ts +4 -4
  225. package/src/providers/discovery/probe-wire.ts +6 -13
  226. package/src/providers/discovery/probing-service.ts +1117 -1045
  227. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  228. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  231. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  232. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  233. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  234. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  235. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  236. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  237. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  238. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  239. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  240. package/src/providers/integrations/claude/claude.ts +608 -608
  241. package/src/providers/integrations/cline/cline-quota.ts +132 -132
  242. package/src/providers/integrations/cline/cline.ts +537 -539
  243. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  244. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  245. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  246. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  247. package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
  248. package/src/providers/integrations/cursor/cursor.ts +11 -163
  249. package/src/providers/integrations/factory/factory-models.ts +3 -5
  250. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  251. package/src/providers/integrations/grok/grok.ts +6 -7
  252. package/src/providers/integrations/hermes.ts +14 -18
  253. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  254. package/src/providers/integrations/kiro/kiro.ts +1 -1
  255. package/src/providers/integrations/mistral.ts +6 -6
  256. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  257. package/src/providers/integrations/opencode/provider.ts +8 -8
  258. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  259. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  260. package/src/providers/integrations/qoder/qoder.ts +884 -865
  261. package/src/providers/integrations/shared/connect.ts +6 -6
  262. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  263. package/src/providers/integrations/trae/trae.ts +2 -2
  264. package/src/providers/integrations/zcode/oauth.ts +6 -1
  265. package/src/providers/operations/account-health-service.ts +1528 -1523
  266. package/src/providers/operations/account-reset-service.ts +1 -1
  267. package/src/providers/operations/client-versions.ts +6 -6
  268. package/src/providers/operations/custom-cli-headers.ts +3 -4
  269. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  270. package/src/providers/operations/provider-catalog-service.ts +3 -4
  271. package/src/providers/provider-metadata.ts +4 -8
  272. package/src/providers/provider-registry.ts +6 -7
  273. package/src/providers/provider-usage.ts +1 -1
  274. package/src/security/abuse.ts +15 -10
  275. package/src/security/access-control.ts +144 -146
  276. package/src/security/admission/contracts.ts +11 -0
  277. package/src/security/admission/in-memory-store.ts +4 -3
  278. package/src/security/admission/lease-sweep.ts +6 -6
  279. package/src/security/admission/reasons.ts +5 -1
  280. package/src/security/admission/redis-store.ts +211 -210
  281. package/src/security/admission/service.ts +4 -0
  282. package/src/security/api-key-auth.ts +504 -504
  283. package/src/security/ip-boundary.ts +155 -15
  284. package/src/security/model-abuse.ts +366 -94
  285. package/src/security/outbound-headers.ts +3 -4
  286. package/src/shared/sleep.ts +4 -0
  287. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  288. package/src/transport/dispatch/attempt-loop.ts +531 -530
  289. package/src/transport/dispatch/stream-bridge.ts +1 -3
  290. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  291. package/src/transport/dispatch/streaming/stream.ts +5 -6
  292. package/src/transport/failure-policy.ts +162 -8
  293. package/src/transport/guards/auth-guard.ts +7 -4
  294. package/src/transport/guards/concurrency-guard.ts +0 -5
  295. package/src/transport/guards/error-lifecycle.ts +13 -6
  296. package/src/transport/guards/pipeline.ts +15 -3
  297. package/src/transport/guards/request-context.ts +8 -2
  298. package/src/transport/request/inflight.ts +5 -6
  299. package/src/transport/request/preparer.ts +2 -2
  300. package/src/transport/request/state.ts +12 -8
  301. package/src/transport/routing/admission.ts +4 -0
  302. package/src/transport/routing/route-catalog.ts +8 -1
  303. package/src/transport/routing/route-model.ts +46 -14
  304. package/src/transport/routing/router.ts +21 -14
  305. package/src/transport/surface/completion.ts +2 -3
  306. package/src/transport/surface/dialects.ts +5 -6
  307. package/src/transport/surface/responses/encode.ts +38 -0
  308. package/src/transport/surface/responses/parse.ts +4 -0
  309. package/src/transport/translation/capabilities.ts +2 -3
  310. package/src/transport/translation/quirks.ts +3 -3
  311. package/src/workers/buddy-growth-tasks.ts +5 -10
  312. package/src/workers/daily-checkin.ts +17 -23
  313. package/src/workers/quota-refresh-sweep.ts +26 -26
  314. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  315. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
  316. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  317. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  318. package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
  319. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  320. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  321. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  322. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  323. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  324. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
  325. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  326. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
  327. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  328. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
  329. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  330. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  331. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
  332. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  333. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
  334. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  335. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
  336. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  337. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  338. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  339. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  340. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
  341. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  342. package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
  343. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  344. package/dist/dashboard/assets/app-COlK95oi.css +0 -2
  345. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  346. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  347. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  348. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  349. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  350. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  351. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  352. package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
  353. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  354. package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
  355. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  356. package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
  357. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  358. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
  360. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  361. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  363. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  365. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
  367. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  368. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  369. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  370. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  371. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  372. package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
  373. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  374. package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
  375. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  376. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  377. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  378. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  379. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  380. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  381. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  382. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  383. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  384. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  385. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  386. package/scripts/cli/command/admin.ts +0 -65
  387. package/scripts/cli/command/doctor.ts +0 -257
  388. package/scripts/cli/gateway/drain-token.ts +0 -78
  389. package/scripts/cli/gateway/instance-lock.ts +0 -105
  390. package/scripts/cli/gateway/stop.ts +0 -51
  391. package/scripts/cli/support/bun.ts +0 -27
  392. package/scripts/cli/support/version.ts +0 -37
  393. /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
  394. /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
  395. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,99 +1,99 @@
1
- import { existsSync } from "node:fs";
2
- import { readFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
4
- import { PACKAGE_ROOT } from "../../../src/config/package-paths";
5
-
6
- /**
7
- * Absolute path of the repository root.
8
- *
9
- * Operational scripts and the npm runtime share one package-root resolver so
10
- * assets and repository config do not depend on the caller's working directory.
11
- */
12
- export const PROJECT_ROOT = PACKAGE_ROOT;
13
-
14
- /** Resolve the active `.env`; npm CLI sets a user-owned config directory. */
15
- export function resolveEnvPath(env: NodeJS.ProcessEnv = process.env): string {
16
- const configDir = env.CARTETHYIA_CONFIG_DIR?.trim();
17
- return configDir ? resolve(configDir, ".env") : resolve(PROJECT_ROOT, ".env");
18
- }
19
-
20
- /** Absolute path of the active `.env`. */
21
- export const ENV_PATH = resolveEnvPath();
22
-
23
- /**
24
- * Reads a dotenv-style file without overriding already-exported variables.
25
- */
26
- export async function readEnvFile(path: string): Promise<Record<string, string>> {
27
- if (!existsSync(path)) return {};
28
- const content = await readFile(path, "utf8");
29
- const values: Record<string, string> = {};
30
- for (const line of content.split(/\r?\n/)) {
31
- if (line.startsWith("#")) continue;
32
- const separator = line.indexOf("=");
33
- if (separator <= 0) continue;
34
- const key = line.slice(0, separator).trim();
35
- const value = line.slice(separator + 1).trim();
36
- if (key) values[key] = value;
37
- }
38
- return values;
39
- }
40
-
41
- /**
42
- * Checks if a string contains a configured placeholder value.
43
- */
44
- export function hasPlaceholderSecrets(value: string): boolean {
45
- const normalized = value.toLowerCase();
46
- return (
47
- normalized.includes("replace-with") ||
48
- normalized.includes("replace_me") ||
49
- normalized.includes("your_")
50
- );
51
- }
52
-
53
- /**
54
- * Generates a 256-bit hex cartethyia encryption key.
55
- *
56
- * Hex is preferred over base64 because it never carries padding or `+`/`/`
57
- * characters that confuse some dotenv shells, and it round-trips through
58
- * every adapter that accepts either encoding. The install/setup helpers call
59
- * this when the key is absent or still a placeholder.
60
- */
61
- export function generateCartethyiaEncryptionKey(): string {
62
- const bytes = new Uint8Array(32);
63
- crypto.getRandomValues(bytes);
64
- let hex = "";
65
- for (let i = 0; i < bytes.length; i += 1) hex += bytes[i]!.toString(16).padStart(2, "0");
66
- return hex;
67
- }
68
-
69
- /**
70
- * Returns the mandatory-only `.env` body derived from `.env.example`.
71
- *
72
- * Mandatory means the key is assigned without a leading `#`. Optional entries
73
- * (leading `#`) carry built-in defaults and must not be copied into a fresh
74
- * `.env` — they would override the default with a literal `example` value.
75
- * The generated body therefore contains only mandatory rows, plus a single
76
- * `CARTETHYIA_ENCRYPTION_KEY` replacement when the caller supplies one.
77
- */
78
- export async function mandatoryEnvBody(
79
- examplePath: string,
80
- overrides: Readonly<Record<string, string>> = {},
81
- ): Promise<string> {
82
- const raw = await readFile(examplePath, "utf8");
83
- const lines: string[] = [];
84
- for (const line of raw.split(/\r?\n/)) {
85
- const trimmed = line.trim();
86
- if (trimmed === "" || trimmed.startsWith("#")) continue;
87
- const sep = line.indexOf("=");
88
- if (sep <= 0) continue;
89
- const key = line.slice(0, sep).trim();
90
- if (!key) continue;
91
- const value = (overrides as Record<string, string>)[key] ?? line.slice(sep + 1).trim();
92
- lines.push(`${key}=${value}`);
93
- }
94
- // Preserve overrides even when the example row was commented out (legacy case).
95
- for (const [key, value] of Object.entries(overrides)) {
96
- if (!lines.some((l) => l.startsWith(`${key}=`))) lines.push(`${key}=${value}`);
97
- }
98
- return `${lines.join("\n")}\n`;
99
- }
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { PACKAGE_ROOT } from "../../src/config/package-paths";
5
+
6
+ /**
7
+ * Absolute path of the repository root.
8
+ *
9
+ * Operational scripts and the npm runtime share one package-root resolver so
10
+ * assets and repository config do not depend on the caller's working directory.
11
+ */
12
+ export const PROJECT_ROOT = PACKAGE_ROOT;
13
+
14
+ /** Resolve the active `.env`; npm CLI sets a user-owned config directory. */
15
+ export function resolveEnvPath(env: NodeJS.ProcessEnv = process.env): string {
16
+ const configDir = env.CARTETHYIA_CONFIG_DIR?.trim();
17
+ return configDir ? resolve(configDir, ".env") : resolve(PROJECT_ROOT, ".env");
18
+ }
19
+
20
+ /** Absolute path of the active `.env`. */
21
+ export const ENV_PATH = resolveEnvPath();
22
+
23
+ /**
24
+ * Reads a dotenv-style file without overriding already-exported variables.
25
+ */
26
+ export async function readEnvFile(path: string): Promise<Record<string, string>> {
27
+ if (!existsSync(path)) return {};
28
+ const content = await readFile(path, "utf8");
29
+ const values: Record<string, string> = {};
30
+ for (const line of content.split(/\r?\n/)) {
31
+ if (line.startsWith("#")) continue;
32
+ const separator = line.indexOf("=");
33
+ if (separator <= 0) continue;
34
+ const key = line.slice(0, separator).trim();
35
+ const value = line.slice(separator + 1).trim();
36
+ if (key) values[key] = value;
37
+ }
38
+ return values;
39
+ }
40
+
41
+ /**
42
+ * Checks if a string contains a configured placeholder value.
43
+ */
44
+ export function hasPlaceholderSecrets(value: string): boolean {
45
+ const normalized = value.toLowerCase();
46
+ return (
47
+ normalized.includes("replace-with") ||
48
+ normalized.includes("replace_me") ||
49
+ normalized.includes("your_")
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Generates a 256-bit hex cartethyia encryption key.
55
+ *
56
+ * Hex is preferred over base64 because it never carries padding or `+`/`/`
57
+ * characters that confuse some dotenv shells, and it round-trips through
58
+ * every adapter that accepts either encoding. The install/setup helpers call
59
+ * this when the key is absent or still a placeholder.
60
+ */
61
+ export function generateCartethyiaEncryptionKey(): string {
62
+ const bytes = new Uint8Array(32);
63
+ crypto.getRandomValues(bytes);
64
+ let hex = "";
65
+ for (let i = 0; i < bytes.length; i += 1) hex += bytes[i]!.toString(16).padStart(2, "0");
66
+ return hex;
67
+ }
68
+
69
+ /**
70
+ * Returns the mandatory-only `.env` body derived from `.env.example`.
71
+ *
72
+ * Mandatory means the key is assigned without a leading `#`. Optional entries
73
+ * (leading `#`) carry built-in defaults and must not be copied into a fresh
74
+ * `.env` — they would override the default with a literal `example` value.
75
+ * The generated body therefore contains only mandatory rows, plus a single
76
+ * `CARTETHYIA_ENCRYPTION_KEY` replacement when the caller supplies one.
77
+ */
78
+ export async function mandatoryEnvBody(
79
+ examplePath: string,
80
+ overrides: Readonly<Record<string, string>> = {},
81
+ ): Promise<string> {
82
+ const raw = await readFile(examplePath, "utf8");
83
+ const lines: string[] = [];
84
+ for (const line of raw.split(/\r?\n/)) {
85
+ const trimmed = line.trim();
86
+ if (trimmed === "" || trimmed.startsWith("#")) continue;
87
+ const sep = line.indexOf("=");
88
+ if (sep <= 0) continue;
89
+ const key = line.slice(0, sep).trim();
90
+ if (!key) continue;
91
+ const value = (overrides as Record<string, string>)[key] ?? line.slice(sep + 1).trim();
92
+ lines.push(`${key}=${value}`);
93
+ }
94
+ // Preserve overrides even when the example row was commented out (legacy case).
95
+ for (const [key, value] of Object.entries(overrides)) {
96
+ if (!lines.some((l) => l.startsWith(`${key}=`))) lines.push(`${key}=${value}`);
97
+ }
98
+ return `${lines.join("\n")}\n`;
99
+ }
@@ -1,13 +1,44 @@
1
- import { openSync } from "node:fs";
1
+ import { openSync, statSync } from "node:fs";
2
2
  import {
3
3
  clearInstanceLock,
4
4
  fingerprint,
5
5
  pidAlive,
6
6
  readInstanceLock,
7
7
  writeInstanceLock,
8
- } from "./instance-lock";
8
+ } from "./instance";
9
9
  import { join } from "node:path";
10
- import { PACKAGE_ROOT, resolvePackagePath } from "../../../src/config/package-paths";
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
+ }
11
42
 
12
43
  /**
13
44
  * Frees the port when what holds it is this install's own gateway.
@@ -82,19 +113,25 @@ export async function startGateway(
82
113
  drainToken: string,
83
114
  options: { readonly detached?: boolean } = {},
84
115
  ): Promise<Bun.Subprocess> {
85
- // Starting a second gateway on the same port used to fail with "stopped
86
- // before becoming ready (exit code 1)", which says nothing about the real
87
- // cause. Probe the port first and say what is actually there.
88
- const existing = await fetch(`http://127.0.0.1:${port}/health`, {
89
- signal: AbortSignal.timeout(1_000),
90
- }).catch(() => null);
91
- if (existing !== null) {
92
- // Something already owns the port. Replace it only when it proves to be our
93
- // own gateway — otherwise stop and say what is there. A gateway left behind
94
- // by a closed terminal used to make every later start fail until the
95
- // operator found and killed it by hand.
96
- const claimed = await claimPort(port, drainToken);
97
- if (claimed !== null) throw new Error(claimed);
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
+ }
98
135
  }
99
136
  // The gateway logs a startup phase line per stage. In menu mode those lines
100
137
  // would scroll through the same terminal the menu is drawing on, so they go
@@ -108,9 +145,22 @@ export async function startGateway(
108
145
  // A read-only config directory costs only the log, not startup.
109
146
  }
110
147
  }
111
- const child = Bun.spawn(
112
- [process.execPath, "run", resolvePackagePath("src", "main.ts")],
113
- {
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, {
114
164
  cwd: PACKAGE_ROOT,
115
165
  env: { ...process.env, CARTETHYIA_DRAIN_TOKEN: drainToken },
116
166
  // "ignore", not "inherit": the gateway does not read stdin, and in menu
@@ -123,9 +173,8 @@ export async function startGateway(
123
173
  // terminal, so closing that terminal took the gateway down with it and
124
174
  // "Hide to tray" left nothing running.
125
175
  stderr: logFd ?? "ignore",
126
- detached: options.detached === true,
127
- },
128
- );
176
+ detached: false,
177
+ });
129
178
  const startupController = new AbortController();
130
179
  const ready = (async (): Promise<void> => {
131
180
  const deadline = Date.now() + 30_000;
@@ -157,13 +206,14 @@ export async function startGateway(
157
206
  try {
158
207
  const result = await Promise.race([readyResult, exitedEarly]);
159
208
  if (result.status === "exit") {
160
- throw new Error(`Cartethyia stopped before becoming ready (exit code ${result.code})`);
209
+ throw new Error(startupFailureMessage(port, result.code, logPath, logOffset));
161
210
  }
162
211
  if (result.status === "failure") throw result.error;
163
212
  // Recorded only once the child is actually serving: a marker written before
164
213
  // readiness would name a gateway that never came up, and the next start
165
214
  // would treat a live port as ours when it is not.
166
- writeInstanceLock(port, child.pid, drainToken);
215
+ writeInstanceLock(port, child.pid, drainToken, executable[0]!);
216
+ if (options.detached) child.unref();
167
217
  return child;
168
218
  } catch (error) {
169
219
  child.kill();
@@ -171,3 +221,41 @@ export async function startGateway(
171
221
  throw error;
172
222
  }
173
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
+ }
@@ -0,0 +1,224 @@
1
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const RUNTIME_STATE_FILE = "runtime.json";
5
+
6
+ interface RuntimeState {
7
+ readonly drainToken: string;
8
+ }
9
+
10
+ function statePath(): string | null {
11
+ const configDir = process.env.CARTETHYIA_CONFIG_DIR;
12
+ return configDir ? join(configDir, RUNTIME_STATE_FILE) : null;
13
+ }
14
+
15
+ function readState(): RuntimeState | null {
16
+ const path = statePath();
17
+ if (path === null || !existsSync(path)) return null;
18
+ try {
19
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
20
+ if (parsed !== null && typeof parsed === "object" && "drainToken" in parsed &&
21
+ typeof parsed.drainToken === "string" && parsed.drainToken.length > 0) {
22
+ return { drainToken: parsed.drainToken };
23
+ }
24
+ } catch {
25
+ // Corrupt state reads as no token and is replaced on next start.
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /** Returns the install's persistent drain token, creating it on first use. */
31
+ export function ensureDrainToken(): string {
32
+ const existing = readState();
33
+ if (existing !== null) return existing.drainToken;
34
+ const token = crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, "");
35
+ const path = statePath();
36
+ if (path !== null) {
37
+ try {
38
+ writeFileSync(path, JSON.stringify({ drainToken: token }), { encoding: "utf8", mode: 0o600 });
39
+ } catch {
40
+ // A read-only config directory only loses stop-from-another-process.
41
+ }
42
+ }
43
+ return token;
44
+ }
45
+
46
+ export function readPersistedDrainToken(): string | null {
47
+ return readState()?.drainToken ?? null;
48
+ }
49
+
50
+ /**
51
+ * A marker that one gateway owns this install's port.
52
+ *
53
+ * The port probe alone cannot tell a healthy gateway from a stranger's process:
54
+ * something answering on 12800 is not necessarily ours, so a start that found
55
+ * the port busy either failed outright or — worse — would have to guess before
56
+ * stopping anything. The lock names the PID and the token it was written with,
57
+ * which is what makes "is this our gateway?" answerable without guessing.
58
+ *
59
+ * It is advisory, not an OS lock: a stale file from a crashed process is the
60
+ * normal case this exists to recover from, so presence alone never blocks a
61
+ * start. Liveness is decided by probing the port, and identity by the token.
62
+ */
63
+ export interface InstanceLock {
64
+ readonly pid: number;
65
+ readonly port: number;
66
+ /** Fingerprint of the drain token, never the token itself. */
67
+ readonly tokenFingerprint: string;
68
+ readonly startedAt: string;
69
+ readonly executable: string;
70
+ }
71
+
72
+ const LOCK_FILE = "instance.json";
73
+
74
+ function lockPath(): string | null {
75
+ const configDir = process.env.CARTETHYIA_CONFIG_DIR;
76
+ return configDir ? join(configDir, LOCK_FILE) : null;
77
+ }
78
+
79
+ /** A short, non-reversible fingerprint so the token itself is never written. */
80
+ export function fingerprint(token: string): string {
81
+ let hash = 0;
82
+ for (let i = 0; i < token.length; i += 1) {
83
+ hash = (hash * 31 + token.charCodeAt(i)) | 0;
84
+ }
85
+ return (hash >>> 0).toString(16).padStart(8, "0");
86
+ }
87
+
88
+ export function writeInstanceLock(port: number, pid: number, token: string, executable = ""): void {
89
+ const path = lockPath();
90
+ if (path === null) return;
91
+ const lock: InstanceLock = {
92
+ pid,
93
+ port,
94
+ tokenFingerprint: fingerprint(token),
95
+ startedAt: new Date().toISOString(),
96
+ executable,
97
+ };
98
+ try {
99
+ writeFileSync(path, JSON.stringify(lock), { encoding: "utf8", mode: 0o600 });
100
+ } catch {
101
+ // A read-only config directory costs the marker, not the run.
102
+ }
103
+ }
104
+
105
+ export function readInstanceLock(): InstanceLock | null {
106
+ const path = lockPath();
107
+ if (path === null || !existsSync(path)) return null;
108
+ try {
109
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
110
+ if (
111
+ parsed !== null &&
112
+ typeof parsed === "object" &&
113
+ "pid" in parsed &&
114
+ typeof parsed.pid === "number" &&
115
+ "port" in parsed &&
116
+ typeof parsed.port === "number" &&
117
+ "tokenFingerprint" in parsed &&
118
+ typeof parsed.tokenFingerprint === "string"
119
+ ) {
120
+ const startedAt = "startedAt" in parsed && typeof parsed.startedAt === "string" ? parsed.startedAt : "";
121
+ return {
122
+ pid: parsed.pid,
123
+ port: parsed.port,
124
+ tokenFingerprint: parsed.tokenFingerprint,
125
+ startedAt,
126
+ executable: "executable" in parsed && typeof parsed.executable === "string" ? parsed.executable : "",
127
+ };
128
+ }
129
+ } catch {
130
+ // A corrupt marker reads as none.
131
+ }
132
+ return null;
133
+ }
134
+
135
+ export function clearInstanceLock(): void {
136
+ const path = lockPath();
137
+ if (path === null) return;
138
+ try {
139
+ unlinkSync(path);
140
+ } catch {
141
+ // Already gone, or the directory is read-only.
142
+ }
143
+ }
144
+
145
+ /** Whether a process id is alive. `0` is never a real pid. */
146
+ export function pidAlive(pid: number): boolean {
147
+ if (!Number.isInteger(pid) || pid <= 0) return false;
148
+ try {
149
+ process.kill(pid, 0);
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ }
154
+ }
155
+
156
+ export interface RunningGateway { pid: number; port: number; executable: string; }
157
+
158
+ interface InstanceDependencies {
159
+ alive: (pid: number) => boolean;
160
+ fetch: (input: string, init?: RequestInit) => Promise<Response>;
161
+ kill: (pid: number) => void;
162
+ sleep: (ms: number) => Promise<void>;
163
+ }
164
+ const instanceDependencies: InstanceDependencies = {
165
+ alive: pidAlive,
166
+ fetch: (input, init) => fetch(input, init),
167
+ kill: (pid) => process.kill(pid, "SIGTERM"),
168
+ sleep: Bun.sleep,
169
+ };
170
+
171
+ export async function findRunningGateway(port: number, token: string): Promise<RunningGateway | null> {
172
+ const lock = readInstanceLock();
173
+ if (lock === null || lock.port !== port || lock.tokenFingerprint !== fingerprint(token)) return null;
174
+ if (!instanceDependencies.alive(lock.pid)) {
175
+ clearInstanceLock();
176
+ return null;
177
+ }
178
+ try {
179
+ const response = await instanceDependencies.fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(1_000) });
180
+ if (!response.ok) return null;
181
+ return { pid: lock.pid, port, executable: lock.executable };
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ export async function attachGateway(port: number, token: string): Promise<RunningGateway | null> {
188
+ return findRunningGateway(port, token);
189
+ }
190
+
191
+ export async function stopAttachedGateway(instance: RunningGateway, token: string): Promise<void> {
192
+ const lock = readInstanceLock();
193
+ if (lock === null || lock.pid !== instance.pid || lock.port !== instance.port || lock.tokenFingerprint !== fingerprint(token)) {
194
+ throw new Error("The gateway is not owned by this installation; refusing to stop it.");
195
+ }
196
+ if (!instanceDependencies.alive(instance.pid)) {
197
+ clearInstanceLock();
198
+ return;
199
+ }
200
+ let accepted = false;
201
+ try {
202
+ const response = await instanceDependencies.fetch(`http://127.0.0.1:${instance.port}/admin/drain`, {
203
+ method: "POST",
204
+ headers: { "x-drain-token": token },
205
+ signal: AbortSignal.timeout(2_000),
206
+ });
207
+ accepted = response.status === 202;
208
+ if (!accepted) throw new Error(`Gateway drain was refused (HTTP ${response.status}); process left untouched.`);
209
+ } catch (error) {
210
+ if (error instanceof Error && error.message.includes("process left untouched")) throw error;
211
+ }
212
+ if (!accepted) instanceDependencies.kill(instance.pid);
213
+ const deadline = Date.now() + 30_000;
214
+ while (instanceDependencies.alive(instance.pid) && Date.now() < deadline) await instanceDependencies.sleep(200);
215
+ if (instanceDependencies.alive(instance.pid)) {
216
+ if (accepted) instanceDependencies.kill(instance.pid);
217
+ throw new Error("Gateway did not stop within the drain timeout");
218
+ }
219
+ clearInstanceLock();
220
+ }
221
+
222
+ export const instanceTestInternals = {
223
+ dependencies: instanceDependencies,
224
+ };