cartethyia 2.5.2 → 2.5.3

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 (96) hide show
  1. package/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-hfCzi-Ux.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
  3. package/dist/dashboard/assets/{CliToolDetailPage-BO9nKg9w.js → CliToolDetailPage-DLGqYEwI.js} +1 -1
  4. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-DEDyKBlC.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-CfuYiQ8Z.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-bsLuZmYH.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-_UWhwziJ.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
  13. package/dist/dashboard/assets/{HealthEventsModal-qomXThxX.js → HealthEventsModal-Cjfc5WFS.js} +1 -1
  14. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-DDVIfZGW.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
  17. package/dist/dashboard/assets/{OverviewPage-C-ExCy1V.js → OverviewPage-B53YYMng.js} +1 -1
  18. package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
  19. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +3 -0
  20. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
  21. package/dist/dashboard/assets/{ProvidersPage-BS9IEpvv.js → ProvidersPage-Dx31DgIO.js} +1 -1
  22. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
  23. package/dist/dashboard/assets/{ProxyPage-Rn_qXScb.js → ProxyPage-BpnR4kXT.js} +1 -1
  24. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
  25. package/dist/dashboard/assets/{QuotaPage-BihkgQSq.js → QuotaPage-BpOQCnun.js} +1 -1
  26. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
  27. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +1 -0
  28. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
  29. package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-D6pmQ86L.js} +1 -1
  30. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
  31. package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-DWvKiuSl.js} +1 -1
  32. package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
  33. package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-CmkUrgdj.js} +1 -1
  34. package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
  35. package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-B47uOydr.js} +1 -1
  36. package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
  37. package/dist/dashboard/assets/{entry-Dj9AR0gY.js → entry-CkryrIea.js} +3 -3
  38. package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
  39. package/dist/dashboard/assets/{index-DCBtROoK.js → index-BydyKaDz.js} +2 -2
  40. package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
  41. package/dist/dashboard/assets/{select-DsX5QT8K.js → select-BO6h8yNv.js} +1 -1
  42. package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
  43. package/dist/dashboard/index.html +1 -1
  44. package/dist/dashboard/index.html.gz +0 -0
  45. package/dist/dashboard/precompressed-assets.json +21 -21
  46. package/package.json +1 -1
  47. package/scripts/cli/command/console-menu.ts +248 -246
  48. package/scripts/cli/command/tray.ts +208 -204
  49. package/src/config/spec.ts +26 -2
  50. package/src/console/backup/routes.ts +52 -1
  51. package/src/console/backup/service.ts +60 -4
  52. package/src/console/share/share-router.ts +6 -9
  53. package/src/observability/payload-capture.ts +366 -344
  54. package/src/observability/payload-store.ts +12 -2
  55. package/src/observability/telemetry-status.ts +28 -16
  56. package/src/persistence/stores/api-key-store.ts +6 -0
  57. package/src/persistence/stores/share-store.ts +583 -556
  58. package/src/persistence/stores/telemetry-store.ts +1 -1
  59. package/src/protocol/response/gemini.ts +18 -4
  60. package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
  61. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
  62. package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
  63. package/src/providers/integrations/cline/cline-quota.ts +8 -11
  64. package/src/providers/integrations/opencode/provider.ts +532 -532
  65. package/src/providers/integrations/tokenharbor.ts +2 -2
  66. package/src/providers/integrations/trae/trae-catalog.ts +5 -3
  67. package/src/providers/operations/account-health-service.ts +23 -0
  68. package/src/providers/operations/client-versions.ts +9 -2
  69. package/src/providers/provider-metadata.ts +7 -1
  70. package/src/transport/dispatch/attempt-finalize.ts +46 -5
  71. package/src/transport/dispatch/proxy-request.ts +9 -0
  72. package/src/transport/dispatch/streaming/stream.ts +16 -2
  73. package/src/transport/routing/route-catalog.ts +806 -799
  74. package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
  75. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
  76. package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
  77. package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
  78. package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
  79. package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
  80. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
  81. package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
  82. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
  83. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
  84. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
  85. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
  86. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
  87. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
  88. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
  89. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
  90. package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
  91. package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
  92. package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
  93. package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
  94. package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
  95. package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
  96. package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
@@ -1,204 +1,208 @@
1
- import { isNewerRelease } from "../../../src/console/updates/version-compare";
2
- import { resolvePackagePath } from "../../../src/config/package-paths";
3
- import { resolvePort } from "../../../src/config/runtime";
4
- import { ensureDrainToken } from "../gateway/drain-token";
5
- import { startGateway } from "../gateway/spawn";
6
- import { stopGateway } from "../gateway/stop";
7
- import { readInstalledVersion, readLatestPublishedVersion } from "../support/version";
8
- import { reportTrayStartup } from "../support/startup-signal";
9
- import { createTray } from "../tray";
10
- import { openUrl } from "../ui/background";
11
- import { exportBackup, resetConsoleAccount } from "./admin";
12
- import { printUpdateInstructions } from "./update";
13
-
14
- /**
15
- * Runs the gateway with a tray icon. The tray is platform-specific (`tray/`):
16
- * on Windows it is a PowerShell NotifyIcon, because a Node tray library leaves
17
- * an empty console tab open for the tray's whole lifetime, and that window is
18
- * owned by Windows Terminal — not by this process, so it cannot be found and
19
- * hidden from inside.
20
- *
21
- * The gateway it starts is detached, which is what makes the health poll below
22
- * necessary — see {@link TRAY_HEALTH_POLL_MS}.
23
- */
24
-
25
- /**
26
- * How often the tray asks whether the gateway is still answering.
27
- *
28
- * 10 s: often enough that an operator who stops the gateway by hand sees the
29
- * icon go away, and far below the cost of a request. A detached gateway can no
30
- * longer be waited on, so this poll is the only signal left.
31
- */
32
- const TRAY_HEALTH_POLL_MS = 10_000;
33
-
34
- /**
35
- * Consecutive misses to forgive before a dead gateway closes the tray.
36
- *
37
- * One refusal is not a stopped gateway — a probe can lose a race with startup
38
- * or a slow request — and closing the icon on one would make startup feel
39
- * random.
40
- */
41
- const TRAY_HEALTH_MISSES_BEFORE_CLOSE = 3;
42
-
43
- export async function startTray(): Promise<void> {
44
- const port = resolvePort();
45
- const drainToken = ensureDrainToken();
46
- const dashboardUrl = `http://127.0.0.1:${port}/console`;
47
- const repositoryUrl = "https://github.com/risunCode/cartethyia";
48
- const currentVersion = await readInstalledVersion();
49
-
50
- let gateway: Bun.Subprocess | null = null;
51
- let closing = false;
52
- let busy = false;
53
- let resetArmed = false;
54
- let latestVersion: string | null = null;
55
- let closePromise: Promise<void> | undefined;
56
-
57
- const tray = createTray({
58
- iconPath: resolvePackagePath(
59
- "scripts",
60
- "cli",
61
- "tray",
62
- "assets",
63
- process.platform === "win32" ? "cartethyia.ico" : "cartethyia.png",
64
- ),
65
- tooltip: `Cartethyia ${currentVersion}`,
66
- items: [
67
- { kind: "action", id: "update", title: `Cartethyia ${currentVersion} \u00b7 checking\u2026`, enabled: true },
68
- { kind: "separator" },
69
- { kind: "action", id: "console", title: `Open Console \u00b7 ${dashboardUrl}` },
70
- { kind: "action", id: "reset", title: "Reset console password" },
71
- { kind: "action", id: "backup", title: "Backup" },
72
- { kind: "separator" },
73
- { kind: "action", id: "exit", title: "Exit" },
74
- ],
75
- onSelect: (id: string) => void handleSelect(id),
76
- onExit: () => void close(1),
77
- });
78
-
79
- const close = (exitCode = 0): Promise<void> => {
80
- if (closePromise) return closePromise;
81
- closing = true;
82
- closePromise = (async () => {
83
- const current: Bun.Subprocess | null = gateway;
84
- gateway = null;
85
- if (current) await stopGateway(current, port, drainToken);
86
- await tray.close();
87
- process.exitCode = exitCode;
88
- })();
89
- return closePromise;
90
- };
91
-
92
- const refreshVersion = async (): Promise<void> => {
93
- const fetched = await readLatestPublishedVersion();
94
- if (closing) return;
95
- latestVersion = fetched;
96
- if (fetched === null) {
97
- tray.update("update", `Cartethyia ${currentVersion} \u00b7 check for updates`, true);
98
- return;
99
- }
100
- const newer = isNewerRelease(currentVersion, fetched);
101
- tray.update(
102
- "update",
103
- newer
104
- ? `Cartethyia ${currentVersion} \u00b7 update to ${fetched}`
105
- : `Cartethyia ${currentVersion} \u00b7 up to date`,
106
- // Always clickable: the row opens the repository, and only offers the
107
- // update command when there is something newer.
108
- true,
109
- );
110
- };
111
-
112
- const runUpdate = async (): Promise<void> => {
113
- if (busy) return;
114
- busy = true;
115
- const latest = await readLatestPublishedVersion();
116
- await close();
117
- printUpdateInstructions(currentVersion, latest);
118
- process.exit(0);
119
- };
120
-
121
- async function handleSelect(id: string): Promise<void> {
122
- if (closing) return;
123
- if (id === "exit") return void (await close());
124
- if (id === "console") return void (await openUrl(dashboardUrl));
125
- if (id === "backup") {
126
- const result = await exportBackup(port, drainToken);
127
- if (result !== null) {
128
- console.log(
129
- `Backup written to ${result.path} (${(result.bytes / 1024).toFixed(1)} KiB). Telemetry is not included.`,
130
- );
131
- }
132
- return;
133
- }
134
- if (id === "reset") {
135
- // A tray click cannot ask for the typed confirmation the menu uses, so
136
- // the first click arms the action and the second one runs it.
137
- if (!resetArmed) {
138
- resetArmed = true;
139
- tray.update("reset", "Reset console password \u00b7 click again to delete", true);
140
- return;
141
- }
142
- resetArmed = false;
143
- tray.update("reset", "Reset console password", true);
144
- await resetConsoleAccount(port, drainToken);
145
- return;
146
- }
147
- if (id === "update") {
148
- // Nothing to install: the row is the way to the repository instead.
149
- if (latestVersion === null || !isNewerRelease(currentVersion, latestVersion)) {
150
- await openUrl(repositoryUrl);
151
- return;
152
- }
153
- await runUpdate();
154
- }
155
- }
156
-
157
- process.once("SIGINT", () => void close());
158
- process.once("SIGTERM", () => void close());
159
-
160
- try {
161
- await tray.ready();
162
- // Detached, unlike every other caller: the gateway must outlive this
163
- // process. Attached, it dies with the tray — and the tray dies with the
164
- // terminal that launched it, so closing that terminal took the gateway
165
- // down too and "Hide to tray" left nothing serving. `stopGateway` drains
166
- // over HTTP before it ever reaches for `kill()`, so detaching costs the
167
- // exit path nothing: the tray can still stop the gateway on Exit.
168
- gateway = await startGateway(port, drainToken, { detached: true });
169
- // The launcher that spawned this tray is blocked on a signal file: it
170
- // cannot read our stdio, because the tray is detached. Without this write
171
- // it waits out its 60 s deadline and reports a failure for a tray that is
172
- // running fine.
173
- reportTrayStartup({ status: "ready", url: dashboardUrl });
174
- console.log(`Cartethyia is running at ${dashboardUrl}`);
175
- void refreshVersion();
176
-
177
- let misses = 0;
178
- // A detached child's `exited` never settles on Windows, so the old
179
- // `await current.exited` would have parked here forever and the tray would
180
- // never notice the gateway going away. Poll its health endpoint instead:
181
- // that answers for a process we no longer own, which is the whole point of
182
- // detaching it. The probe is the same one startup used, so a gateway that
183
- // is up reads as up regardless of who spawned it.
184
- while (!closing) {
185
- await Bun.sleep(TRAY_HEALTH_POLL_MS);
186
- if (closing || gateway === null) continue;
187
- const alive = await fetch(`http://127.0.0.1:${port}/health`, {
188
- signal: AbortSignal.timeout(2_000),
189
- }).then((response) => response.ok).catch(() => false);
190
- if (alive) {
191
- misses = 0;
192
- continue;
193
- }
194
- misses += 1;
195
- if (misses < TRAY_HEALTH_MISSES_BEFORE_CLOSE) continue;
196
- gateway = null;
197
- console.error("Cartethyia stopped answering; the tray is closing.");
198
- await close(1);
199
- }
200
- } catch (error) {
201
- await close(1);
202
- throw error;
203
- }
204
- }
1
+ import { isNewerRelease } from "../../../src/console/updates/version-compare";
2
+ import { resolvePackagePath } from "../../../src/config/package-paths";
3
+ import { resolvePort } from "../../../src/config/runtime";
4
+ import { ensureDrainToken } from "../gateway/drain-token";
5
+ import { startGateway } from "../gateway/spawn";
6
+ import { stopGateway } from "../gateway/stop";
7
+ import { readInstalledVersion, readLatestPublishedVersion } from "../support/version";
8
+ import { reportTrayStartup } from "../support/startup-signal";
9
+ import { createTray } from "../tray";
10
+ import { openUrl } from "../ui/background";
11
+ import { exportBackup, resetConsoleAccount } from "./admin";
12
+ import { printUpdateInstructions } from "./update";
13
+
14
+ /**
15
+ * Runs the gateway with a tray icon. The tray is platform-specific (`tray/`):
16
+ * on Windows it is a PowerShell NotifyIcon, because a Node tray library leaves
17
+ * an empty console tab open for the tray's whole lifetime, and that window is
18
+ * owned by Windows Terminal — not by this process, so it cannot be found and
19
+ * hidden from inside.
20
+ *
21
+ * The gateway it starts is detached, which is what makes the health poll below
22
+ * necessary — see {@link TRAY_HEALTH_POLL_MS}.
23
+ */
24
+
25
+ /**
26
+ * How often the tray asks whether the gateway is still answering.
27
+ *
28
+ * 10 s: often enough that an operator who stops the gateway by hand sees the
29
+ * icon go away, and far below the cost of a request. A detached gateway can no
30
+ * longer be waited on, so this poll is the only signal left.
31
+ */
32
+ const TRAY_HEALTH_POLL_MS = 10_000;
33
+
34
+ /**
35
+ * Consecutive misses to forgive before a dead gateway closes the tray.
36
+ *
37
+ * One refusal is not a stopped gateway — a probe can lose a race with startup
38
+ * or a slow request — and closing the icon on one would make startup feel
39
+ * random.
40
+ */
41
+ const TRAY_HEALTH_MISSES_BEFORE_CLOSE = 3;
42
+
43
+ export async function startTray(): Promise<void> {
44
+ const port = resolvePort();
45
+ const drainToken = ensureDrainToken();
46
+ const dashboardUrl = `http://127.0.0.1:${port}/console`;
47
+ const repositoryUrl = "https://github.com/risunCode/cartethyia";
48
+ const currentVersion = await readInstalledVersion();
49
+
50
+ let gateway: Bun.Subprocess | null = null;
51
+ let closing = false;
52
+ let busy = false;
53
+ let resetArmed = false;
54
+ let latestVersion: string | null = null;
55
+ let closePromise: Promise<void> | undefined;
56
+
57
+ const tray = createTray({
58
+ iconPath: resolvePackagePath(
59
+ "scripts",
60
+ "cli",
61
+ "tray",
62
+ "assets",
63
+ process.platform === "win32" ? "cartethyia.ico" : "cartethyia.png",
64
+ ),
65
+ tooltip: `Cartethyia ${currentVersion}`,
66
+ items: [
67
+ { kind: "action", id: "update", title: `Cartethyia ${currentVersion} \u00b7 checking\u2026`, enabled: true },
68
+ { kind: "separator" },
69
+ { kind: "action", id: "console", title: `Open Console \u00b7 ${dashboardUrl}` },
70
+ { kind: "action", id: "reset", title: "Reset console password" },
71
+ { kind: "action", id: "backup", title: "Backup" },
72
+ { kind: "separator" },
73
+ { kind: "action", id: "exit", title: "Exit" },
74
+ ],
75
+ onSelect: (id: string) => void handleSelect(id),
76
+ onExit: () => void close(1),
77
+ });
78
+
79
+ const close = (exitCode = 0): Promise<void> => {
80
+ if (closePromise) return closePromise;
81
+ closing = true;
82
+ closePromise = (async () => {
83
+ const current: Bun.Subprocess | null = gateway;
84
+ gateway = null;
85
+ if (current) await stopGateway(current, port, drainToken);
86
+ await tray.close();
87
+ process.exitCode = exitCode;
88
+ })();
89
+ return closePromise;
90
+ };
91
+
92
+ const refreshVersion = async (): Promise<void> => {
93
+ const fetched = await readLatestPublishedVersion();
94
+ if (closing) return;
95
+ latestVersion = fetched;
96
+ if (fetched === null) {
97
+ tray.update("update", `Cartethyia ${currentVersion} \u00b7 check for updates`, true);
98
+ return;
99
+ }
100
+ const newer = isNewerRelease(currentVersion, fetched);
101
+ tray.update(
102
+ "update",
103
+ newer
104
+ ? `Cartethyia ${currentVersion} \u00b7 update to ${fetched}`
105
+ : `Cartethyia ${currentVersion} \u00b7 up to date`,
106
+ // Always clickable: the row opens the repository, and only offers the
107
+ // update command when there is something newer.
108
+ true,
109
+ );
110
+ };
111
+
112
+ const runUpdate = async (): Promise<void> => {
113
+ if (busy) return;
114
+ busy = true;
115
+ const latest = await readLatestPublishedVersion();
116
+ await close();
117
+ printUpdateInstructions(currentVersion, latest);
118
+ // The tray has no terminal of its own, so these instructions are only
119
+ // visible when it was started from one. Hold that terminal open long
120
+ // enough to copy the command, then exit.
121
+ await new Promise<void>((resolve) => setTimeout(resolve, 180_000));
122
+ process.exit(0);
123
+ };
124
+
125
+ async function handleSelect(id: string): Promise<void> {
126
+ if (closing) return;
127
+ if (id === "exit") return void (await close());
128
+ if (id === "console") return void (await openUrl(dashboardUrl));
129
+ if (id === "backup") {
130
+ const result = await exportBackup(port, drainToken);
131
+ if (result !== null) {
132
+ console.log(
133
+ `Backup written to ${result.path} (${(result.bytes / 1024).toFixed(1)} KiB). Telemetry is not included.`,
134
+ );
135
+ }
136
+ return;
137
+ }
138
+ if (id === "reset") {
139
+ // A tray click cannot ask for the typed confirmation the menu uses, so
140
+ // the first click arms the action and the second one runs it.
141
+ if (!resetArmed) {
142
+ resetArmed = true;
143
+ tray.update("reset", "Reset console password \u00b7 click again to delete", true);
144
+ return;
145
+ }
146
+ resetArmed = false;
147
+ tray.update("reset", "Reset console password", true);
148
+ await resetConsoleAccount(port, drainToken);
149
+ return;
150
+ }
151
+ if (id === "update") {
152
+ // Nothing to install: the row is the way to the repository instead.
153
+ if (latestVersion === null || !isNewerRelease(currentVersion, latestVersion)) {
154
+ await openUrl(repositoryUrl);
155
+ return;
156
+ }
157
+ await runUpdate();
158
+ }
159
+ }
160
+
161
+ process.once("SIGINT", () => void close());
162
+ process.once("SIGTERM", () => void close());
163
+
164
+ try {
165
+ await tray.ready();
166
+ // Detached, unlike every other caller: the gateway must outlive this
167
+ // process. Attached, it dies with the tray — and the tray dies with the
168
+ // terminal that launched it, so closing that terminal took the gateway
169
+ // down too and "Hide to tray" left nothing serving. `stopGateway` drains
170
+ // over HTTP before it ever reaches for `kill()`, so detaching costs the
171
+ // exit path nothing: the tray can still stop the gateway on Exit.
172
+ gateway = await startGateway(port, drainToken, { detached: true });
173
+ // The launcher that spawned this tray is blocked on a signal file: it
174
+ // cannot read our stdio, because the tray is detached. Without this write
175
+ // it waits out its 60 s deadline and reports a failure for a tray that is
176
+ // running fine.
177
+ reportTrayStartup({ status: "ready", url: dashboardUrl });
178
+ console.log(`Cartethyia is running at ${dashboardUrl}`);
179
+ void refreshVersion();
180
+
181
+ let misses = 0;
182
+ // A detached child's `exited` never settles on Windows, so the old
183
+ // `await current.exited` would have parked here forever and the tray would
184
+ // never notice the gateway going away. Poll its health endpoint instead:
185
+ // that answers for a process we no longer own, which is the whole point of
186
+ // detaching it. The probe is the same one startup used, so a gateway that
187
+ // is up reads as up regardless of who spawned it.
188
+ while (!closing) {
189
+ await Bun.sleep(TRAY_HEALTH_POLL_MS);
190
+ if (closing || gateway === null) continue;
191
+ const alive = await fetch(`http://127.0.0.1:${port}/health`, {
192
+ signal: AbortSignal.timeout(2_000),
193
+ }).then((response) => response.ok).catch(() => false);
194
+ if (alive) {
195
+ misses = 0;
196
+ continue;
197
+ }
198
+ misses += 1;
199
+ if (misses < TRAY_HEALTH_MISSES_BEFORE_CLOSE) continue;
200
+ gateway = null;
201
+ console.error("Cartethyia stopped answering; the tray is closing.");
202
+ await close(1);
203
+ }
204
+ } catch (error) {
205
+ await close(1);
206
+ throw error;
207
+ }
208
+ }
@@ -157,7 +157,14 @@ export const CONFIG_SPEC = {
157
157
  },
158
158
 
159
159
  // Upstream request timeout + retry backoff
160
- CARTETHYIA_UPSTREAM_TIMEOUT_MS: { kind: "int", default: 120_000, min: 5_000, max: 600_000 },
160
+ //
161
+ // Strictly under 90s: a client whose own deadline is 90s must never be
162
+ // waiting on a gateway that is still holding a request open past it, and a
163
+ // provider that has not answered within 75s is not answering — the request
164
+ // is queued upstream or the egress is stuck, and the honest move is to fail
165
+ // over to another account rather than burn the caller's whole budget on one.
166
+ // 120s outlived the clients that call this, which cap lower.
167
+ CARTETHYIA_UPSTREAM_TIMEOUT_MS: { kind: "int", default: 75_000, min: 5_000, max: 600_000 },
161
168
  CARTETHYIA_STREAM_STALL_TIMEOUT_MS: {
162
169
  kind: "int",
163
170
  default: 360_000,
@@ -181,7 +188,24 @@ export const CONFIG_SPEC = {
181
188
  CARTETHYIA_ACCOUNT_QUOTA_COOLDOWN_MS: { kind: "int", default: 3_600_000, min: 0, max: 86_400_000 },
182
189
  CARTETHYIA_ACCOUNT_MODEL_CAPACITY_COOLDOWN_MS: { kind: "int", default: 120_000, min: 0, max: 86_400_000 },
183
190
  CARTETHYIA_ACCOUNT_TRANSIENT_COOLDOWN_MS: { kind: "int", default: 30_000, min: 0, max: 86_400_000 },
184
- CARTETHYIA_ACCOUNT_UNCLASSIFIED_COOLDOWN_MS: { kind: "int", default: 60_000, min: 0, max: 86_400_000 },
191
+ /**
192
+ * An unmapped upstream failure cools the account down for 15 minutes.
193
+ *
194
+ * It used to be 60s, which put the account straight back into rotation and
195
+ * re-dialled an upstream that had just answered something this gateway does
196
+ * not understand — so the same unknown error was retried every minute, and
197
+ * an account that was actually broken contributed a steady stream of failed
198
+ * attempts instead of one. Fifteen minutes is long enough for a transient
199
+ * provider condition to clear on its own and short enough that no operator
200
+ * action is needed to recover it. It is still a cooldown with a deadline,
201
+ * never `disabled`, so nothing here can strand an account.
202
+ */
203
+ CARTETHYIA_ACCOUNT_UNCLASSIFIED_COOLDOWN_MS: {
204
+ kind: "int",
205
+ default: 900_000,
206
+ min: 0,
207
+ max: 86_400_000,
208
+ },
185
209
  CARTETHYIA_ACCOUNT_COOLDOWN_ESCALATION_MAX_MS: { kind: "int", default: 3_600_000, min: 0, max: 86_400_000 },
186
210
  CARTETHYIA_POOL_COOLDOWN_MS: { kind: "int", default: 120_000, min: 0, max: 86_400_000 },
187
211
  CARTETHYIA_ACCOUNT_AUTH_REJECTED_COOLDOWN_MS: { kind: "int", default: 300_000, min: 0, max: 86_400_000 },
@@ -78,6 +78,24 @@ const BACKUP_SCOPES = ["dashboard:write"] as const;
78
78
  const importBody = t.Object({
79
79
  password: t.String(),
80
80
  backup: t.Unknown(),
81
+ /**
82
+ * Table names to restore. Absent or empty restores every table in the file,
83
+ * which is what the dashboard's checklist defaults to and what the API did
84
+ * before selection existed.
85
+ */
86
+ tables: t.Optional(t.Array(t.String())),
87
+ });
88
+
89
+ /**
90
+ * The inspect body: a file, no password.
91
+ *
92
+ * Inspection writes nothing and reads no secret — it only reports which tables
93
+ * the file carries and which of them the live schema has — so it needs no
94
+ * re-authentication. Requiring the console password here would mean typing it
95
+ * twice for one restore.
96
+ */
97
+ const inspectBody = t.Object({
98
+ backup: t.Unknown(),
81
99
  });
82
100
 
83
101
  const exportQuery = t.Object({
@@ -164,7 +182,12 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
164
182
  // from nowhere else. A request body may not name the tenant it writes
165
183
  // into: that would let any holder of the scope import into, and
166
184
  // overwrite, a tenant they are not.
167
- const result = await config.backupFor(request).restore(body.password, body.backup, access.tenantId);
185
+ const result = await config.backupFor(request).restore(
186
+ body.password,
187
+ body.backup,
188
+ access.tenantId,
189
+ body.tables === undefined ? undefined : { tables: body.tables },
190
+ );
168
191
  // Routing-visible write: the restore has committed, so the cached
169
192
  // snapshot must be rebuilt before the next `/v1/*` dispatch.
170
193
  await config.snapshotInvalidator?.invalidate();
@@ -214,5 +237,33 @@ export function createBackupRoutes(config: BackupRoutesConfig): Elysia {
214
237
  } catch (error) {
215
238
  return errorResponse(error, set, "Delete all failed");
216
239
  }
240
+ })
241
+ .post("/inspect", { body: inspectBody }, async ({ request, body, set }) => {
242
+ try {
243
+ const access = requireAnyScope(config.accessResolver(request), BACKUP_SCOPES);
244
+ if (access.tenantId === null) {
245
+ throw new ConsoleDomainError("tenant_required", 403, "Tenant isolation required");
246
+ }
247
+ // Same byte bound as the import, for the same reason: the file is
248
+ // parsed here too, and an unbounded one would be buffered before any
249
+ // validation could refuse it.
250
+ const declared = Number(request.headers.get("content-length") ?? "0");
251
+ const actualBytes = JSON.stringify(body ?? null).length;
252
+ if (
253
+ (Number.isFinite(declared) && declared > MAX_BACKUP_BYTES) ||
254
+ actualBytes > MAX_BACKUP_BYTES
255
+ ) {
256
+ set.status = 413;
257
+ return {
258
+ error: `backup exceeds the ${MAX_BACKUP_BYTES} byte limit`,
259
+ code: "request_too_large",
260
+ };
261
+ }
262
+ // Reads nothing and writes nothing: the checklist the restore wizard
263
+ // shows, so an operator can pick sections before typing a password.
264
+ return config.backupFor(request).inspect(body.backup);
265
+ } catch (error) {
266
+ return errorResponse(error, set, "Backup inspection failed");
267
+ }
217
268
  }) as unknown as Elysia;
218
269
  }
@@ -19,6 +19,7 @@
19
19
  * re-authentication.
20
20
  */
21
21
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
22
+ import type { Table } from "drizzle-orm";
22
23
  import {
23
24
  models,
24
25
  providerAccounts,
@@ -29,7 +30,12 @@ import {
29
30
  } from "../../persistence/schema";
30
31
  import { ConsoleDomainError } from "../shared/errors";
31
32
  import { tablesForSection, tableName } from "./contracts";
32
- import type { BackupSection, RestoreWarning, ValidatedRestore } from "./contracts";
33
+ import type {
34
+ BackupSection,
35
+ RestoreWarning,
36
+ ValidatedRestore,
37
+ ValidatedTable,
38
+ } from "./contracts";
33
39
  import {
34
40
  applyRestore,
35
41
  decompressBackup,
@@ -90,6 +96,53 @@ export interface ImportResult {
90
96
  readonly format: "native" | "nine_router";
91
97
  }
92
98
 
99
+ /**
100
+ * Which parts of a backup file a restore should actually write.
101
+ *
102
+ * Absent or empty means "everything", which is the historical behavior and
103
+ * what the dashboard's checklist defaults to. A table named here but absent
104
+ * from the file is simply not restored — it is not an error, because the
105
+ * checklist is built from an inspection of *this* file and a selection never
106
+ * invents a table.
107
+ */
108
+ export interface BackupRestoreSelection {
109
+ /** Table names to restore; absent or empty restores every table present. */
110
+ readonly tables?: readonly string[];
111
+ }
112
+
113
+ /**
114
+ * Narrows a validated backup to the tables the operator selected.
115
+ *
116
+ * The tenant row is the one thing that is not independently selectable: it is
117
+ * not a table in either section (`BACKUP_SECTIONS` are config and telemetry),
118
+ * so it never appears on the checklist, and every tenant-scoped table
119
+ * cascades from it. Dropping it because it was not named would make a partial
120
+ * restore fail on a foreign key for a table the operator *did* ask for. So it
121
+ * rides along with any config selection and is written only when one is made.
122
+ *
123
+ * A table named in the selection but missing from the file is a no-op rather
124
+ * than an error: the checklist is built from an inspection of this file, but
125
+ * the selection is validated as names only, and refusing to restore anything
126
+ * over one stale name would be worse than ignoring it.
127
+ */
128
+ function restrictToSelection(
129
+ validation: ValidatedRestore,
130
+ selection: BackupRestoreSelection | undefined,
131
+ ): ValidatedRestore {
132
+ const wanted = selection?.tables;
133
+ if (wanted === undefined || wanted.length === 0) return validation;
134
+ const selected = new Set(wanted);
135
+ const tables = new Map<Table, ValidatedTable>();
136
+ for (const [table, entry] of validation.tables) {
137
+ if (selected.has(tableName(table))) tables.set(table, entry);
138
+ }
139
+ return {
140
+ ...validation,
141
+ tables,
142
+ tenantRows: tables.size > 0 ? validation.tenantRows : [],
143
+ };
144
+ }
145
+
93
146
  function sectionsOrDefault(sections: readonly BackupSection[] | undefined): readonly BackupSection[] {
94
147
  if (sections === undefined || sections.length === 0) return ["config"];
95
148
  for (const section of sections) {
@@ -246,6 +299,7 @@ export class BackupService {
246
299
  password: unknown,
247
300
  payload: unknown,
248
301
  tenantId: string,
302
+ selection?: BackupRestoreSelection,
249
303
  ): Promise<ImportResult> {
250
304
  if (typeof password !== "string" || !(await this.options.verifyPassword(password))) {
251
305
  throw new ConsoleDomainError("unauthorized", 401, "password is incorrect");
@@ -258,11 +312,11 @@ export class BackupService {
258
312
 
259
313
  if (detected.kind === "nine_router") {
260
314
  const { payload: converted, report } = convert9RouterBackup(detected.payload, tenantId);
261
- const result = await this.apply(converted, tenantId);
315
+ const result = await this.apply(converted, tenantId, selection);
262
316
  return { ...result, report, format: "nine_router" };
263
317
  }
264
318
 
265
- const result = await this.apply(detected.payload, tenantId);
319
+ const result = await this.apply(detected.payload, tenantId, selection);
266
320
  return { ...result, format: "native" };
267
321
  }
268
322
 
@@ -348,13 +402,15 @@ export class BackupService {
348
402
  private async apply(
349
403
  payload: unknown,
350
404
  tenantId: string,
405
+ selection?: BackupRestoreSelection,
351
406
  ): Promise<{ restored: Record<string, number>; skipped: Record<string, number>; warnings: readonly RestoreWarning[] }> {
352
407
  const validation = validateRestorePayload(payload, tenantId);
353
408
  if (!validation.ok) {
354
409
  throw new ConsoleDomainError("invalid_request", 400, validation.error);
355
410
  }
356
411
  try {
357
- const compatible = await omitRowsForUnavailableProviders(this.options.db, validation.value, tenantId);
412
+ const scoped = restrictToSelection(validation.value, selection);
413
+ const compatible = await omitRowsForUnavailableProviders(this.options.db, scoped, tenantId);
358
414
  const applied = await applyRestore(this.options.db, compatible, restoreOrder(), tenantId);
359
415
  return { ...applied, warnings: compatible.warnings };
360
416
  } catch (error) {