cartethyia 2.5.2 → 2.5.4
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/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-C2orBsI2.js} +1 -1
- package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-BV6pxVQo.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-Bsan4679.js} +1 -1
- package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-CW-wPmJ6.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-0p8uX7vB.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-BvzCx8Kr.js} +1 -1
- package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-CWkTUtpX.js → ModelPicker-BY1aKEkP.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +7 -0
- package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +1 -0
- package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +1 -0
- package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-DDbMQsoo.js} +1 -1
- package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-CB3Z7VTf.js} +1 -1
- package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-D95UCPHI.js} +3 -3
- package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-Cnae9SWA.js} +13 -13
- package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-uNywYlfv.js → api-keys-VY68Mhd1.js} +1 -1
- package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
- package/dist/dashboard/assets/app-COlK95oi.css +2 -0
- package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
- package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-Chi7pCnt.js} +1 -1
- package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
- package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-D4pQapKj.js} +1 -1
- package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
- package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-DH6cg-_B.js} +1 -1
- package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
- package/dist/dashboard/assets/{card--AtFedAq.js → card-BKqlUOe4.js} +1 -1
- package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-fTgljTGA.js} +1 -1
- package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-D5Ys_sM0.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
- package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
- package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-BtpsJU98.js → dialog-BYj-8WuK.js} +1 -1
- package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-WwwtzihF.js → drawer-CmXIZ6H1.js} +1 -1
- package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
- package/dist/dashboard/assets/entry-CBg5zeu8.js +1 -0
- package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
- package/dist/dashboard/assets/entry-CQBr6U9c.js +1 -0
- package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
- package/dist/dashboard/assets/entry-CgdSAzEV.js +4 -0
- package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-BlptL8Uf.js} +1 -1
- package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
- package/dist/dashboard/assets/{index-DCBtROoK.js → index-js6TJvdr.js} +2 -2
- package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
- package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-DUWDSmDV.js} +1 -1
- package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
- package/dist/dashboard/assets/{input-BG41et9G.js → input-LVjgZq4f.js} +1 -1
- package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
- package/dist/dashboard/assets/{layout-C8mer_Ak.js → layout-CPfxpsfy.js} +1 -1
- package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-DupvLfDx.js} +1 -1
- package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
- package/dist/dashboard/assets/{live-BJGYRBnR.js → live-C4pEJUqI.js} +1 -1
- package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BRiuufkB.js +1 -0
- package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-B4ROwk8L.js} +1 -1
- package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
- package/dist/dashboard/assets/numbers-YNcHvRY7.js +2 -0
- package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-gmjzC3RS.js} +1 -1
- package/dist/dashboard/assets/{query-keys-D7wX2vM5.js.gz → query-keys-gmjzC3RS.js.gz} +0 -0
- package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-C7tBT5o-.js} +1 -1
- package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
- package/dist/dashboard/assets/{select-DsX5QT8K.js → select-WUCXorXw.js} +2 -2
- package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
- package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-C_zKl1GS.js} +1 -1
- package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
- package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-_3O0F8Xn.js} +1 -1
- package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
- package/dist/dashboard/assets/{state-DYdQmdAl.js → state-BgTxHcJS.js} +1 -1
- package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
- package/dist/dashboard/assets/switch-DEjTtOWf.js +1 -0
- package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
- package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
- package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-BWSC0UAS.js} +1 -1
- package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
- package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
- package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
- package/dist/dashboard/donation/qris-risun-marke.png +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +54 -54
- package/package.json +1 -1
- package/scripts/cli/autostart.ts +237 -0
- package/scripts/cli/command/console-menu.ts +244 -246
- package/scripts/cli/command/tray.ts +208 -204
- package/scripts/cli/command/update.ts +27 -10
- package/scripts/cli/main.ts +28 -1
- package/src/boot/app.ts +693 -693
- package/src/boot/dependencies.ts +646 -616
- package/src/config/spec.ts +81 -2
- package/src/config/telemetry.ts +46 -0
- package/src/console/api-keys/bucket-spend.ts +15 -4
- package/src/console/backup/contracts.ts +15 -2
- package/src/console/backup/routes.ts +52 -1
- package/src/console/backup/service.ts +60 -4
- package/src/console/console-router.ts +8 -0
- package/src/console/data-management/contracts.ts +93 -0
- package/src/console/data-management/operations.ts +229 -0
- package/src/console/data-management/routes.ts +104 -0
- package/src/console/domain-registration.ts +39 -0
- package/src/console/observability/contracts.ts +524 -485
- package/src/console/observability/store.ts +1115 -1018
- package/src/console/providers/catalog/contracts.ts +6 -0
- package/src/console/providers/catalog/provider-operations.ts +20 -0
- package/src/console/providers/catalog/routes.ts +7 -0
- package/src/console/providers/catalog/store.ts +22 -1
- package/src/console/quota/account-quota-shared.ts +52 -39
- package/src/console/quota/refresh.ts +441 -429
- package/src/console/routing/pools/import-parse.ts +206 -0
- package/src/console/routing/pools/operations.ts +543 -501
- package/src/console/routing/pools/routes.ts +181 -164
- package/src/console/settings/contracts.ts +37 -0
- package/src/console/settings/store.ts +51 -0
- package/src/console/share/share-router.ts +6 -9
- package/src/console/updates/routes.ts +16 -0
- package/src/main.ts +10 -0
- package/src/observability/event-compaction.ts +315 -0
- package/src/observability/event-day-index.ts +442 -0
- package/src/observability/event-log.ts +152 -3
- package/src/observability/payload-capture.ts +373 -344
- package/src/observability/payload-store.ts +440 -366
- package/src/observability/telemetry-status.ts +28 -16
- package/src/persistence/health-event-flush.ts +36 -0
- package/src/persistence/schema.ts +1402 -1360
- package/src/persistence/stores/api-key-store.ts +6 -0
- package/src/persistence/stores/share-store.ts +583 -556
- package/src/persistence/stores/telemetry-store.ts +237 -214
- package/src/protocol/codecs.ts +1 -2
- package/src/protocol/image-clamp.ts +19 -1
- package/src/protocol/registry.ts +8 -0
- package/src/protocol/request/gemini.ts +9 -2
- package/src/protocol/request/messages.ts +14 -2
- package/src/protocol/response/gemini.ts +18 -4
- package/src/providers/adapters/compatible-adapter.ts +3 -0
- package/src/providers/bundled-discovery-providers.ts +122 -119
- package/src/providers/configured-openai-providers.ts +136 -134
- package/src/providers/default-registry.ts +5 -4
- package/src/providers/discovery/openai-model-discovery.ts +5 -0
- package/src/providers/discovery/openrouter-free-roster.ts +15 -17
- package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
- package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
- package/src/providers/integrations/cline/cline-quota.ts +132 -128
- package/src/providers/integrations/codex/codex-headers.ts +25 -39
- package/src/providers/integrations/codex/codex.ts +2 -29
- package/src/providers/integrations/cursor/cursor-quota.ts +310 -0
- package/src/providers/integrations/cursor/cursor-request.ts +616 -471
- package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
- package/src/providers/integrations/cursor/cursor.ts +1485 -960
- package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
- package/src/providers/integrations/factory/factory.ts +8 -1
- package/src/providers/integrations/gemini.ts +6 -1
- package/src/providers/integrations/grok/grok-quota.ts +279 -177
- package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
- package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
- package/src/providers/integrations/opencode/provider.ts +531 -532
- package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
- package/src/providers/integrations/tokenharbor.ts +2 -2
- package/src/providers/integrations/trae/trae-catalog.ts +5 -3
- package/src/providers/integrations/trae/trae-quota.ts +384 -0
- package/src/providers/operations/account-health-service.ts +1523 -1457
- package/src/providers/operations/client-versions.ts +9 -2
- package/src/providers/provider-contracts.ts +7 -0
- package/src/providers/provider-metadata.ts +13 -1
- package/src/providers/quota/quota-support.ts +8 -0
- package/src/security/api-key-auth.ts +504 -494
- package/src/transport/dispatch/attempt-finalize.ts +46 -5
- package/src/transport/dispatch/generation.ts +6 -0
- package/src/transport/dispatch/proxy-request.ts +33 -1
- package/src/transport/dispatch/ratelimit-capture.ts +37 -0
- package/src/transport/dispatch/streaming/stream.ts +30 -2
- package/src/transport/dispatch/upstream.ts +9 -0
- package/src/transport/request/preparer.ts +7 -6
- package/src/transport/request/state.ts +9 -0
- package/src/transport/request/tenant-preferences.ts +60 -0
- package/src/transport/routing/route-catalog.ts +854 -799
- package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js +0 -1
- package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js +0 -7
- package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BihkgQSq.js +0 -1
- package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
- package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
- package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
- package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
- package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
- package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
- package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
- package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
- package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
- package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
- package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dj9AR0gY.js +0 -4
- package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
- package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
- package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
- package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
- package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
- package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
- package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
- package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
- package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
- package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
- package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
- package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
- package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
- package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
- package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
- package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
- package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
- package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
- package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
- package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
- package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
- package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
- package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
- /package/dist/dashboard/assets/{app-CafWtVXA.js → app-DCQZRmHY.js} +0 -0
- /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-DCQZRmHY.js.gz} +0 -0
|
@@ -1,366 +1,440 @@
|
|
|
1
|
-
import { mkdir, open, readdir, readFile, stat, unlink } from "node:fs/promises";
|
|
2
|
-
import { basename, join } from "node:path";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
-
import { gunzipSync, gzipSync } from "node:zlib";
|
|
5
|
-
|
|
6
|
-
export interface PayloadFileReference {
|
|
7
|
-
/**
|
|
8
|
-
* `jsonb-file` is a frame written before compression existed: plain JSON.
|
|
9
|
-
* New frames are `jsonl-file`: the same JSON, gzipped. The row names its
|
|
10
|
-
* encoding, so a reader never guesses and an old row still resolves.
|
|
11
|
-
*/
|
|
12
|
-
readonly storage: "jsonb-file" | "jsonl-file";
|
|
13
|
-
readonly file: string;
|
|
14
|
-
readonly offset: number;
|
|
15
|
-
readonly length: number;
|
|
16
|
-
readonly checksum: string;
|
|
17
|
-
readonly version: 1;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface PayloadFrame {
|
|
21
|
-
readonly version: 1;
|
|
22
|
-
readonly id: string;
|
|
23
|
-
readonly expiresAt: string;
|
|
24
|
-
readonly payload: unknown;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function isPayloadFileReference(value: unknown): value is PayloadFileReference {
|
|
28
|
-
if (typeof value !== "object" || value === null) return false;
|
|
29
|
-
const record = value as Record<string, unknown>;
|
|
30
|
-
return (
|
|
31
|
-
(record["storage"] === "jsonb-file" || record["storage"] === "jsonl-file") &&
|
|
32
|
-
typeof record["file"] === "string" &&
|
|
33
|
-
typeof record["offset"] === "number" &&
|
|
34
|
-
typeof record["length"] === "number" &&
|
|
35
|
-
typeof record["checksum"] === "string" &&
|
|
36
|
-
record["version"] === 1
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Builds a frame reference from a `telemetry_payloads` row's typed columns.
|
|
42
|
-
* Returns undefined when any column is out of contract so callers treat a
|
|
43
|
-
* corrupt index row as "no payload" rather than path-traversing or reading
|
|
44
|
-
* the wrong bytes.
|
|
45
|
-
*/
|
|
46
|
-
export function payloadReferenceFromRow(row: {
|
|
47
|
-
readonly storage: string;
|
|
48
|
-
readonly file: string;
|
|
49
|
-
readonly offset: number;
|
|
50
|
-
readonly length: number;
|
|
51
|
-
readonly checksum: string;
|
|
52
|
-
readonly version: number;
|
|
53
|
-
}): PayloadFileReference | undefined {
|
|
54
|
-
const candidate = {
|
|
55
|
-
storage: row.storage,
|
|
56
|
-
file: row.file,
|
|
57
|
-
offset: row.offset,
|
|
58
|
-
length: row.length,
|
|
59
|
-
checksum: row.checksum,
|
|
60
|
-
version: row.version,
|
|
61
|
-
};
|
|
62
|
-
return isPayloadFileReference(candidate) ? candidate : undefined;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
const
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
return
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
*
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
1
|
+
import { mkdir, open, readdir, readFile, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
5
|
+
|
|
6
|
+
export interface PayloadFileReference {
|
|
7
|
+
/**
|
|
8
|
+
* `jsonb-file` is a frame written before compression existed: plain JSON.
|
|
9
|
+
* New frames are `jsonl-file`: the same JSON, gzipped. The row names its
|
|
10
|
+
* encoding, so a reader never guesses and an old row still resolves.
|
|
11
|
+
*/
|
|
12
|
+
readonly storage: "jsonb-file" | "jsonl-file";
|
|
13
|
+
readonly file: string;
|
|
14
|
+
readonly offset: number;
|
|
15
|
+
readonly length: number;
|
|
16
|
+
readonly checksum: string;
|
|
17
|
+
readonly version: 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface PayloadFrame {
|
|
21
|
+
readonly version: 1;
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly expiresAt: string;
|
|
24
|
+
readonly payload: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isPayloadFileReference(value: unknown): value is PayloadFileReference {
|
|
28
|
+
if (typeof value !== "object" || value === null) return false;
|
|
29
|
+
const record = value as Record<string, unknown>;
|
|
30
|
+
return (
|
|
31
|
+
(record["storage"] === "jsonb-file" || record["storage"] === "jsonl-file") &&
|
|
32
|
+
typeof record["file"] === "string" &&
|
|
33
|
+
typeof record["offset"] === "number" &&
|
|
34
|
+
typeof record["length"] === "number" &&
|
|
35
|
+
typeof record["checksum"] === "string" &&
|
|
36
|
+
record["version"] === 1
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Builds a frame reference from a `telemetry_payloads` row's typed columns.
|
|
42
|
+
* Returns undefined when any column is out of contract so callers treat a
|
|
43
|
+
* corrupt index row as "no payload" rather than path-traversing or reading
|
|
44
|
+
* the wrong bytes.
|
|
45
|
+
*/
|
|
46
|
+
export function payloadReferenceFromRow(row: {
|
|
47
|
+
readonly storage: string;
|
|
48
|
+
readonly file: string;
|
|
49
|
+
readonly offset: number;
|
|
50
|
+
readonly length: number;
|
|
51
|
+
readonly checksum: string;
|
|
52
|
+
readonly version: number;
|
|
53
|
+
}): PayloadFileReference | undefined {
|
|
54
|
+
const candidate = {
|
|
55
|
+
storage: row.storage,
|
|
56
|
+
file: row.file,
|
|
57
|
+
offset: row.offset,
|
|
58
|
+
length: row.length,
|
|
59
|
+
checksum: row.checksum,
|
|
60
|
+
version: row.version,
|
|
61
|
+
};
|
|
62
|
+
return isPayloadFileReference(candidate) ? candidate : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Extension for newly written frame files. Old `.jsonb` files are still read. */
|
|
66
|
+
const FRAME_FILE_SUFFIX = ".jsonl";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* True for a frame file, whichever extension it carries.
|
|
70
|
+
*
|
|
71
|
+
* Files written before the format change end `.jsonb`. They still hold live
|
|
72
|
+
* frames whose rows point at them, so a prune that recognised only the new
|
|
73
|
+
* extension would abandon them — and since nothing else removes them, they
|
|
74
|
+
* would sit on disk indefinitely.
|
|
75
|
+
*/
|
|
76
|
+
function isFrameFileName(name: string): boolean {
|
|
77
|
+
return name.endsWith(FRAME_FILE_SUFFIX) || name.endsWith(".jsonb");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const FRAME_HEADER_BYTES = 4;
|
|
81
|
+
const MAX_FRAME_BYTES = 256 * 1024 * 1024;
|
|
82
|
+
const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
83
|
+
const DEFAULT_DIRECTORY = "./data/telemetry-payloads";
|
|
84
|
+
let writeTail: Promise<void> = Promise.resolve();
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Single source of truth for the telemetry payload directory: the
|
|
88
|
+
* `CARTETHYIA_TELEMETRY_PAYLOAD_DIR` override when set, otherwise the
|
|
89
|
+
* default `./data/telemetry-payloads`. Every writer and reader must resolve
|
|
90
|
+
* the directory through this function so the two can never drift.
|
|
91
|
+
*/
|
|
92
|
+
export function payloadDirectory(): string {
|
|
93
|
+
return process.env.CARTETHYIA_TELEMETRY_PAYLOAD_DIR?.trim() || DEFAULT_DIRECTORY;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function maxFileBytes(): number {
|
|
97
|
+
const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_FILE_MAX_BYTES ?? DEFAULT_MAX_FILE_BYTES);
|
|
98
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_FILE_BYTES;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function checksum(value: Buffer | readonly Buffer[]): string {
|
|
102
|
+
const parts = Array.isArray(value) ? value : [value];
|
|
103
|
+
let hash = 2166136261;
|
|
104
|
+
for (const part of parts) {
|
|
105
|
+
for (let index = 0; index < part.byteLength; index += 1) {
|
|
106
|
+
hash ^= part[index]!;
|
|
107
|
+
hash = Math.imul(hash, 16777619);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function fileName(now = new Date()): string {
|
|
114
|
+
return `${now.toISOString().slice(0, 13).replaceAll(":", "-")}-${process.pid}${FRAME_FILE_SUFFIX}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Gzipped frame body.
|
|
119
|
+
*
|
|
120
|
+
* The pieces stay separate until here, because nothing before the write reads
|
|
121
|
+
* them assembled and joining them earlier is a copy of the whole frame. The
|
|
122
|
+
* join happens once, at the moment of compression, and the compressed buffer
|
|
123
|
+
* is what gets written, checksummed, and bounded — so the cap and the
|
|
124
|
+
* reference both describe bytes on disk, not bytes of JSON.
|
|
125
|
+
*/
|
|
126
|
+
function compressFrame(parts: readonly Buffer[]): Buffer {
|
|
127
|
+
return gzipSync(Buffer.concat(parts));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function appendFrame(frame: PayloadFrame, parts?: readonly Buffer[]): Promise<PayloadFileReference> {
|
|
131
|
+
const directory = payloadDirectory();
|
|
132
|
+
await mkdir(directory, { recursive: true });
|
|
133
|
+
// Encoded as a line here rather than at the write, so the cap, the checksum,
|
|
134
|
+
// and the reference all describe the same bytes that land on disk — the
|
|
135
|
+
// prebuilt-parts path and the plain one cannot disagree about the shape.
|
|
136
|
+
const json = parts === undefined
|
|
137
|
+
? Buffer.from(JSON.stringify(frame), "utf8")
|
|
138
|
+
: Buffer.concat(parts);
|
|
139
|
+
const compressed = compressFrame([json]);
|
|
140
|
+
if (compressed.byteLength > MAX_FRAME_BYTES) throw new Error("telemetry payload frame exceeds limit");
|
|
141
|
+
const encoded = toLine(compressed);
|
|
142
|
+
const file = fileName();
|
|
143
|
+
const path = join(directory, file);
|
|
144
|
+
const currentSize = await stat(path).then((value) => value.size).catch(() => 0);
|
|
145
|
+
const offset = currentSize;
|
|
146
|
+
if (offset + encoded.byteLength > maxFileBytes()) {
|
|
147
|
+
const rotated = `${Date.now()}-${randomUUID()}${FRAME_FILE_SUFFIX}`;
|
|
148
|
+
return appendFrameToPath(join(directory, rotated), encoded, basename(rotated));
|
|
149
|
+
}
|
|
150
|
+
return appendFrameToPath(path, encoded, file, offset);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* One frame as a stored line: base64 of the gzipped JSON, newline-terminated.
|
|
155
|
+
*
|
|
156
|
+
* The 4-byte binary length header is gone. A header made every frame opaque —
|
|
157
|
+
* you could not `cat` the file or read a frame without first decoding a
|
|
158
|
+
* big-endian length and then gunzipping exactly that many bytes. A newline is
|
|
159
|
+
* the delimiter instead, so the file is a text file of base64 lines and a
|
|
160
|
+
* frame's extent is found by reading to the next `\n`.
|
|
161
|
+
*
|
|
162
|
+
* base64 rather than raw gzip bytes because gzip contains newlines, which
|
|
163
|
+
* would make the delimiter ambiguous. The row still names `jsonl-file`, so no
|
|
164
|
+
* schema change and no migration: readers tell old from new off the bytes.
|
|
165
|
+
*/
|
|
166
|
+
function toLine(compressed: Buffer): Buffer {
|
|
167
|
+
return Buffer.from(`${compressed.toString("base64")}\n`, "utf8");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function appendFrameToPath(
|
|
171
|
+
path: string,
|
|
172
|
+
encoded: Buffer,
|
|
173
|
+
knownFile = basename(path),
|
|
174
|
+
knownOffset?: number,
|
|
175
|
+
): Promise<PayloadFileReference> {
|
|
176
|
+
const offset = knownOffset ?? await stat(path).then((value) => value.size).catch(() => 0);
|
|
177
|
+
const handle = await open(path, "a");
|
|
178
|
+
try {
|
|
179
|
+
await handle.write(encoded);
|
|
180
|
+
} finally {
|
|
181
|
+
await handle.close();
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
storage: "jsonl-file",
|
|
185
|
+
file: knownFile,
|
|
186
|
+
offset,
|
|
187
|
+
length: encoded.byteLength,
|
|
188
|
+
checksum: checksum(encoded),
|
|
189
|
+
version: 1,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The frame's body keys, in the same order `buildPayloadRecord` serializes
|
|
195
|
+
* them.
|
|
196
|
+
*
|
|
197
|
+
* The record hands the writer one string per body already serialized, so the
|
|
198
|
+
* two orders must agree or every body lands under the wrong key. The order is
|
|
199
|
+
* drawer order with the legacy `response_body` alias last, for the same reason
|
|
200
|
+
* the record accounts it last: it names no panel of its own and must not
|
|
201
|
+
* spend the shared byte budget ahead of the provider stages.
|
|
202
|
+
*/
|
|
203
|
+
const FRAME_BODY_KEYS = [
|
|
204
|
+
"request_body",
|
|
205
|
+
"provider_request_body",
|
|
206
|
+
"provider_response_body",
|
|
207
|
+
"client_response_body",
|
|
208
|
+
"response_body",
|
|
209
|
+
] as const;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The pieces of a frame whose bodies were already serialized while measured.
|
|
213
|
+
*
|
|
214
|
+
* Returned as separate buffers rather than one assembled buffer: the file
|
|
215
|
+
* write accepts a list, and assembling them first is a copy of the whole frame
|
|
216
|
+
* that nothing reads back.
|
|
217
|
+
*/
|
|
218
|
+
function prebuiltFrameParts(
|
|
219
|
+
id: string,
|
|
220
|
+
expiresAt: string,
|
|
221
|
+
capturedAt: string,
|
|
222
|
+
requestId: string | null,
|
|
223
|
+
signals: unknown,
|
|
224
|
+
bodies: readonly string[],
|
|
225
|
+
): Buffer[] {
|
|
226
|
+
const parts = [
|
|
227
|
+
Buffer.from(
|
|
228
|
+
`{"version":1,"id":${JSON.stringify(id)},"expiresAt":${JSON.stringify(expiresAt)},` +
|
|
229
|
+
`"payload":{"request_id":${JSON.stringify(requestId)},"captured_at":${JSON.stringify(capturedAt)},` +
|
|
230
|
+
`"expires_at":${JSON.stringify(expiresAt)}`,
|
|
231
|
+
"utf8",
|
|
232
|
+
),
|
|
233
|
+
];
|
|
234
|
+
for (let index = 0; index < FRAME_BODY_KEYS.length; index += 1) {
|
|
235
|
+
parts.push(Buffer.from(`,"${FRAME_BODY_KEYS[index]}":${bodies[index] ?? "null"}`, "utf8"));
|
|
236
|
+
}
|
|
237
|
+
parts.push(Buffer.from(`,"signals":${JSON.stringify(signals)}}}`, "utf8"));
|
|
238
|
+
return parts;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function writePayloadFrame(
|
|
242
|
+
payload: unknown,
|
|
243
|
+
expiresAt: Date,
|
|
244
|
+
prebuilt?: { readonly requestId: string | null; readonly capturedAt: string; readonly signals: unknown; readonly bodies: readonly string[] },
|
|
245
|
+
): Promise<PayloadFileReference> {
|
|
246
|
+
const id = randomUUID();
|
|
247
|
+
const expires = expiresAt.toISOString();
|
|
248
|
+
const parts = prebuilt
|
|
249
|
+
? prebuiltFrameParts(id, expires, prebuilt.capturedAt, prebuilt.requestId, prebuilt.signals, prebuilt.bodies)
|
|
250
|
+
: undefined;
|
|
251
|
+
const frame: PayloadFrame = { version: 1, id, expiresAt: expires, payload };
|
|
252
|
+
const task = writeTail.then(() => appendFrame(frame, parts));
|
|
253
|
+
writeTail = task.then(() => undefined, () => undefined);
|
|
254
|
+
return task;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function readPayloadFrame(reference: PayloadFileReference): Promise<unknown | undefined> {
|
|
258
|
+
// Basename-constrain the reference: a hostile/restored row carrying
|
|
259
|
+
// `../../x` must not escape the payload directory (path traversal).
|
|
260
|
+
const safeFile = basename(reference.file);
|
|
261
|
+
if (safeFile !== reference.file || safeFile.length === 0) return undefined;
|
|
262
|
+
const path = join(payloadDirectory(), safeFile);
|
|
263
|
+
// Read exactly the frame. Reading the whole file copied every earlier frame
|
|
264
|
+
// in the same hour into memory just to return one of them.
|
|
265
|
+
const handle = await open(path, "r");
|
|
266
|
+
let data: Buffer;
|
|
267
|
+
try {
|
|
268
|
+
const stat = await handle.stat();
|
|
269
|
+
if (reference.offset < 0 || reference.offset + reference.length > stat.size) return undefined;
|
|
270
|
+
data = Buffer.alloc(reference.length);
|
|
271
|
+
await handle.read(data, 0, reference.length, reference.offset);
|
|
272
|
+
} finally {
|
|
273
|
+
await handle.close();
|
|
274
|
+
}
|
|
275
|
+
const frame = data;
|
|
276
|
+
const json = frameJsonFromSlice(frame, reference.storage);
|
|
277
|
+
if (json === undefined) return undefined;
|
|
278
|
+
const parsed = JSON.parse(json.toString("utf8")) as PayloadFrame;
|
|
279
|
+
return parsed.version === 1 ? parsed.payload : undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Decompressed frame body, or undefined when the bytes are not gzip. */
|
|
283
|
+
function gunzipFrame(stored: Buffer): Buffer | undefined {
|
|
284
|
+
try {
|
|
285
|
+
return gunzipSync(stored);
|
|
286
|
+
} catch {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The JSON of one frame read out of a file, in either on-disk shape.
|
|
293
|
+
*
|
|
294
|
+
* Old frames carry a 4-byte big-endian length header, so their first byte is
|
|
295
|
+
* `0x00` for any frame under 16 MB. New frames are a base64 line, which never
|
|
296
|
+
* begins with a null byte. That one byte separates the two without a version
|
|
297
|
+
* field, a schema change, or a migration — the same trick `frameJson` already
|
|
298
|
+
* uses to tell gzip from plain JSON by its magic.
|
|
299
|
+
*
|
|
300
|
+
* Old frames are still read because they exist on disk right now and their
|
|
301
|
+
* rows point at them; they expire on their own within the retention window, so
|
|
302
|
+
* tolerating both is temporary by construction.
|
|
303
|
+
*/
|
|
304
|
+
function frameJsonFromSlice(
|
|
305
|
+
slice: Buffer,
|
|
306
|
+
storage: PayloadFileReference["storage"],
|
|
307
|
+
): Buffer | undefined {
|
|
308
|
+
const hasBinaryHeader = slice.byteLength >= FRAME_HEADER_BYTES && slice[0] === 0x00;
|
|
309
|
+
if (hasBinaryHeader) {
|
|
310
|
+
const length = slice.readUInt32BE(0);
|
|
311
|
+
if (length <= 0 || length + FRAME_HEADER_BYTES !== slice.byteLength) return undefined;
|
|
312
|
+
const stored = slice.subarray(FRAME_HEADER_BYTES);
|
|
313
|
+
return storage === "jsonl-file" ? gunzipFrame(stored) : stored;
|
|
314
|
+
}
|
|
315
|
+
// New shape: a base64 line. The trailing newline is not part of the payload.
|
|
316
|
+
const text = slice.toString("utf8").trim();
|
|
317
|
+
if (text.length === 0) return undefined;
|
|
318
|
+
const gzipped = Buffer.from(text, "base64");
|
|
319
|
+
return gunzipFrame(gzipped);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* What a whole file's parseable frames say about reclaiming it.
|
|
324
|
+
*
|
|
325
|
+
* `live` — at least one frame is still inside its retention window.
|
|
326
|
+
* `none` — every frame was read and every one has expired.
|
|
327
|
+
* `unknown` — parsing stopped on a torn tail or a corrupt frame, so the rest
|
|
328
|
+
* of the file is unreadable and cannot be assumed empty of live frames.
|
|
329
|
+
*/
|
|
330
|
+
type FrameLiveness = "live" | "none" | "unknown";
|
|
331
|
+
|
|
332
|
+
function frameLiveness(data: Buffer, before: Date): FrameLiveness {
|
|
333
|
+
let offset = 0;
|
|
334
|
+
// One byte, not FRAME_HEADER_BYTES: a line frame is bounded by its newline
|
|
335
|
+
// and needs no minimum length of its own. Keying the loop to the old header
|
|
336
|
+
// size would stop a new-shape file a few bytes early and report it
|
|
337
|
+
// unreadable, which leaves it on disk forever.
|
|
338
|
+
while (offset < data.byteLength) {
|
|
339
|
+
// New frames end at the next newline; old ones carry a length header. The
|
|
340
|
+
// first byte tells them apart, so a file holding a mix still walks.
|
|
341
|
+
const newline = data.indexOf(0x0a, offset);
|
|
342
|
+
const hasBinaryHeader = data[offset] === 0x00;
|
|
343
|
+
|
|
344
|
+
let end: number;
|
|
345
|
+
let slice: Buffer;
|
|
346
|
+
if (hasBinaryHeader) {
|
|
347
|
+
const length = data.readUInt32BE(offset);
|
|
348
|
+
end = offset + FRAME_HEADER_BYTES + length;
|
|
349
|
+
// A length that runs past the file is a half-written tail, not a frame.
|
|
350
|
+
if (length <= 0 || end > data.byteLength) return "unknown";
|
|
351
|
+
slice = data.subarray(offset, end);
|
|
352
|
+
} else {
|
|
353
|
+
if (newline <= 0) return "unknown";
|
|
354
|
+
end = newline + 1;
|
|
355
|
+
slice = data.subarray(offset, end);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const json = frameJsonFromSlice(slice, "jsonl-file");
|
|
359
|
+
if (json === undefined) return "unknown";
|
|
360
|
+
let frame: PayloadFrame;
|
|
361
|
+
try {
|
|
362
|
+
frame = JSON.parse(json.toString("utf8")) as PayloadFrame;
|
|
363
|
+
} catch {
|
|
364
|
+
return "unknown";
|
|
365
|
+
}
|
|
366
|
+
if (new Date(frame.expiresAt).getTime() >= before.getTime()) return "live";
|
|
367
|
+
offset = end;
|
|
368
|
+
}
|
|
369
|
+
// Bytes left over that are too short to hold a header are an unreadable tail.
|
|
370
|
+
return offset === data.byteLength ? "none" : "unknown";
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Reclaims frame files that no longer hold a live payload.
|
|
375
|
+
*
|
|
376
|
+
* Frames are append-only and this pass never rewrites a file. That is a
|
|
377
|
+
* correctness requirement, not an optimization: a `telemetry_payloads` row
|
|
378
|
+
* addresses its body by file + offset + length, so compacting a file in place
|
|
379
|
+
* would shift every later frame and leave live rows pointing at the wrong
|
|
380
|
+
* bytes — the drawer then reads `undefined` and the payload silently vanishes
|
|
381
|
+
* while its row is still current. A file is dropped whole, and only once
|
|
382
|
+
* nothing in it is still live.
|
|
383
|
+
*
|
|
384
|
+
* `unparseableWrittenBefore` bounds the damaged-file case. A region that
|
|
385
|
+
* cannot be parsed cannot prove it holds no live frame, so such a file is kept
|
|
386
|
+
* until its last write is older than that instant — every frame it could hold
|
|
387
|
+
* was written before then and has therefore expired. A caller that cannot
|
|
388
|
+
* supply that bound leaves the default, which keeps damaged files rather than
|
|
389
|
+
* risk dropping a frame a row still addresses.
|
|
390
|
+
*
|
|
391
|
+
* One damaged file must not abort the pass. A single corrupt frame used to
|
|
392
|
+
* throw straight out of the loop, so every other expired file stayed on disk
|
|
393
|
+
* forever and the volume only grew — and because a container restart is the
|
|
394
|
+
* usual way a half-written tail appears, the failure looked Docker-specific.
|
|
395
|
+
*/
|
|
396
|
+
export async function prunePayloadFrames(
|
|
397
|
+
before: Date,
|
|
398
|
+
unparseableWrittenBefore: Date = new Date(0),
|
|
399
|
+
): Promise<number> {
|
|
400
|
+
const directory = payloadDirectory();
|
|
401
|
+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
402
|
+
let deleted = 0;
|
|
403
|
+
for (const entry of entries) {
|
|
404
|
+
if (!entry.isFile() || !isFrameFileName(entry.name)) continue;
|
|
405
|
+
const path = join(directory, entry.name);
|
|
406
|
+
try {
|
|
407
|
+
if (await fileReclaimable(path, before, unparseableWrittenBefore)) {
|
|
408
|
+
await unlink(path);
|
|
409
|
+
deleted += 1;
|
|
410
|
+
}
|
|
411
|
+
} catch {
|
|
412
|
+
// Stat, read, or unlink failed on this one file. Leave it for the next
|
|
413
|
+
// pass rather than abandoning every remaining file's reclaim.
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return deleted;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* True when the file can be deleted without losing a frame a row still
|
|
421
|
+
* addresses. A readable file is reclaimable once every frame has expired; a
|
|
422
|
+
* damaged one only once `unparseableWrittenBefore` rules out a live frame in
|
|
423
|
+
* the part that could not be parsed.
|
|
424
|
+
*/
|
|
425
|
+
async function fileReclaimable(
|
|
426
|
+
path: string,
|
|
427
|
+
before: Date,
|
|
428
|
+
unparseableWrittenBefore: Date,
|
|
429
|
+
): Promise<boolean> {
|
|
430
|
+
const data = await readFile(path);
|
|
431
|
+
// Nothing written yet, so nothing to lose. The old test was "shorter than a
|
|
432
|
+
// binary header", which the line format has no need of; a real frame — old
|
|
433
|
+
// or new — is far longer than that anyway.
|
|
434
|
+
if (data.byteLength === 0) return true;
|
|
435
|
+
const liveness = frameLiveness(data, before);
|
|
436
|
+
if (liveness === "live") return false;
|
|
437
|
+
if (liveness === "none") return true;
|
|
438
|
+
const written = await stat(path).then((value) => value.mtimeMs);
|
|
439
|
+
return written <= unparseableWrittenBefore.getTime();
|
|
440
|
+
}
|