cartethyia 2.6.6 → 2.7.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 (189) hide show
  1. package/.env.example +106 -113
  2. package/dist/dashboard/assets/{BannedPage-1v5rzKMl.js → BannedPage-P76QLGJA.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-chMg74ct.js → CliToolDetailPage-DdBggJS9.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-CKG-Vxrt.js → CliToolsPage-aN1iXhhP.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-JYmVKzIx.js → CombosPage-DE-F1nxZ.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CPflWDry.js → ConsoleLogPage-5SpmfLyw.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-B0EYUE47.js → CustomizationPage-3CUxMy2X.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-F1c4lkNG.js → HealthEventsModal-BkmJQKC0.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BYTpzcoU.js → LoginPage-B_0FUKWc.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-DKVslqG3.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  22. package/dist/dashboard/assets/{ProviderDetailPage-CM4MNy45.js → ProviderDetailPage-CEJw6Guo.js} +3 -3
  23. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-Bsoqg2HG.js → ProvidersPage-CEplAR-i.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-8G3doV7j.js → ProxyPage-CAN7hy0f.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  28. package/dist/dashboard/assets/{QuotaPage-CVIQq6JG.js → QuotaPage-BjldQwIU.js} +1 -1
  29. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-BCx07-bj.js → SetupPage-_bCaTM0v.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-MhkB6msu.js → SortableList-fI7JkdIB.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CTqK6Nsj.js → StudioPage-CHVZqskZ.js} +1 -1
  37. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  38. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +56 -0
  39. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-C23S9sgp.js → api-keys-DFY_fZU4.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  42. package/dist/dashboard/assets/app-BOJ1_xUB.css +2 -0
  43. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  44. package/dist/dashboard/assets/{assert-DU6qrMA_.js → assert-CwdnZARt.js} +1 -1
  45. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  46. package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-Dr-uf0Au.js} +1 -1
  47. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  48. package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BddmMdoS.js} +1 -1
  49. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  50. package/dist/dashboard/assets/{card-BOPBuBzD.js → card-CK0PXWlX.js} +1 -1
  51. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  52. package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-CcB6mGh2.js} +1 -1
  53. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  54. package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DGb2P9Yx.js} +1 -1
  55. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  56. package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-LkfuL50u.js} +1 -1
  57. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  58. package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-ZbEZ_84c.js} +1 -1
  59. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  60. package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-BJfsVMvA.js} +1 -1
  61. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-CBHzQ6Qh.js → entry-CWBY5A0f.js} +3 -3
  63. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-CvC1-Zqx.js} +1 -1
  65. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  66. package/dist/dashboard/assets/{github-badge-BuDsrsPn.js → github-badge-BxpznFLt.js} +1 -1
  67. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  68. package/dist/dashboard/assets/{index-Ck4uwuYb.js → index-CDzP3ovJ.js} +2 -2
  69. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  70. package/dist/dashboard/assets/{inline-DToMICwl.js → inline-Ae13MQ5w.js} +1 -1
  71. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  72. package/dist/dashboard/assets/{input-WlyHWeG2.js → input-D23fo7cp.js} +1 -1
  73. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  74. package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CSsxjj5P.js} +1 -1
  75. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  76. package/dist/dashboard/assets/{list-row-B3oSK13z.js → list-row-dmbwZMDY.js} +1 -1
  77. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  78. package/dist/dashboard/assets/{live-ta6GMb-8.js → live-BG1oUsZZ.js} +1 -1
  79. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  80. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
  81. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  82. package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-DSF-e4ho.js} +1 -1
  83. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  84. package/dist/dashboard/assets/{numbers-Dulqgys5.js → numbers-Bm_aECY7.js} +1 -1
  85. package/dist/dashboard/assets/{numbers-Dulqgys5.js.gz → numbers-Bm_aECY7.js.gz} +0 -0
  86. package/dist/dashboard/assets/{query-keys-UETqfytp.js → query-keys-CF56nYIV.js} +1 -1
  87. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  88. package/dist/dashboard/assets/{routing-BPUo-W8d.js → routing-CeA71n7G.js} +1 -1
  89. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  90. package/dist/dashboard/assets/{select-BrrUDUPc.js → select-KIT0rC6G.js} +1 -1
  91. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  92. package/dist/dashboard/assets/settings-B_2gkePf.js +1 -0
  93. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  94. package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-D7WOSzcX.js} +1 -1
  95. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  96. package/dist/dashboard/assets/{state-DhZBfWgx.js → state-BBxtKZOQ.js} +1 -1
  97. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  98. package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-DNS_D5Ea.js} +1 -1
  99. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  100. package/dist/dashboard/assets/{useMutation-EDFRkn1i.js → useMutation-BgoxdoMk.js} +1 -1
  101. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  102. package/dist/dashboard/index.html +1 -1
  103. package/dist/dashboard/index.html.gz +0 -0
  104. package/dist/dashboard/precompressed-assets.json +51 -51
  105. package/package.json +96 -96
  106. package/src/boot/app.ts +714 -722
  107. package/src/boot/dependencies.ts +729 -750
  108. package/src/config/resources.ts +70 -137
  109. package/src/config/security.ts +37 -73
  110. package/src/config/spec.ts +358 -378
  111. package/src/console/console-router.ts +225 -233
  112. package/src/console/domain-registration.ts +381 -391
  113. package/src/console/remote-support/contracts.ts +3 -1
  114. package/src/console/remote-support/service.ts +115 -6
  115. package/src/main.ts +167 -169
  116. package/src/observability/metrics.ts +467 -457
  117. package/src/observability/runtime-metrics.ts +269 -254
  118. package/src/protocol/gateway-error.ts +232 -233
  119. package/src/providers/integrations/commandcode/commandcode.ts +134 -104
  120. package/src/providers/integrations/commandcode/discovery.ts +6 -5
  121. package/src/providers/integrations/qoder/qoder.ts +597 -23
  122. package/src/providers/operations/account-health-service.ts +52 -3
  123. package/src/security/abuse.ts +17 -0
  124. package/src/transport/guards/auth-guard.ts +109 -127
  125. package/src/transport/guards/pipeline.ts +220 -226
  126. package/src/transport/guards/request-context.ts +238 -318
  127. package/src/transport/request/inflight.ts +21 -2
  128. package/src/workers/scheduler.ts +73 -6
  129. package/dist/dashboard/assets/BannedPage-1v5rzKMl.js.gz +0 -0
  130. package/dist/dashboard/assets/CliToolDetailPage-chMg74ct.js.gz +0 -0
  131. package/dist/dashboard/assets/CliToolsPage-CKG-Vxrt.js.gz +0 -0
  132. package/dist/dashboard/assets/CombosPage-JYmVKzIx.js.gz +0 -0
  133. package/dist/dashboard/assets/ConsoleLogPage-CPflWDry.js.gz +0 -0
  134. package/dist/dashboard/assets/CustomizationPage-B0EYUE47.js.gz +0 -0
  135. package/dist/dashboard/assets/HealthEventsModal-F1c4lkNG.js.gz +0 -0
  136. package/dist/dashboard/assets/LoginPage-BYTpzcoU.js.gz +0 -0
  137. package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
  138. package/dist/dashboard/assets/OverviewPage-DIYZRAcb.js +0 -1
  139. package/dist/dashboard/assets/OverviewPage-DIYZRAcb.js.gz +0 -0
  140. package/dist/dashboard/assets/ProviderDetailPage-CM4MNy45.js.gz +0 -0
  141. package/dist/dashboard/assets/ProvidersPage-Bsoqg2HG.js.gz +0 -0
  142. package/dist/dashboard/assets/ProxyPage-8G3doV7j.js.gz +0 -0
  143. package/dist/dashboard/assets/QuotaPage-CVIQq6JG.js.gz +0 -0
  144. package/dist/dashboard/assets/SettingsPage-BgXvRN2Q.js +0 -1
  145. package/dist/dashboard/assets/SettingsPage-BgXvRN2Q.js.gz +0 -0
  146. package/dist/dashboard/assets/SetupPage-BCx07-bj.js.gz +0 -0
  147. package/dist/dashboard/assets/SortableList-MhkB6msu.js.gz +0 -0
  148. package/dist/dashboard/assets/StudioPage-CTqK6Nsj.js.gz +0 -0
  149. package/dist/dashboard/assets/UsagePage-Do65OWbr.js +0 -56
  150. package/dist/dashboard/assets/UsagePage-Do65OWbr.js.gz +0 -0
  151. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  152. package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
  153. package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
  154. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  155. package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
  156. package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
  157. package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
  158. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  159. package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
  160. package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
  161. package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
  162. package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
  163. package/dist/dashboard/assets/entry-CBHzQ6Qh.js.gz +0 -0
  164. package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
  165. package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
  166. package/dist/dashboard/assets/index-Ck4uwuYb.js.gz +0 -0
  167. package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
  168. package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
  169. package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
  170. package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
  171. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  172. package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
  173. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  174. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  175. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  176. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  177. package/dist/dashboard/assets/select-BrrUDUPc.js.gz +0 -0
  178. package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
  179. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  180. package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
  181. package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
  182. package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
  183. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  184. package/src/console/admin/model-abuse/contracts.ts +0 -38
  185. package/src/console/admin/model-abuse/routes.ts +0 -97
  186. package/src/security/model-abuse.ts +0 -704
  187. package/src/transport/guards/concurrency-guard.ts +0 -125
  188. /package/dist/dashboard/assets/{app-MdVfwraB.js → app-CaXZ9SCn.js} +0 -0
  189. /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-CaXZ9SCn.js.gz} +0 -0
@@ -21,7 +21,9 @@ export type RemoteSupportTool = (typeof REMOTE_SUPPORT_TOOLS)[number];
21
21
  export type RemoteSupportBlocker =
22
22
  | "not_installed"
23
23
  | "unsupported_platform"
24
- | "launch_failed";
24
+ | "launch_failed"
25
+ /** The publisher launched but has not published an address yet. */
26
+ | "not_ready";
25
27
 
26
28
  export interface RemoteSupportStatus {
27
29
  /** Which publisher is up, or `null` when neither is. */
@@ -186,9 +186,95 @@ async function waitForUrl(dir: string, tool: RemoteSupportTool): Promise<string
186
186
  * live hostname comes from the daemon's own status. The JSON shape has drifted
187
187
  * across releases (`DNSName` at the root in some, nested under `Self` in
188
188
  * others), so both locations are probed and the first hit wins.
189
+ *
190
+ * `status` is probed as well as `serve status`: the latter answers `{}` until
191
+ * the node has actually joined the tailnet, which is exactly when the operator
192
+ * is staring at a "started" publisher with no link. `status` carries
193
+ * `Self.DNSName` and `BackendState` throughout, so it names the node while
194
+ * serve is still coming up.
189
195
  */
190
196
  function tailscaleUrlFromDaemon(): string | null {
197
+ const name = tailscaleDnsName();
198
+ return name === null ? null : `https://${name}`;
199
+ }
200
+
201
+ /**
202
+ * Why a publisher that started has not published an address.
203
+ *
204
+ * For Tailscale the answer is almost always that the node is not on the
205
+ * tailnet yet — still starting, or never logged in — and both are the
206
+ * operator's move, not the gateway's. Naming which one turns a dead end into
207
+ * something to do.
208
+ */
209
+ function notReadyReason(tool: RemoteSupportTool): string {
210
+ if (tool !== "tailscale") return `${tool} started but has not published an address yet.`;
211
+ const state = spawnSync("tailscale", ["status", "--json"], { encoding: "utf8", windowsHide: true });
212
+ let backend: string | null = null;
213
+ let authUrl: string | null = null;
214
+ try {
215
+ const bag = JSON.parse(state.stdout) as Record<string, unknown>;
216
+ backend = typeof bag["BackendState"] === "string" ? (bag["BackendState"] as string) : null;
217
+ authUrl = typeof bag["AuthURL"] === "string" && (bag["AuthURL"] as string).length > 0 ? (bag["AuthURL"] as string) : null;
218
+ } catch {
219
+ // An unreadable status still leaves the generic message below.
220
+ }
221
+ if (authUrl !== null) return `Tailscale needs to be logged in. Open ${authUrl} once, then start the tunnel again.`;
222
+ if (backend !== null && backend !== "Running") {
223
+ return `Tailscale is not connected yet (state: ${backend}). Wait for it to finish starting, then start the tunnel again.`;
224
+ }
225
+ return "Tailscale has no tailnet address yet. Run `tailscale up` once, then start the tunnel again.";
226
+ }
227
+
228
+ /** This node's MagicDNS name, or `null` when the daemon has none yet. */
229
+ function tailscaleDnsName(): string | null {
230
+ for (const args of [
231
+ ["serve", "status", "--json"],
232
+ ["status", "--json"],
233
+ ] as const) {
234
+ const name = dnsNameFromStatus(args);
235
+ if (name !== null) return name;
236
+ }
237
+ return null;
238
+ }
239
+
240
+ /**
241
+ * Whether `tailscale serve` is actually publishing anything.
242
+ *
243
+ * `serve status --json` is `{}` when nothing is served and gains `Web`
244
+ * (handlers) or `TCP` entries when something is. The node being online says
245
+ * nothing about this: stopping the tunnel leaves the node up and empties the
246
+ * status, which is why an online node alone was read as "still active".
247
+ */
248
+ export function tailscaleServing(): boolean {
191
249
  const result = spawnSync("tailscale", ["serve", "status", "--json"], { encoding: "utf8", windowsHide: true });
250
+ if (result.status !== 0) return false;
251
+ let parsed: unknown;
252
+ try {
253
+ parsed = JSON.parse(result.stdout);
254
+ } catch {
255
+ return false;
256
+ }
257
+ return serveStatusHasHandler(parsed);
258
+ }
259
+
260
+ /**
261
+ * Whether a parsed `serve status` body publishes anything.
262
+ *
263
+ * Split from the spawn so the shape rules are testable without a daemon: `{}`
264
+ * is the empty answer, and any entry under `Web` or `TCP` is a live one.
265
+ */
266
+ export function serveStatusHasHandler(parsed: unknown): boolean {
267
+ const bag: Record<string, unknown> =
268
+ typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
269
+ for (const key of ["Web", "TCP"]) {
270
+ const entries = bag[key];
271
+ if (typeof entries === "object" && entries !== null && Object.keys(entries).length > 0) return true;
272
+ }
273
+ return false;
274
+ }
275
+
276
+ function dnsNameFromStatus(args: readonly string[]): string | null {
277
+ const result = spawnSync("tailscale", [...args], { encoding: "utf8", windowsHide: true });
192
278
  if (result.status !== 0) return null;
193
279
  let parsed: unknown;
194
280
  try {
@@ -198,12 +284,24 @@ function tailscaleUrlFromDaemon(): string | null {
198
284
  }
199
285
  const bag: Record<string, unknown> =
200
286
  typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
287
+ // Only `status` can prove the node is up — `serve status` carries no node
288
+ // state. A stopped node still reports its last DNS name, and publishing that
289
+ // as a live link is worse than publishing nothing: the operator opens an
290
+ // address that cannot resolve.
291
+ if (args[0] === "status") {
292
+ const self = selfOf(bag);
293
+ const online = self === null ? null : self["Online"];
294
+ if (online === false) return null;
295
+ }
201
296
  const direct = typeof bag["DNSName"] === "string" ? (bag["DNSName"] as string) : null;
202
- const self =
203
- typeof bag["Self"] === "object" && bag["Self"] !== null ? (bag["Self"] as Record<string, unknown>) : null;
297
+ const self = selfOf(bag);
204
298
  const nested = self && typeof self["DNSName"] === "string" ? (self["DNSName"] as string) : null;
205
- const hostname = (direct ?? nested)?.trim().replace(/\/+$/, "");
206
- return hostname && hostname.length > 0 ? `https://${hostname}` : null;
299
+ const hostname = (direct ?? nested)?.trim().replace(/\/+$/, "").replace(/\.$/, "");
300
+ return hostname && hostname.length > 0 ? hostname : null;
301
+ }
302
+
303
+ function selfOf(bag: Record<string, unknown>): Record<string, unknown> | null {
304
+ return typeof bag["Self"] === "object" && bag["Self"] !== null ? (bag["Self"] as Record<string, unknown>) : null;
207
305
  }
208
306
 
209
307
  /**
@@ -281,7 +379,11 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
281
379
  // Tailscale runs as a system service — its serve daemon owns its own
282
380
  // lifecycle, so a pid file is not reliable. Ask the daemon.
283
381
  if (tool === "tailscale") {
284
- const url = tailscaleUrlFromDaemon();
382
+ // Both halves are required. A node that is up but serving nothing is
383
+ // not publishing an address, and reporting it as active left the
384
+ // toggle on and a stale link in place after the operator stopped it —
385
+ // `serve status` goes back to `{}` but the node stays `Online`.
386
+ const url = tailscaleServing() ? tailscaleUrlFromDaemon() : null;
285
387
  if (url) return { tool: "tailscale", url };
286
388
  continue;
287
389
  }
@@ -348,7 +450,14 @@ export function createRemoteSupportService(config: RemoteSupportServiceConfig):
348
450
  installed: await detectInstalled(),
349
451
  };
350
452
  }
351
- return await readStatus();
453
+ const status = await readStatus();
454
+ // A publisher that started but published no address is the state that
455
+ // used to look like a silent success: the UI showed the tool as active
456
+ // with no link and no reason. Say what is missing instead.
457
+ if (status.active !== null && status.url === null) {
458
+ return { ...status, blocker: "not_ready", message: notReadyReason(status.active) };
459
+ }
460
+ return status;
352
461
  },
353
462
 
354
463
  async stop(tool) {
package/src/main.ts CHANGED
@@ -1,169 +1,167 @@
1
- // Must run before any module that registers a route: it wires Elysia's TypeBox
2
- // bridge statically, which the source-run path needs under NODE_ENV=production.
3
- import "./boot/elysia-typebox-bridge";
4
- import { createGatewayApp, createGatewayShell } from "./boot/app";
5
- import { bootstrap } from "./boot/lifecycle";
6
- import type { CartethyiaBoot } from "./boot/lifecycle";
7
- import {
8
- resolveDrainToken,
9
- resolveIdleTimeout,
10
- resolveMaxBodyBytes,
11
- resolvePort,
12
- resolveProcessTopology,
13
- } from "./config/runtime";
14
- import { Manifest } from "elysia";
15
- import { log } from "./observability/logger";
16
-
17
- const port = resolvePort();
18
- const entryMemory = process.memoryUsage();
19
- log.info(
20
- `[startup] phase=entrypoint-ready elapsed_ms=${Math.round(process.uptime() * 1_000)} ` +
21
- `rss_mb=${(entryMemory.rss / 1024 / 1024).toFixed(1)} ` +
22
- `heap_used_mb=${(entryMemory.heapUsed / 1024 / 1024).toFixed(1)}`,
23
- );
24
-
25
- declare global {
26
- // eslint-disable-next-line no-var -- globalThis augmentation requires `var`
27
- var __cartethyiaBoot: CartethyiaBoot | undefined;
28
- // eslint-disable-next-line no-var
29
- var __cartethyiaSignalsRegistered: boolean | undefined;
30
- }
31
-
32
- const boot = Manifest.isCapturing()
33
- ? undefined
34
- : (globalThis.__cartethyiaBoot ??= await bootstrap());
35
-
36
- const app = boot
37
- ? createGatewayApp({
38
- mode: "production",
39
- db: boot.deps.db,
40
- proxyPreparer: boot.deps.proxyPreparer,
41
- resolveProviderAdapter: boot.deps.resolveProviderAdapter,
42
- byokUpstreamHosts: boot.deps.byokUpstreamHosts,
43
- networkBindingFactory: boot.deps.networkBindingFactory,
44
- ipAbuseProtection: boot.deps.ipAbuseProtection,
45
- trustedProxyBoundary: boot.deps.trustedProxyBoundary,
46
- poolSelector: boot.deps.poolSelector,
47
- snapshotService: boot.deps.snapshotService,
48
- readiness: boot.deps.readiness,
49
- telemetryBuffer: boot.deps.telemetryBuffer,
50
- resolveOAuthRefresher: boot.deps.resolveOAuthRefresher,
51
- oauthRefreshService: boot.deps.oauthRefreshService,
52
- modelStrikes: boot.deps.modelStrikes,
53
- maxBodyBytes: resolveMaxBodyBytes(),
54
- scheduledTasks: boot.deps.scheduledTasks,
55
- // The sampler is constructed in the dependency graph and already
56
- // sampling on its own interval; without passing it here the app cannot
57
- // bind its in-flight source, so `cartethyia_process_in_flight` stays
58
- // pinned at the constructor's `() => 0` placeholder forever while the
59
- // registry it was meant to read reports real flights.
60
- runtimeMetricsSampler: boot.deps.runtimeMetricsSampler,
61
- shutdownCoordinator: boot.shutdownCoordinator,
62
- // Signal-free graceful stop for platforms where a catchable signal cannot
63
- // be delivered (Windows). Off unless `CARTETHYIA_DRAIN_TOKEN` is set.
64
- ...(resolveDrainToken() !== undefined
65
- ? { drainToken: resolveDrainToken() as string, triggerDrain: () => shutdown("SIGTERM") }
66
- : {}),
67
- // The console mounts in every boot: without REDIS_URL it runs on the
68
- // in-process memory backend like the rest of the gateway.
69
- consoleApi: {
70
- db: boot.deps.db,
71
- accessResolver: () => undefined,
72
- routeSnapshotService: boot.deps.snapshotService,
73
- poolSelector: boot.deps.poolSelector,
74
- telemetryBuffer: boot.deps.telemetryBuffer,
75
- providerRegistry: boot.deps.providerRegistry,
76
- bundledModelCatalog: boot.deps.bundledModelCatalog,
77
- networkBindingFactory: boot.deps.networkBindingFactory,
78
- redis: boot.deps.redis,
79
- oauthRefreshService: boot.deps.oauthRefreshService,
80
- admissionService: boot.deps.admissionService,
81
- modelStrikes: boot.deps.modelStrikes,
82
- readRoutingAccountInflight: boot.deps.readRoutingAccountInflight,
83
- // The same three writers the shutdown coordinator drains, so a manual
84
- // flush from the console does exactly what a graceful stop would do
85
- // for the queues, minus the stop.
86
- flushBuffers: {
87
- flush: async () => {
88
- await boot.deps.telemetryBuffer.flush();
89
- await boot.deps.usageFlush.flush();
90
- await boot.deps.healthEventFlush.flush();
91
- },
92
- },
93
- },
94
- })
95
- : createGatewayShell();
96
- export { app };
97
-
98
- function shutdown(signal: "SIGINT" | "SIGTERM", reason: "SIGINT" | "SIGTERM" | "update" = signal): void {
99
- if (!boot) return;
100
- log.info(`[shutdown] ${signal} received, draining...`);
101
- // Derived from the coordinator's own budget, not a hand-kept literal: a
102
- // force-exit shorter than the drain would hard-kill a process that was about
103
- // to finish gracefully, truncating the in-flight responses the drain exists
104
- // to protect.
105
- const forceExit = setTimeout(() => {
106
- log.error("[shutdown] forced exit after drain budget elapsed");
107
- process.exit(1);
108
- }, boot.shutdownCoordinator.totalShutdownBudgetMs());
109
- forceExit.unref();
110
- boot.shutdownCoordinator
111
- .begin(reason)
112
- .then(() => {
113
- log.info("[shutdown] complete");
114
- process.exit(0);
115
- })
116
- .catch((error: unknown) => {
117
- log.error("[shutdown] failed", error as Error);
118
- process.exit(1);
119
- });
120
- }
121
-
122
- if (boot) {
123
- boot.server = app.listen(
124
- {
125
- port,
126
- // Cap idle sockets so a Slowloris/keep-alive-abuse client cannot
127
- // pin one of the server's `maxRequestBodySize`-budgeted sockets
128
- // indefinitely. 60 s aligns with the DB pool idle timeout so all
129
- // long-idle resources reap together.
130
- idleTimeout: resolveIdleTimeout(),
131
- // SO_REUSEPORT load-balancing, so several processes can share the port
132
- // without a reverse proxy. Tied to the declared topology rather than
133
- // left always-on: with one process declared, enabling it lets an
134
- // unrelated process on the same port silently take half the traffic —
135
- // which is worse than a bind error, because nothing reports it.
136
- reusePort: resolveProcessTopology().multiProcess,
137
- // 8 MiB cap on request bodies keeps a rogue client from allocating
138
- // gigabytes of memory in the ingress path before validation rejects.
139
- maxRequestBodySize: resolveMaxBodyBytes(),
140
- },
141
- () => {
142
- log.info(`Cartethyia listening on :${port} (Bun ${Bun.version})`);
143
- const memory = process.memoryUsage();
144
- log.info(
145
- `[startup] phase=listener-ready rss_mb=${(memory.rss / 1024 / 1024).toFixed(1)} ` +
146
- `heap_used_mb=${(memory.heapUsed / 1024 / 1024).toFixed(1)}`,
147
- );
148
- },
149
- );
150
- // Started here rather than inside the dependency builder: the first tick of
151
- // the lease sweep and the health sweep should not run before the listener
152
- // exists to serve traffic.
153
- boot.deps.scheduledTasks.start();
154
- if (!globalThis.__cartethyiaSignalsRegistered) {
155
- globalThis.__cartethyiaSignalsRegistered = true;
156
- process.on("SIGINT", () => shutdown("SIGINT"));
157
- process.on("SIGTERM", () => shutdown("SIGTERM"));
158
- // An in-place update signals SIGUSR2 before swapping the image so the old
159
- // process drains with the `update` reason and its callers are told the
160
- // replacement is seconds away instead of seeing a generic shutdown. Not
161
- // available on Windows, where the listener is simply never registered —
162
- // production runs in Linux containers, which is where an update happens.
163
- try {
164
- process.on("SIGUSR2", () => shutdown("SIGTERM", "update"));
165
- } catch {
166
- // Platform without SIGUSR2: update drains fall back to the SIGTERM notice.
167
- }
168
- }
169
- }
1
+ // Must run before any module that registers a route: it wires Elysia's TypeBox
2
+ // bridge statically, which the source-run path needs under NODE_ENV=production.
3
+ import "./boot/elysia-typebox-bridge";
4
+ import { createGatewayApp, createGatewayShell } from "./boot/app";
5
+ import { bootstrap } from "./boot/lifecycle";
6
+ import type { CartethyiaBoot } from "./boot/lifecycle";
7
+ import {
8
+ resolveDrainToken,
9
+ resolveIdleTimeout,
10
+ resolveMaxBodyBytes,
11
+ resolvePort,
12
+ resolveProcessTopology,
13
+ } from "./config/runtime";
14
+ import { Manifest } from "elysia";
15
+ import { log } from "./observability/logger";
16
+
17
+ const port = resolvePort();
18
+ const entryMemory = process.memoryUsage();
19
+ log.info(
20
+ `[startup] phase=entrypoint-ready elapsed_ms=${Math.round(process.uptime() * 1_000)} ` +
21
+ `rss_mb=${(entryMemory.rss / 1024 / 1024).toFixed(1)} ` +
22
+ `heap_used_mb=${(entryMemory.heapUsed / 1024 / 1024).toFixed(1)}`,
23
+ );
24
+
25
+ declare global {
26
+ // eslint-disable-next-line no-var -- globalThis augmentation requires `var`
27
+ var __cartethyiaBoot: CartethyiaBoot | undefined;
28
+ // eslint-disable-next-line no-var
29
+ var __cartethyiaSignalsRegistered: boolean | undefined;
30
+ }
31
+
32
+ const boot = Manifest.isCapturing()
33
+ ? undefined
34
+ : (globalThis.__cartethyiaBoot ??= await bootstrap());
35
+
36
+ const app = boot
37
+ ? createGatewayApp({
38
+ mode: "production",
39
+ db: boot.deps.db,
40
+ proxyPreparer: boot.deps.proxyPreparer,
41
+ resolveProviderAdapter: boot.deps.resolveProviderAdapter,
42
+ byokUpstreamHosts: boot.deps.byokUpstreamHosts,
43
+ networkBindingFactory: boot.deps.networkBindingFactory,
44
+ ipAbuseProtection: boot.deps.ipAbuseProtection,
45
+ trustedProxyBoundary: boot.deps.trustedProxyBoundary,
46
+ poolSelector: boot.deps.poolSelector,
47
+ snapshotService: boot.deps.snapshotService,
48
+ readiness: boot.deps.readiness,
49
+ telemetryBuffer: boot.deps.telemetryBuffer,
50
+ resolveOAuthRefresher: boot.deps.resolveOAuthRefresher,
51
+ oauthRefreshService: boot.deps.oauthRefreshService,
52
+ maxBodyBytes: resolveMaxBodyBytes(),
53
+ scheduledTasks: boot.deps.scheduledTasks,
54
+ // The sampler is constructed in the dependency graph and already
55
+ // sampling on its own interval; without passing it here the app cannot
56
+ // bind its in-flight source, so `cartethyia_process_in_flight` stays
57
+ // pinned at the constructor's `() => 0` placeholder forever while the
58
+ // registry it was meant to read reports real flights.
59
+ runtimeMetricsSampler: boot.deps.runtimeMetricsSampler,
60
+ shutdownCoordinator: boot.shutdownCoordinator,
61
+ // Signal-free graceful stop for platforms where a catchable signal cannot
62
+ // be delivered (Windows). Off unless `CARTETHYIA_DRAIN_TOKEN` is set.
63
+ ...(resolveDrainToken() !== undefined
64
+ ? { drainToken: resolveDrainToken() as string, triggerDrain: () => shutdown("SIGTERM") }
65
+ : {}),
66
+ // The console mounts in every boot: without REDIS_URL it runs on the
67
+ // in-process memory backend like the rest of the gateway.
68
+ consoleApi: {
69
+ db: boot.deps.db,
70
+ accessResolver: () => undefined,
71
+ routeSnapshotService: boot.deps.snapshotService,
72
+ poolSelector: boot.deps.poolSelector,
73
+ telemetryBuffer: boot.deps.telemetryBuffer,
74
+ providerRegistry: boot.deps.providerRegistry,
75
+ bundledModelCatalog: boot.deps.bundledModelCatalog,
76
+ networkBindingFactory: boot.deps.networkBindingFactory,
77
+ redis: boot.deps.redis,
78
+ oauthRefreshService: boot.deps.oauthRefreshService,
79
+ admissionService: boot.deps.admissionService,
80
+ readRoutingAccountInflight: boot.deps.readRoutingAccountInflight,
81
+ // The same three writers the shutdown coordinator drains, so a manual
82
+ // flush from the console does exactly what a graceful stop would do
83
+ // for the queues, minus the stop.
84
+ flushBuffers: {
85
+ flush: async () => {
86
+ await boot.deps.telemetryBuffer.flush();
87
+ await boot.deps.usageFlush.flush();
88
+ await boot.deps.healthEventFlush.flush();
89
+ },
90
+ },
91
+ },
92
+ })
93
+ : createGatewayShell();
94
+ export { app };
95
+
96
+ function shutdown(signal: "SIGINT" | "SIGTERM", reason: "SIGINT" | "SIGTERM" | "update" = signal): void {
97
+ if (!boot) return;
98
+ log.info(`[shutdown] ${signal} received, draining...`);
99
+ // Derived from the coordinator's own budget, not a hand-kept literal: a
100
+ // force-exit shorter than the drain would hard-kill a process that was about
101
+ // to finish gracefully, truncating the in-flight responses the drain exists
102
+ // to protect.
103
+ const forceExit = setTimeout(() => {
104
+ log.error("[shutdown] forced exit after drain budget elapsed");
105
+ process.exit(1);
106
+ }, boot.shutdownCoordinator.totalShutdownBudgetMs());
107
+ forceExit.unref();
108
+ boot.shutdownCoordinator
109
+ .begin(reason)
110
+ .then(() => {
111
+ log.info("[shutdown] complete");
112
+ process.exit(0);
113
+ })
114
+ .catch((error: unknown) => {
115
+ log.error("[shutdown] failed", error as Error);
116
+ process.exit(1);
117
+ });
118
+ }
119
+
120
+ if (boot) {
121
+ boot.server = app.listen(
122
+ {
123
+ port,
124
+ // Cap idle sockets so a Slowloris/keep-alive-abuse client cannot
125
+ // pin one of the server's `maxRequestBodySize`-budgeted sockets
126
+ // indefinitely. 60 s aligns with the DB pool idle timeout so all
127
+ // long-idle resources reap together.
128
+ idleTimeout: resolveIdleTimeout(),
129
+ // SO_REUSEPORT load-balancing, so several processes can share the port
130
+ // without a reverse proxy. Tied to the declared topology rather than
131
+ // left always-on: with one process declared, enabling it lets an
132
+ // unrelated process on the same port silently take half the traffic —
133
+ // which is worse than a bind error, because nothing reports it.
134
+ reusePort: resolveProcessTopology().multiProcess,
135
+ // 8 MiB cap on request bodies keeps a rogue client from allocating
136
+ // gigabytes of memory in the ingress path before validation rejects.
137
+ maxRequestBodySize: resolveMaxBodyBytes(),
138
+ },
139
+ () => {
140
+ log.info(`Cartethyia listening on :${port} (Bun ${Bun.version})`);
141
+ const memory = process.memoryUsage();
142
+ log.info(
143
+ `[startup] phase=listener-ready rss_mb=${(memory.rss / 1024 / 1024).toFixed(1)} ` +
144
+ `heap_used_mb=${(memory.heapUsed / 1024 / 1024).toFixed(1)}`,
145
+ );
146
+ },
147
+ );
148
+ // Started here rather than inside the dependency builder: the first tick of
149
+ // the lease sweep and the health sweep should not run before the listener
150
+ // exists to serve traffic.
151
+ boot.deps.scheduledTasks.start();
152
+ if (!globalThis.__cartethyiaSignalsRegistered) {
153
+ globalThis.__cartethyiaSignalsRegistered = true;
154
+ process.on("SIGINT", () => shutdown("SIGINT"));
155
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
156
+ // An in-place update signals SIGUSR2 before swapping the image so the old
157
+ // process drains with the `update` reason and its callers are told the
158
+ // replacement is seconds away instead of seeing a generic shutdown. Not
159
+ // available on Windows, where the listener is simply never registered —
160
+ // production runs in Linux containers, which is where an update happens.
161
+ try {
162
+ process.on("SIGUSR2", () => shutdown("SIGTERM", "update"));
163
+ } catch {
164
+ // Platform without SIGUSR2: update drains fall back to the SIGTERM notice.
165
+ }
166
+ }
167
+ }