failproofai 1.0.0-beta.2 → 1.0.0-beta.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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +6 -6
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +2 -2
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +1 -1
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
- package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-dh5jy._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0f8nu8d._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0h3lfmp._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l4vuqm._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0longh-._.js → [root-of-the-server]__0szqzl8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__10oa-v9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__18d-9oi._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__1p7s8-d._.js} +2 -2
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__0cmdyn_._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0uno39i._.js → [root-of-the-server]__0n_uvv0._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__0rag5dg._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0rtdfof._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__0sdrz-j._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_1q9xxca.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 06jakt_xhoolk.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/0oe2-hp0_2tzs.js +6 -0
- package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 0ue9ak_xexiqh.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/1fw3pxobk_otl.js +1 -0
- package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
- package/.next/standalone/.next/static/chunks/2c90h1g6x8z8y.js +1 -0
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 2zb3m1tzq6h98.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 31cqiv_cpgj9m.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 3h5_3u46gu9jx.js} +1 -1
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 43a1ywqsg9bq9.js} +1 -1
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/Cargo.lock +1992 -198
- package/.next/standalone/Cargo.toml +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
- package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
- package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
- package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
- package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
- package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
- package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
- package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
- package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
- package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
- package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
- package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
- package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
- package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
- package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
- package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
- package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
- package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
- package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
- package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
- package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
- package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
- package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
- package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
- package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
- package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
- package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
- package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
- package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
- package/.next/standalone/package.json +15 -9
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2255 -779
- package/dist/worker.mjs +365 -52
- package/package.json +15 -9
- package/scripts/build-daemon-packages.mjs +210 -0
- package/scripts/daemon-platforms.mjs +42 -0
- package/scripts/prune-standalone.mjs +6 -0
- package/scripts/publish-aliases.mjs +62 -41
- package/src/hooks/builtin-policies.ts +139 -0
- package/src/hooks/cloud-connection.ts +205 -0
- package/src/hooks/cloud-enrollment-cli.ts +227 -0
- package/src/hooks/cloud-enrollment.ts +173 -0
- package/src/hooks/cloud-managed-policies.ts +138 -0
- package/src/hooks/collector-config.ts +226 -0
- package/src/hooks/configure-wizard.ts +221 -5
- package/src/hooks/custom-hooks-loader.ts +69 -25
- package/src/hooks/daemon-client.ts +18 -0
- package/src/hooks/daemon-download.ts +137 -27
- package/src/hooks/daemon-service.ts +23 -8
- package/src/hooks/handler.ts +144 -6
- package/src/hooks/hook-activity-store.ts +50 -0
- package/src/hooks/loader-utils.ts +6 -4
- package/src/hooks/policy-types.ts +35 -0
- package/src/hooks/session-pause-cli.ts +157 -0
- package/src/hooks/session-pause.ts +236 -0
- package/src/hooks/tui.ts +115 -0
- package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
- package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/31feojxhjyjpl.js +0 -1
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
//! Ships spooled batches to the ingest endpoint.
|
|
2
|
+
//!
|
|
3
|
+
//! Four properties here are load-bearing. Each one exists because its absence
|
|
4
|
+
//! loses data rather than merely slowing things down.
|
|
5
|
+
//!
|
|
6
|
+
//! **The timeout is per-read, not per-request.** A whole-request timeout also
|
|
7
|
+
//! bounds streaming the body, and the body is re-sent in full on every retry —
|
|
8
|
+
//! so a large batch on an ordinary uplink can never finish, and burns its
|
|
9
|
+
//! entire retry budget failing the same way each time. A per-read timeout
|
|
10
|
+
//! bounds progress instead of total size. On its own it bounds nothing,
|
|
11
|
+
//! though: a server trickling one byte inside every window would hold the
|
|
12
|
+
//! request open forever, so a deliberately generous total cap stays as a
|
|
13
|
+
//! backstop. It is a large multiple of the read timeout so it only ever
|
|
14
|
+
//! catches a genuinely stuck request, never a merely slow one.
|
|
15
|
+
//!
|
|
16
|
+
//! **A 2xx is not automatically a success.** Ingest answers
|
|
17
|
+
//! `{"accepted":N,"skipped":M}` and silently skips any line it cannot parse —
|
|
18
|
+
//! a missing `session_id`, an unparseable timestamp, a comma in
|
|
19
|
+
//! `environment`. Without reading that body, a batch the server discarded
|
|
20
|
+
//! entirely is indistinguishable from a perfect upload.
|
|
21
|
+
//!
|
|
22
|
+
//! **`failed/` is a retry queue, not a graveyard.** A batch there is data the
|
|
23
|
+
//! server does not have, and it is the last copy. Batches are retried in place
|
|
24
|
+
//! with the attempt count encoded in the filename, and only ever parked as
|
|
25
|
+
//! `.poison` — never deleted.
|
|
26
|
+
//!
|
|
27
|
+
//! **Oversized batches are split in memory.** Writing the chunks to disk
|
|
28
|
+
//! beside the original would create files the watcher had never seen, so it
|
|
29
|
+
//! would pick them up and post them concurrently with this function — the same
|
|
30
|
+
//! payload delivered twice.
|
|
31
|
+
|
|
32
|
+
use std::path::{Path, PathBuf};
|
|
33
|
+
use std::sync::Arc;
|
|
34
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
35
|
+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
36
|
+
|
|
37
|
+
use serde::Deserialize;
|
|
38
|
+
|
|
39
|
+
/// Suffix marking a batch that exhausted its retry budget. Deliberately NOT
|
|
40
|
+
/// `.jsonl`, so every directory scan and the watcher skip it for free rather
|
|
41
|
+
/// than each needing its own filter.
|
|
42
|
+
pub const POISON_SUFFIX: &str = ".poison";
|
|
43
|
+
|
|
44
|
+
/// Largest body sent in one request. Bigger spool files are split.
|
|
45
|
+
pub const DEFAULT_MAX_UPLOAD_BYTES: u64 = 8 * 1024 * 1024;
|
|
46
|
+
/// Total attempts per batch, including the first.
|
|
47
|
+
pub const DEFAULT_MAX_RETRIES: u32 = 5;
|
|
48
|
+
/// Base for the exponential backoff.
|
|
49
|
+
pub const DEFAULT_RETRY_BASE: Duration = Duration::from_millis(1000);
|
|
50
|
+
/// Per-read timeout. Bounds progress, not total transfer size.
|
|
51
|
+
pub const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(120);
|
|
52
|
+
/// Attempts a parked batch gets before it is marked poison.
|
|
53
|
+
pub const DEFAULT_FAILED_RETRIES_MAX: u32 = 3;
|
|
54
|
+
/// Ceiling applied to a server-supplied `Retry-After`, so a misconfigured
|
|
55
|
+
/// header cannot park the uploader for hours.
|
|
56
|
+
const MAX_RETRY_AFTER: Duration = Duration::from_secs(300);
|
|
57
|
+
|
|
58
|
+
#[derive(Debug)]
|
|
59
|
+
pub enum UploadError {
|
|
60
|
+
/// A definitive client error. Not retried — the cause is a rotated key or
|
|
61
|
+
/// a wrong URL, and retrying burns the budget of every other batch.
|
|
62
|
+
Client {
|
|
63
|
+
status: u16,
|
|
64
|
+
},
|
|
65
|
+
Server {
|
|
66
|
+
status: u16,
|
|
67
|
+
attempts: u32,
|
|
68
|
+
},
|
|
69
|
+
Network {
|
|
70
|
+
attempts: u32,
|
|
71
|
+
detail: String,
|
|
72
|
+
},
|
|
73
|
+
Io(std::io::Error),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
impl std::fmt::Display for UploadError {
|
|
77
|
+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
78
|
+
match self {
|
|
79
|
+
UploadError::Client { status } => write!(f, "client error {status}: not retried"),
|
|
80
|
+
UploadError::Server { status, attempts } => {
|
|
81
|
+
write!(f, "server error {status} after {attempts} attempt(s)")
|
|
82
|
+
}
|
|
83
|
+
UploadError::Network { attempts, detail } => {
|
|
84
|
+
write!(f, "network error after {attempts} attempt(s): {detail}")
|
|
85
|
+
}
|
|
86
|
+
UploadError::Io(e) => write!(f, "io error: {e}"),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
impl std::error::Error for UploadError {}
|
|
92
|
+
|
|
93
|
+
impl From<std::io::Error> for UploadError {
|
|
94
|
+
fn from(e: std::io::Error) -> Self {
|
|
95
|
+
UploadError::Io(e)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/// What ingest reports it did with a batch. `skipped` counts lines it refused
|
|
100
|
+
/// to store; those are gone, and nothing retries them.
|
|
101
|
+
#[derive(Debug, Default, Deserialize)]
|
|
102
|
+
struct IngestAck {
|
|
103
|
+
// REQUIRED — no serde default. This is what proves the 2xx came from the
|
|
104
|
+
// ingest endpoint and not a login page, a proxy or a static host that
|
|
105
|
+
// happens to answer 200. An adversarial red-team pointed a live daemon at a
|
|
106
|
+
// dashboard-lookalike (POST /events -> 307 -> /login -> 200 text/html); the
|
|
107
|
+
// old `resp.json().unwrap_or_default()` turned that HTML into a default ack,
|
|
108
|
+
// reported the batch delivered, and DELETED the spool file — silent, total
|
|
109
|
+
// data loss. A body without a numeric `accepted` now fails to parse and the
|
|
110
|
+
// batch is parked instead.
|
|
111
|
+
accepted: u64,
|
|
112
|
+
#[serde(default)]
|
|
113
|
+
skipped: u64,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/// Delivery counters. Live on the long-lived `Uploader` so a supervised task
|
|
117
|
+
/// restart never resets them to zero — a counter that silently rewinds is
|
|
118
|
+
/// worse than no counter, because it reads as "nothing has gone wrong".
|
|
119
|
+
#[derive(Debug, Default)]
|
|
120
|
+
pub struct UploadMetrics {
|
|
121
|
+
pub accepted_total: AtomicU64,
|
|
122
|
+
pub skipped_total: AtomicU64,
|
|
123
|
+
/// Batches the server answered 200 for while storing NONE of their events.
|
|
124
|
+
pub batches_fully_skipped: AtomicU64,
|
|
125
|
+
/// Unix seconds of the last upload the server accepted.
|
|
126
|
+
pub last_ok_ts: AtomicU64,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
pub struct Uploader {
|
|
130
|
+
client: reqwest::Client,
|
|
131
|
+
url: String,
|
|
132
|
+
key: String,
|
|
133
|
+
failed_dir: PathBuf,
|
|
134
|
+
max_upload_bytes: u64,
|
|
135
|
+
max_retries: u32,
|
|
136
|
+
retry_base: Duration,
|
|
137
|
+
failed_retries_max: u32,
|
|
138
|
+
metrics: Arc<UploadMetrics>,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
impl Uploader {
|
|
142
|
+
pub fn new(url: String, key: String, failed_dir: PathBuf) -> Result<Self, String> {
|
|
143
|
+
let client = reqwest::Client::builder()
|
|
144
|
+
.connect_timeout(Duration::from_secs(10))
|
|
145
|
+
.read_timeout(DEFAULT_READ_TIMEOUT)
|
|
146
|
+
// Backstop only — see the module docs. Large multiple of the read
|
|
147
|
+
// timeout so it catches a stuck request, never a slow one.
|
|
148
|
+
.timeout(DEFAULT_READ_TIMEOUT.saturating_mul(10))
|
|
149
|
+
// NEVER follow a redirect. reqwest follows by default, and a
|
|
150
|
+
// misconfigured or hostile ingest URL that 307s to another host
|
|
151
|
+
// then re-POSTs the batch there — verified live, an attacker server
|
|
152
|
+
// received full event payloads (prompts, command text) while the
|
|
153
|
+
// spool file was deleted as "delivered". With no following, a 3xx
|
|
154
|
+
// surfaces as a status that post_batch parks as a client error.
|
|
155
|
+
// reqwest already strips the bearer token across hosts; this stops
|
|
156
|
+
// the body from leaving too.
|
|
157
|
+
.redirect(reqwest::redirect::Policy::none())
|
|
158
|
+
.build()
|
|
159
|
+
.map_err(|e| format!("could not build the HTTP client: {e}"))?;
|
|
160
|
+
|
|
161
|
+
Ok(Uploader {
|
|
162
|
+
client,
|
|
163
|
+
url,
|
|
164
|
+
key,
|
|
165
|
+
failed_dir,
|
|
166
|
+
max_upload_bytes: DEFAULT_MAX_UPLOAD_BYTES,
|
|
167
|
+
max_retries: DEFAULT_MAX_RETRIES,
|
|
168
|
+
retry_base: DEFAULT_RETRY_BASE,
|
|
169
|
+
failed_retries_max: DEFAULT_FAILED_RETRIES_MAX,
|
|
170
|
+
metrics: Arc::new(UploadMetrics::default()),
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/// Shorten every delay. Tests only — without it each retry test would wait
|
|
175
|
+
/// out a real multi-second backoff.
|
|
176
|
+
#[doc(hidden)]
|
|
177
|
+
pub fn with_retry_base(mut self, base: Duration) -> Self {
|
|
178
|
+
self.retry_base = base;
|
|
179
|
+
self
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#[doc(hidden)]
|
|
183
|
+
pub fn with_max_upload_bytes(mut self, bytes: u64) -> Self {
|
|
184
|
+
self.max_upload_bytes = bytes;
|
|
185
|
+
self
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
pub fn metrics(&self) -> Arc<UploadMetrics> {
|
|
189
|
+
self.metrics.clone()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/// Upload one batch file, deleting it once every chunk has landed.
|
|
193
|
+
///
|
|
194
|
+
/// A `NotFound` on read is `Ok`: another task already handled this file.
|
|
195
|
+
/// The delete happens only after all chunks succeed, so a crash part-way
|
|
196
|
+
/// leaves the original intact and the worst case is re-sending chunks the
|
|
197
|
+
/// server already has — byte-identical, so it dedups them.
|
|
198
|
+
pub async fn upload_file(&self, path: &Path) -> Result<(), UploadError> {
|
|
199
|
+
let bytes = match tokio::fs::read(path).await {
|
|
200
|
+
Ok(b) => b,
|
|
201
|
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
|
202
|
+
Err(e) => return Err(UploadError::Io(e)),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
for chunk in split_lines(&bytes, self.max_upload_bytes) {
|
|
206
|
+
self.post_batch(path, chunk).await?;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
match tokio::fs::remove_file(path).await {
|
|
210
|
+
Ok(()) => Ok(()),
|
|
211
|
+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
|
212
|
+
Err(e) => Err(UploadError::Io(e)),
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// POST one body, retrying per policy. `path` is used only for parking and
|
|
217
|
+
/// log context — this never deletes it.
|
|
218
|
+
async fn post_batch(&self, path: &Path, bytes: Vec<u8>) -> Result<(), UploadError> {
|
|
219
|
+
let mut attempt: u32 = 0;
|
|
220
|
+
|
|
221
|
+
loop {
|
|
222
|
+
let result = self
|
|
223
|
+
.client
|
|
224
|
+
.post(&self.url)
|
|
225
|
+
.header("Authorization", format!("Bearer {}", self.key))
|
|
226
|
+
.header("Content-Type", "application/x-ndjson")
|
|
227
|
+
// A HEADER, not a payload field, so it stays OUT of the
|
|
228
|
+
// server's content-hash dedup key — otherwise every upgrade
|
|
229
|
+
// would make previously-shipped events look new.
|
|
230
|
+
.header("X-Failproofai-Collector-Version", env!("CARGO_PKG_VERSION"))
|
|
231
|
+
.body(bytes.clone())
|
|
232
|
+
.send()
|
|
233
|
+
.await;
|
|
234
|
+
|
|
235
|
+
attempt += 1;
|
|
236
|
+
|
|
237
|
+
match result {
|
|
238
|
+
Ok(resp) => {
|
|
239
|
+
let status = resp.status();
|
|
240
|
+
if status.is_success() {
|
|
241
|
+
// A 2xx is necessary but NOT sufficient. Require the body
|
|
242
|
+
// to actually be an ingest ack (a numeric `accepted`);
|
|
243
|
+
// otherwise this "success" is a login page or a proxy,
|
|
244
|
+
// and returning Ok here deletes the spool file behind it.
|
|
245
|
+
// Park instead, non-retryable — it will parse-fail
|
|
246
|
+
// identically until the URL is fixed.
|
|
247
|
+
match resp.json::<IngestAck>().await {
|
|
248
|
+
Ok(ack) => {
|
|
249
|
+
self.record_ack(path, &ack);
|
|
250
|
+
return Ok(());
|
|
251
|
+
}
|
|
252
|
+
Err(_) => {
|
|
253
|
+
let code = status.as_u16();
|
|
254
|
+
self.park(path, Some(code), attempt).await;
|
|
255
|
+
return Err(UploadError::Client { status: code });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let code = status.as_u16();
|
|
261
|
+
// 408 and 429 are the two 4xx that mean "try again" rather
|
|
262
|
+
// than "you are wrong". Treating them as definitive would
|
|
263
|
+
// park a batch the server explicitly asked us to resend.
|
|
264
|
+
let retryable = status.is_server_error() || code == 408 || code == 429;
|
|
265
|
+
if !retryable {
|
|
266
|
+
self.park(path, Some(code), attempt).await;
|
|
267
|
+
return Err(UploadError::Client { status: code });
|
|
268
|
+
}
|
|
269
|
+
if attempt >= self.max_retries {
|
|
270
|
+
self.park(path, None, attempt).await;
|
|
271
|
+
return Err(UploadError::Server {
|
|
272
|
+
status: code,
|
|
273
|
+
attempts: attempt,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
let wait = retry_after(&resp).unwrap_or_else(|| self.backoff(attempt));
|
|
277
|
+
tokio::time::sleep(wait).await;
|
|
278
|
+
}
|
|
279
|
+
Err(err) => {
|
|
280
|
+
if attempt >= self.max_retries {
|
|
281
|
+
self.park(path, None, attempt).await;
|
|
282
|
+
return Err(UploadError::Network {
|
|
283
|
+
attempts: attempt,
|
|
284
|
+
detail: err.to_string(),
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
tokio::time::sleep(self.backoff(attempt)).await;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/// Interpret the ack body. A 200 that stored nothing is an error, not a
|
|
294
|
+
/// success — it is the shape a systematically malformed transform takes,
|
|
295
|
+
/// and without this it looks identical to a healthy upload.
|
|
296
|
+
fn record_ack(&self, path: &Path, ack: &IngestAck) {
|
|
297
|
+
self.metrics
|
|
298
|
+
.accepted_total
|
|
299
|
+
.fetch_add(ack.accepted, Ordering::Relaxed);
|
|
300
|
+
self.metrics
|
|
301
|
+
.skipped_total
|
|
302
|
+
.fetch_add(ack.skipped, Ordering::Relaxed);
|
|
303
|
+
self.metrics
|
|
304
|
+
.last_ok_ts
|
|
305
|
+
.store(unix_now_secs(), Ordering::Relaxed);
|
|
306
|
+
|
|
307
|
+
if ack.accepted == 0 && ack.skipped > 0 {
|
|
308
|
+
self.metrics
|
|
309
|
+
.batches_fully_skipped
|
|
310
|
+
.fetch_add(1, Ordering::Relaxed);
|
|
311
|
+
tracing::error!(
|
|
312
|
+
file = %path.display(),
|
|
313
|
+
skipped = ack.skipped,
|
|
314
|
+
"the server accepted the request but stored NONE of its events; \
|
|
315
|
+
every line was rejected as malformed"
|
|
316
|
+
);
|
|
317
|
+
} else if ack.skipped > 0 {
|
|
318
|
+
tracing::warn!(
|
|
319
|
+
file = %path.display(),
|
|
320
|
+
accepted = ack.accepted,
|
|
321
|
+
skipped = ack.skipped,
|
|
322
|
+
"the server skipped some events in this batch"
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/// `base * 2^(attempt-1)` plus jitter.
|
|
328
|
+
///
|
|
329
|
+
/// The jitter matters more than the curve: without it every collector that
|
|
330
|
+
/// went down during the same outage retries in lockstep afterwards and
|
|
331
|
+
/// re-creates the outage. Derived from the clock rather than a PRNG so this
|
|
332
|
+
/// crate needs no `rand` dependency — jitter needs to be *spread*, not
|
|
333
|
+
/// unpredictable.
|
|
334
|
+
fn backoff(&self, attempt: u32) -> Duration {
|
|
335
|
+
let base = self.retry_base.as_millis() as u64;
|
|
336
|
+
let exp = base.saturating_mul(1u64 << attempt.saturating_sub(1).min(16));
|
|
337
|
+
let jitter = if base == 0 {
|
|
338
|
+
0
|
|
339
|
+
} else {
|
|
340
|
+
(nanos_now() % base as u128) as u64
|
|
341
|
+
};
|
|
342
|
+
Duration::from_millis(exp.saturating_add(jitter))
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/// Move a batch into `failed/`, encoding its retry state in the filename.
|
|
346
|
+
///
|
|
347
|
+
/// Never deletes and never overwrites: a collision gets a numeric suffix,
|
|
348
|
+
/// because the file being moved is the only copy of data the server does
|
|
349
|
+
/// not have.
|
|
350
|
+
async fn park(&self, path: &Path, client_status: Option<u16>, _attempt: u32) {
|
|
351
|
+
if let Err(err) = self.park_inner(path, client_status).await {
|
|
352
|
+
tracing::error!(
|
|
353
|
+
file = %path.display(),
|
|
354
|
+
%err,
|
|
355
|
+
"could not park a failed batch; it stays in the spool and will be retried"
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async fn park_inner(
|
|
361
|
+
&self,
|
|
362
|
+
path: &Path,
|
|
363
|
+
client_status: Option<u16>,
|
|
364
|
+
) -> Result<(), std::io::Error> {
|
|
365
|
+
tokio::fs::create_dir_all(&self.failed_dir).await?;
|
|
366
|
+
let name = path
|
|
367
|
+
.file_name()
|
|
368
|
+
.and_then(|n| n.to_str())
|
|
369
|
+
.unwrap_or("batch.jsonl");
|
|
370
|
+
|
|
371
|
+
let mut parked = ParkedName::parse(name);
|
|
372
|
+
parked.attempt += 1;
|
|
373
|
+
if client_status.is_some() {
|
|
374
|
+
parked.client_status = client_status;
|
|
375
|
+
}
|
|
376
|
+
parked.poison = parked.attempt >= self.failed_retries_max.max(1);
|
|
377
|
+
|
|
378
|
+
let dest = unique_destination(&self.failed_dir, &parked.render()).await;
|
|
379
|
+
tracing::warn!(
|
|
380
|
+
file = %path.display(),
|
|
381
|
+
dest = %dest.display(),
|
|
382
|
+
attempt = parked.attempt,
|
|
383
|
+
poison = parked.poison,
|
|
384
|
+
"parking an undelivered batch"
|
|
385
|
+
);
|
|
386
|
+
tokio::fs::rename(path, &dest).await
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/// Retry state carried in a parked batch's filename:
|
|
391
|
+
/// `<base>.a<N>[.c<STATUS>].jsonl[.poison]`.
|
|
392
|
+
///
|
|
393
|
+
/// The filename IS the database. A sidecar would need its own atomicity story
|
|
394
|
+
/// and could desynchronise from the batch it describes; a rename cannot.
|
|
395
|
+
#[derive(Debug, PartialEq, Eq)]
|
|
396
|
+
pub struct ParkedName {
|
|
397
|
+
pub base: String,
|
|
398
|
+
pub attempt: u32,
|
|
399
|
+
pub client_status: Option<u16>,
|
|
400
|
+
pub poison: bool,
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
impl ParkedName {
|
|
404
|
+
pub fn parse(name: &str) -> Self {
|
|
405
|
+
let (name, poison) = match name.strip_suffix(POISON_SUFFIX) {
|
|
406
|
+
Some(rest) => (rest, true),
|
|
407
|
+
None => (name, false),
|
|
408
|
+
};
|
|
409
|
+
let stem = name.strip_suffix(".jsonl").unwrap_or(name);
|
|
410
|
+
|
|
411
|
+
let mut base = stem.to_string();
|
|
412
|
+
let mut attempt = 0;
|
|
413
|
+
let mut client_status = None;
|
|
414
|
+
|
|
415
|
+
// Suffixes are stripped right to left: `.cNNN` then `.aN`. Parsing
|
|
416
|
+
// rather than merely matching the prefix is what keeps a real spool
|
|
417
|
+
// name safe — `claude-...-0` has no `.c`/`.a` to find, and anything
|
|
418
|
+
// that does but is not numeric is left as part of the base.
|
|
419
|
+
if let Some(idx) = base.rfind(".c")
|
|
420
|
+
&& let Ok(code) = base[idx + 2..].parse::<u16>()
|
|
421
|
+
{
|
|
422
|
+
client_status = Some(code);
|
|
423
|
+
base.truncate(idx);
|
|
424
|
+
}
|
|
425
|
+
if let Some(idx) = base.rfind(".a")
|
|
426
|
+
&& let Ok(n) = base[idx + 2..].parse::<u32>()
|
|
427
|
+
{
|
|
428
|
+
attempt = n;
|
|
429
|
+
base.truncate(idx);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
ParkedName {
|
|
433
|
+
base,
|
|
434
|
+
attempt,
|
|
435
|
+
client_status,
|
|
436
|
+
poison,
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
pub fn render(&self) -> String {
|
|
441
|
+
let mut s = format!("{}.a{}", self.base, self.attempt);
|
|
442
|
+
if let Some(code) = self.client_status {
|
|
443
|
+
s.push_str(&format!(".c{code}"));
|
|
444
|
+
}
|
|
445
|
+
s.push_str(".jsonl");
|
|
446
|
+
if self.poison {
|
|
447
|
+
s.push_str(POISON_SUFFIX);
|
|
448
|
+
}
|
|
449
|
+
s
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/// Whether an automatic retry pass should pick this up. A batch the server
|
|
453
|
+
/// definitively rejected will fail identically until the cause is fixed,
|
|
454
|
+
/// so retrying it just burns the budget of batches that could succeed.
|
|
455
|
+
pub fn is_auto_retryable(&self) -> bool {
|
|
456
|
+
!self.poison && self.client_status.is_none()
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/// Split a body on line boundaries so no chunk exceeds `max`.
|
|
461
|
+
///
|
|
462
|
+
/// A single line longer than `max` is emitted alone rather than dropped: the
|
|
463
|
+
/// spool writer already guarantees no such line exists, so reaching this means
|
|
464
|
+
/// the cap was lowered after the batch was written, and the server refusing it
|
|
465
|
+
/// is more useful than this function silently discarding it.
|
|
466
|
+
fn split_lines(bytes: &[u8], max: u64) -> Vec<Vec<u8>> {
|
|
467
|
+
if bytes.len() as u64 <= max {
|
|
468
|
+
return vec![bytes.to_vec()];
|
|
469
|
+
}
|
|
470
|
+
let mut out = Vec::new();
|
|
471
|
+
let mut cur: Vec<u8> = Vec::new();
|
|
472
|
+
for line in bytes.split_inclusive(|b| *b == b'\n') {
|
|
473
|
+
if !cur.is_empty() && (cur.len() + line.len()) as u64 > max {
|
|
474
|
+
out.push(std::mem::take(&mut cur));
|
|
475
|
+
}
|
|
476
|
+
cur.extend_from_slice(line);
|
|
477
|
+
}
|
|
478
|
+
if !cur.is_empty() {
|
|
479
|
+
out.push(cur);
|
|
480
|
+
}
|
|
481
|
+
out
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/// A `Retry-After` in seconds, capped so a bad header cannot stall delivery.
|
|
485
|
+
fn retry_after(resp: &reqwest::Response) -> Option<Duration> {
|
|
486
|
+
let raw = resp.headers().get(reqwest::header::RETRY_AFTER)?;
|
|
487
|
+
let secs: u64 = raw.to_str().ok()?.trim().parse().ok()?;
|
|
488
|
+
Some(Duration::from_secs(secs).min(MAX_RETRY_AFTER))
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/// `<dir>/<name>`, or `<name>.1`, `.2`, … if taken. A collision must never
|
|
492
|
+
/// destroy an undelivered batch.
|
|
493
|
+
async fn unique_destination(dir: &Path, name: &str) -> PathBuf {
|
|
494
|
+
let first = dir.join(name);
|
|
495
|
+
if tokio::fs::metadata(&first).await.is_err() {
|
|
496
|
+
return first;
|
|
497
|
+
}
|
|
498
|
+
for n in 1..1000 {
|
|
499
|
+
let candidate = dir.join(format!("{name}.{n}"));
|
|
500
|
+
if tokio::fs::metadata(&candidate).await.is_err() {
|
|
501
|
+
return candidate;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
dir.join(format!("{name}.{}", nanos_now()))
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
fn unix_now_secs() -> u64 {
|
|
508
|
+
SystemTime::now()
|
|
509
|
+
.duration_since(UNIX_EPOCH)
|
|
510
|
+
.map(|d| d.as_secs())
|
|
511
|
+
.unwrap_or(0)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
fn nanos_now() -> u128 {
|
|
515
|
+
SystemTime::now()
|
|
516
|
+
.duration_since(UNIX_EPOCH)
|
|
517
|
+
.map(|d| d.as_nanos())
|
|
518
|
+
.unwrap_or(0)
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
#[cfg(test)]
|
|
522
|
+
mod tests {
|
|
523
|
+
use super::*;
|
|
524
|
+
|
|
525
|
+
#[test]
|
|
526
|
+
fn parked_names_round_trip() {
|
|
527
|
+
let p = ParkedName {
|
|
528
|
+
base: "hooks-s1-123-0".into(),
|
|
529
|
+
attempt: 2,
|
|
530
|
+
client_status: Some(404),
|
|
531
|
+
poison: false,
|
|
532
|
+
};
|
|
533
|
+
assert_eq!(p.render(), "hooks-s1-123-0.a2.c404.jsonl");
|
|
534
|
+
assert_eq!(ParkedName::parse(&p.render()), p);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
#[test]
|
|
538
|
+
fn a_poison_name_round_trips_and_is_not_jsonl() {
|
|
539
|
+
let p = ParkedName {
|
|
540
|
+
base: "claude-s-1-0".into(),
|
|
541
|
+
attempt: 3,
|
|
542
|
+
client_status: None,
|
|
543
|
+
poison: true,
|
|
544
|
+
};
|
|
545
|
+
let rendered = p.render();
|
|
546
|
+
assert_eq!(rendered, "claude-s-1-0.a3.jsonl.poison");
|
|
547
|
+
// The whole point of the suffix ordering: a poison file is not a
|
|
548
|
+
// `.jsonl`, so every scan skips it without a dedicated filter.
|
|
549
|
+
assert!(!crate::spool::is_batch_file(Path::new(&rendered)));
|
|
550
|
+
assert_eq!(ParkedName::parse(&rendered), p);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
#[test]
|
|
554
|
+
fn a_fresh_batch_carries_no_retry_state() {
|
|
555
|
+
// Spool filenames are `<prefix>-<tag>-<run>-<seq>.jsonl` and the tag is
|
|
556
|
+
// sanitised to alphanumerics and `-`, so a real name can never parse as
|
|
557
|
+
// one that has already been retried.
|
|
558
|
+
let p = ParkedName::parse("claude-3ee9c788-1785741108180149712-0.jsonl");
|
|
559
|
+
assert_eq!(p.attempt, 0);
|
|
560
|
+
assert_eq!(p.client_status, None);
|
|
561
|
+
assert!(!p.poison);
|
|
562
|
+
assert_eq!(p.base, "claude-3ee9c788-1785741108180149712-0");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
#[test]
|
|
566
|
+
fn only_batches_with_no_definitive_rejection_are_auto_retried() {
|
|
567
|
+
let park = |n: &str| ParkedName::parse(n);
|
|
568
|
+
assert!(park("a.a1.jsonl").is_auto_retryable());
|
|
569
|
+
// A 4xx will fail identically until the key or URL is fixed.
|
|
570
|
+
assert!(!park("a.a1.c401.jsonl").is_auto_retryable());
|
|
571
|
+
assert!(!park("a.a3.jsonl.poison").is_auto_retryable());
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
#[test]
|
|
575
|
+
fn splitting_respects_line_boundaries_and_the_cap() {
|
|
576
|
+
let body = b"aaaa\nbbbb\ncccc\ndddd\n";
|
|
577
|
+
let chunks = split_lines(body, 10);
|
|
578
|
+
assert!(chunks.len() > 1);
|
|
579
|
+
for c in &chunks {
|
|
580
|
+
assert!(c.len() <= 10, "chunk of {} bytes exceeds cap", c.len());
|
|
581
|
+
assert!(c.ends_with(b"\n"), "a chunk must not split a line");
|
|
582
|
+
}
|
|
583
|
+
let rejoined: Vec<u8> = chunks.concat();
|
|
584
|
+
assert_eq!(rejoined, body, "splitting must lose nothing");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
#[test]
|
|
588
|
+
fn a_body_under_the_cap_is_one_chunk() {
|
|
589
|
+
let body = b"one\ntwo\n";
|
|
590
|
+
assert_eq!(split_lines(body, 1024).len(), 1);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
#[test]
|
|
594
|
+
fn an_oversized_single_line_is_emitted_rather_than_dropped() {
|
|
595
|
+
let body = b"aaaaaaaaaaaaaaaaaaaaaaaa\n";
|
|
596
|
+
let chunks = split_lines(body, 4);
|
|
597
|
+
assert_eq!(chunks.len(), 1);
|
|
598
|
+
assert_eq!(chunks[0], body);
|
|
599
|
+
}
|
|
600
|
+
}
|