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.
- package/.env.example +106 -113
- package/dist/dashboard/assets/{BannedPage-1v5rzKMl.js → BannedPage-P76QLGJA.js} +1 -1
- package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-chMg74ct.js → CliToolDetailPage-DdBggJS9.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-CKG-Vxrt.js → CliToolsPage-aN1iXhhP.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-JYmVKzIx.js → CombosPage-DE-F1nxZ.js} +1 -1
- package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CPflWDry.js → ConsoleLogPage-5SpmfLyw.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-B0EYUE47.js → CustomizationPage-3CUxMy2X.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-F1c4lkNG.js → HealthEventsModal-BkmJQKC0.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-BYTpzcoU.js → LoginPage-B_0FUKWc.js} +1 -1
- package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-DKVslqG3.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +1 -0
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
- package/dist/dashboard/assets/{ProviderDetailPage-CM4MNy45.js → ProviderDetailPage-CEJw6Guo.js} +3 -3
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-Bsoqg2HG.js → ProvidersPage-CEplAR-i.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-8G3doV7j.js → ProxyPage-CAN7hy0f.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-CVIQq6JG.js → QuotaPage-BjldQwIU.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js +1 -0
- package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-BCx07-bj.js → SetupPage-_bCaTM0v.js} +1 -1
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-MhkB6msu.js → SortableList-fI7JkdIB.js} +1 -1
- package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CTqK6Nsj.js → StudioPage-CHVZqskZ.js} +1 -1
- package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js +56 -0
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-C23S9sgp.js → api-keys-DFY_fZU4.js} +1 -1
- package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
- package/dist/dashboard/assets/app-BOJ1_xUB.css +2 -0
- package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
- package/dist/dashboard/assets/{assert-DU6qrMA_.js → assert-CwdnZARt.js} +1 -1
- package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-Dr-uf0Au.js} +1 -1
- package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
- package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BddmMdoS.js} +1 -1
- package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
- package/dist/dashboard/assets/{card-BOPBuBzD.js → card-CK0PXWlX.js} +1 -1
- package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-CcB6mGh2.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DGb2P9Yx.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-LkfuL50u.js} +1 -1
- package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-ZbEZ_84c.js} +1 -1
- package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-BJfsVMvA.js} +1 -1
- package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CBHzQ6Qh.js → entry-CWBY5A0f.js} +3 -3
- package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-CvC1-Zqx.js} +1 -1
- package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BuDsrsPn.js → github-badge-BxpznFLt.js} +1 -1
- package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
- package/dist/dashboard/assets/{index-Ck4uwuYb.js → index-CDzP3ovJ.js} +2 -2
- package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DToMICwl.js → inline-Ae13MQ5w.js} +1 -1
- package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
- package/dist/dashboard/assets/{input-WlyHWeG2.js → input-D23fo7cp.js} +1 -1
- package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
- package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CSsxjj5P.js} +1 -1
- package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-B3oSK13z.js → list-row-dmbwZMDY.js} +1 -1
- package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
- package/dist/dashboard/assets/{live-ta6GMb-8.js → live-BG1oUsZZ.js} +1 -1
- package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-DSF-e4ho.js} +1 -1
- package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-Dulqgys5.js → numbers-Bm_aECY7.js} +1 -1
- package/dist/dashboard/assets/{numbers-Dulqgys5.js.gz → numbers-Bm_aECY7.js.gz} +0 -0
- package/dist/dashboard/assets/{query-keys-UETqfytp.js → query-keys-CF56nYIV.js} +1 -1
- package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
- package/dist/dashboard/assets/{routing-BPUo-W8d.js → routing-CeA71n7G.js} +1 -1
- package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
- package/dist/dashboard/assets/{select-BrrUDUPc.js → select-KIT0rC6G.js} +1 -1
- package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
- package/dist/dashboard/assets/settings-B_2gkePf.js +1 -0
- package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
- package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-D7WOSzcX.js} +1 -1
- package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
- package/dist/dashboard/assets/{state-DhZBfWgx.js → state-BBxtKZOQ.js} +1 -1
- package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
- package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-DNS_D5Ea.js} +1 -1
- package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-EDFRkn1i.js → useMutation-BgoxdoMk.js} +1 -1
- package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -51
- package/package.json +96 -96
- package/src/boot/app.ts +714 -722
- package/src/boot/dependencies.ts +729 -750
- package/src/config/resources.ts +70 -137
- package/src/config/security.ts +37 -73
- package/src/config/spec.ts +358 -378
- package/src/console/console-router.ts +225 -233
- package/src/console/domain-registration.ts +381 -391
- package/src/console/remote-support/contracts.ts +3 -1
- package/src/console/remote-support/service.ts +115 -6
- package/src/main.ts +167 -169
- package/src/observability/metrics.ts +467 -457
- package/src/observability/runtime-metrics.ts +269 -254
- package/src/protocol/gateway-error.ts +232 -233
- package/src/providers/integrations/commandcode/commandcode.ts +134 -104
- package/src/providers/integrations/commandcode/discovery.ts +6 -5
- package/src/providers/integrations/qoder/qoder.ts +597 -23
- package/src/providers/operations/account-health-service.ts +52 -3
- package/src/security/abuse.ts +17 -0
- package/src/transport/guards/auth-guard.ts +109 -127
- package/src/transport/guards/pipeline.ts +220 -226
- package/src/transport/guards/request-context.ts +238 -318
- package/src/transport/request/inflight.ts +21 -2
- package/src/workers/scheduler.ts +73 -6
- package/dist/dashboard/assets/BannedPage-1v5rzKMl.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-chMg74ct.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-CKG-Vxrt.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-JYmVKzIx.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CPflWDry.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-B0EYUE47.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-F1c4lkNG.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BYTpzcoU.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DIYZRAcb.js +0 -1
- package/dist/dashboard/assets/OverviewPage-DIYZRAcb.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CM4MNy45.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Bsoqg2HG.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-8G3doV7j.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-CVIQq6JG.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-BgXvRN2Q.js +0 -1
- package/dist/dashboard/assets/SettingsPage-BgXvRN2Q.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BCx07-bj.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-MhkB6msu.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CTqK6Nsj.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Do65OWbr.js +0 -56
- package/dist/dashboard/assets/UsagePage-Do65OWbr.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
- package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
- package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
- package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.js.gz +0 -0
- package/dist/dashboard/assets/dialog-Iec_ywbD.js.gz +0 -0
- package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
- package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
- package/dist/dashboard/assets/entry-CBHzQ6Qh.js.gz +0 -0
- package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
- package/dist/dashboard/assets/index-Ck4uwuYb.js.gz +0 -0
- package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
- package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
- package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
- package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/select-BrrUDUPc.js.gz +0 -0
- package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
- package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
- package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
- package/src/console/admin/model-abuse/contracts.ts +0 -38
- package/src/console/admin/model-abuse/routes.ts +0 -97
- package/src/security/model-abuse.ts +0 -704
- package/src/transport/guards/concurrency-guard.ts +0 -125
- /package/dist/dashboard/assets/{app-MdVfwraB.js → app-CaXZ9SCn.js} +0 -0
- /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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
await boot.deps.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
log.info(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
process
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
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
|
+
}
|