failproofai 1.0.0-beta.2 → 1.0.0-beta.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.
- 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.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.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.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.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.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.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/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- 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]__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]__0qucxyj._.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]__1psjybo._.js → [root-of-the-server]__10s_z4l._.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]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- 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_0jfts82.js} +1 -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/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
- package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- 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 +2 -2
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2195 -770
- package/dist/worker.mjs +365 -52
- package/package.json +2 -2
- 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/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/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
//! Durable hand-off between a source and the uploader.
|
|
2
|
+
//!
|
|
3
|
+
//! A source transforms records into events and pushes them here; this writes
|
|
4
|
+
//! them as `<prefix>-<tag>-<run>-<seq>.jsonl` batches into the spool directory,
|
|
5
|
+
//! where the watcher and sweeper pick them up. The agent's own files and
|
|
6
|
+
//! databases are never handed to the uploader, so they are never touched.
|
|
7
|
+
//!
|
|
8
|
+
//! # Three properties that are load-bearing
|
|
9
|
+
//!
|
|
10
|
+
//! **Writes are atomic.** Every batch is written to `.tmp`, fsynced, then
|
|
11
|
+
//! renamed into place. A reader therefore never observes a partial file — and
|
|
12
|
+
//! `.tmp` is not `.jsonl`, so the watcher skips it without needing to know
|
|
13
|
+
//! this. Combined with "flush before advancing the cursor", a crash costs at
|
|
14
|
+
//! most a re-ship, never a lost event.
|
|
15
|
+
//!
|
|
16
|
+
//! **No single line may exceed the batch cap.** A line larger than one request
|
|
17
|
+
//! could never be delivered: it would fail, be parked, be retried at the same
|
|
18
|
+
//! size, and fail identically forever. So oversized events are first shrunk by
|
|
19
|
+
//! truncating their long string fields, and dropped with a warning if they are
|
|
20
|
+
//! still too big. Dropping is the honest outcome — the record remains in the
|
|
21
|
+
//! agent's own store, so nothing is lost from the source, whereas parking it
|
|
22
|
+
//! would quietly accumulate undeliverable files.
|
|
23
|
+
//!
|
|
24
|
+
//! **Truncation is deterministic.** The server dedups on a content hash, so
|
|
25
|
+
//! the same input must always produce the same bytes. That rules out anything
|
|
26
|
+
//! involving time, randomness or a sampled decision, and is why truncation
|
|
27
|
+
//! marks the original length rather than, say, the moment it was cut.
|
|
28
|
+
|
|
29
|
+
use std::path::{Path, PathBuf};
|
|
30
|
+
use std::time::{SystemTime, UNIX_EPOCH};
|
|
31
|
+
|
|
32
|
+
use serde_json::Value;
|
|
33
|
+
use tokio::io::AsyncWriteExt;
|
|
34
|
+
|
|
35
|
+
use crate::config::Redact;
|
|
36
|
+
|
|
37
|
+
/// Longest single string field kept intact. One oversized tool output must not
|
|
38
|
+
/// be able to push a whole batch past the body cap.
|
|
39
|
+
const MAX_FIELD_BYTES: usize = 1024 * 1024;
|
|
40
|
+
|
|
41
|
+
/// Floor for the batch cap. A cap below this would roll a new file per event.
|
|
42
|
+
const MIN_BATCH_BYTES: u64 = 64 * 1024;
|
|
43
|
+
|
|
44
|
+
/// Default batch cap, matching the ingest endpoint's body limit.
|
|
45
|
+
pub const DEFAULT_MAX_BATCH_BYTES: u64 = 8 * 1024 * 1024;
|
|
46
|
+
|
|
47
|
+
/// Accumulates events and writes them out as size-bounded batches.
|
|
48
|
+
pub struct SpoolWriter {
|
|
49
|
+
dir: PathBuf,
|
|
50
|
+
max_batch_bytes: u64,
|
|
51
|
+
prefix: String,
|
|
52
|
+
tag: String,
|
|
53
|
+
run_id: u128,
|
|
54
|
+
seq: u64,
|
|
55
|
+
buf: String,
|
|
56
|
+
buf_bytes: u64,
|
|
57
|
+
redact: Redact,
|
|
58
|
+
redacted: u64,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
impl SpoolWriter {
|
|
62
|
+
/// `prefix` is the source kind (`claude`, `hooks`, …) and `tag` a short
|
|
63
|
+
/// session identifier; together they make a derived batch recognisable in
|
|
64
|
+
/// a shared spool directory.
|
|
65
|
+
///
|
|
66
|
+
/// `run_id` is nanoseconds since the epoch, which distinguishes files
|
|
67
|
+
/// across restarts. Without it a fresh run would reuse `seq` 0 and could
|
|
68
|
+
/// clobber a batch the previous run had written but not yet uploaded.
|
|
69
|
+
pub fn new(dir: PathBuf, max_batch_bytes: u64, prefix: &str, tag: &str) -> Self {
|
|
70
|
+
let run_id = SystemTime::now()
|
|
71
|
+
.duration_since(UNIX_EPOCH)
|
|
72
|
+
.map(|d| d.as_nanos())
|
|
73
|
+
.unwrap_or(0);
|
|
74
|
+
SpoolWriter {
|
|
75
|
+
dir,
|
|
76
|
+
max_batch_bytes: max_batch_bytes.max(MIN_BATCH_BYTES),
|
|
77
|
+
prefix: sanitize_tag(prefix),
|
|
78
|
+
tag: sanitize_tag(tag),
|
|
79
|
+
run_id,
|
|
80
|
+
seq: 0,
|
|
81
|
+
buf: String::new(),
|
|
82
|
+
buf_bytes: 0,
|
|
83
|
+
// Default ON. A source that forgets to opt in still gets scrubbed,
|
|
84
|
+
// which is the safe direction for the mistake to fall.
|
|
85
|
+
redact: Redact::Minimal,
|
|
86
|
+
redacted: 0,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Override the redaction mode. Only `Redact::Off` changes anything —
|
|
91
|
+
/// scrubbing is on by default so a new source cannot ship secrets by
|
|
92
|
+
/// omission.
|
|
93
|
+
pub fn with_redact(mut self, mode: Redact) -> Self {
|
|
94
|
+
self.redact = mode;
|
|
95
|
+
self
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// How many string leaves have been scrubbed. Reported without ever
|
|
99
|
+
/// logging what was removed.
|
|
100
|
+
pub fn redacted_count(&self) -> u64 {
|
|
101
|
+
self.redacted
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/// Events buffered but not yet written. Used by callers that must flush
|
|
105
|
+
/// before advancing a cursor.
|
|
106
|
+
pub fn pending_bytes(&self) -> u64 {
|
|
107
|
+
self.buf_bytes
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/// Append one event, rolling to a new batch first if it would not fit.
|
|
111
|
+
pub async fn push(&mut self, mut event: Value) -> std::io::Result<()> {
|
|
112
|
+
// The single choke point every event passes through, which is why
|
|
113
|
+
// redaction happens here rather than in each transform: a source
|
|
114
|
+
// cannot forget it, and a new source inherits it for free. It runs
|
|
115
|
+
// BEFORE serialization, so what gets hashed, spooled and uploaded is
|
|
116
|
+
// the scrubbed form — there is no window where the raw value exists on
|
|
117
|
+
// disk.
|
|
118
|
+
self.redacted += crate::redact::scrub_value(&mut event, self.redact) as u64;
|
|
119
|
+
|
|
120
|
+
let mut line = serialize(&event)?;
|
|
121
|
+
|
|
122
|
+
if line.len() as u64 > self.max_batch_bytes {
|
|
123
|
+
truncate_strings(&mut event, MAX_FIELD_BYTES);
|
|
124
|
+
line = serialize(&event)?;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if line.len() as u64 > self.max_batch_bytes {
|
|
128
|
+
// Still over cap after truncation — thousands of small fields, say.
|
|
129
|
+
// Shipping it would produce a batch the server refuses, which then
|
|
130
|
+
// parks and retries forever at the same size.
|
|
131
|
+
let event_type = event.get("type").and_then(|v| v.as_str()).unwrap_or("?");
|
|
132
|
+
tracing::warn!(
|
|
133
|
+
source = %self.prefix,
|
|
134
|
+
bytes = line.len(),
|
|
135
|
+
max = self.max_batch_bytes,
|
|
136
|
+
event_type,
|
|
137
|
+
"dropping an event that exceeds the batch cap even after truncation; \
|
|
138
|
+
it remains in the agent's own store"
|
|
139
|
+
);
|
|
140
|
+
return Ok(());
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let needed = line.len() as u64 + 1; // +1 for the newline
|
|
144
|
+
if !self.buf.is_empty() && self.buf_bytes + needed > self.max_batch_bytes {
|
|
145
|
+
self.flush().await?;
|
|
146
|
+
}
|
|
147
|
+
self.buf.push_str(&line);
|
|
148
|
+
self.buf.push('\n');
|
|
149
|
+
self.buf_bytes += needed;
|
|
150
|
+
Ok(())
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Durably write the buffered events as one batch. No-op when empty.
|
|
154
|
+
///
|
|
155
|
+
/// Callers MUST await this before advancing any persisted cursor. The
|
|
156
|
+
/// ordering is the whole durability argument: flush-then-advance costs a
|
|
157
|
+
/// re-ship on crash (which the server dedups), advance-then-flush loses
|
|
158
|
+
/// the events outright.
|
|
159
|
+
pub async fn flush(&mut self) -> std::io::Result<()> {
|
|
160
|
+
if self.buf.is_empty() {
|
|
161
|
+
return Ok(());
|
|
162
|
+
}
|
|
163
|
+
tokio::fs::create_dir_all(&self.dir).await?;
|
|
164
|
+
|
|
165
|
+
let stem = format!("{}-{}-{}-{}", self.prefix, self.tag, self.run_id, self.seq);
|
|
166
|
+
let tmp = self.dir.join(format!("{stem}.tmp"));
|
|
167
|
+
let final_path = self.dir.join(format!("{stem}.jsonl"));
|
|
168
|
+
|
|
169
|
+
let mut f = tokio::fs::File::create(&tmp).await?;
|
|
170
|
+
f.write_all(self.buf.as_bytes()).await?;
|
|
171
|
+
// fsync before rename: a rename is atomic with respect to readers, but
|
|
172
|
+
// it does not guarantee the CONTENT reached disk. Without this, a
|
|
173
|
+
// power loss can leave a correctly-named, zero-length batch — which
|
|
174
|
+
// the uploader would happily deliver as an empty file.
|
|
175
|
+
f.sync_all().await?;
|
|
176
|
+
drop(f);
|
|
177
|
+
tokio::fs::rename(&tmp, &final_path).await?;
|
|
178
|
+
|
|
179
|
+
self.seq += 1;
|
|
180
|
+
self.buf.clear();
|
|
181
|
+
self.buf_bytes = 0;
|
|
182
|
+
Ok(())
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
fn serialize(event: &Value) -> std::io::Result<String> {
|
|
187
|
+
serde_json::to_string(event).map_err(std::io::Error::other)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/// Keep a tag filename-safe and bounded.
|
|
191
|
+
///
|
|
192
|
+
/// The charset matters beyond hygiene: batch filenames encode retry state as
|
|
193
|
+
/// suffixes, so a tag containing a `.` could make a fresh batch parse as one
|
|
194
|
+
/// that had already been retried.
|
|
195
|
+
fn sanitize_tag(tag: &str) -> String {
|
|
196
|
+
let cleaned: String = tag
|
|
197
|
+
.chars()
|
|
198
|
+
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
|
|
199
|
+
.take(16)
|
|
200
|
+
.collect();
|
|
201
|
+
if cleaned.is_empty() {
|
|
202
|
+
"session".to_string()
|
|
203
|
+
} else {
|
|
204
|
+
cleaned
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// Recursively truncate long string leaves to `cap` bytes at a char boundary.
|
|
209
|
+
///
|
|
210
|
+
/// Deterministic by construction: the marker records the ORIGINAL length, so
|
|
211
|
+
/// the same input always yields identical bytes. The truncated payload feeds
|
|
212
|
+
/// the server's dedup hash, so a marker containing a timestamp or a counter
|
|
213
|
+
/// would make every re-read of the same event look like a new one.
|
|
214
|
+
fn truncate_strings(v: &mut Value, cap: usize) {
|
|
215
|
+
match v {
|
|
216
|
+
Value::String(s) if s.len() > cap => {
|
|
217
|
+
let orig = s.len();
|
|
218
|
+
let mut end = cap;
|
|
219
|
+
while end > 0 && !s.is_char_boundary(end) {
|
|
220
|
+
end -= 1;
|
|
221
|
+
}
|
|
222
|
+
let mut truncated = s[..end].to_string();
|
|
223
|
+
truncated.push_str(&format!("…[truncated {orig} bytes]"));
|
|
224
|
+
*s = truncated;
|
|
225
|
+
}
|
|
226
|
+
Value::Array(a) => a.iter_mut().for_each(|e| truncate_strings(e, cap)),
|
|
227
|
+
Value::Object(o) => o.values_mut().for_each(|e| truncate_strings(e, cap)),
|
|
228
|
+
_ => {}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/// True if `path` is a batch the uploader should pick up.
|
|
233
|
+
///
|
|
234
|
+
/// `.tmp` files are excluded by the extension check rather than by a separate
|
|
235
|
+
/// rule, which is exactly why the atomic write works: a batch is invisible
|
|
236
|
+
/// until the instant it is renamed.
|
|
237
|
+
pub fn is_batch_file(path: &Path) -> bool {
|
|
238
|
+
path.extension().and_then(|e| e.to_str()) == Some("jsonl")
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#[cfg(test)]
|
|
242
|
+
mod tests {
|
|
243
|
+
use super::*;
|
|
244
|
+
use serde_json::json;
|
|
245
|
+
|
|
246
|
+
fn tmpdir(name: &str) -> PathBuf {
|
|
247
|
+
let d = std::env::temp_dir().join(format!(
|
|
248
|
+
"fpai-spool-{}-{}-{}",
|
|
249
|
+
name,
|
|
250
|
+
std::process::id(),
|
|
251
|
+
SystemTime::now()
|
|
252
|
+
.duration_since(UNIX_EPOCH)
|
|
253
|
+
.unwrap()
|
|
254
|
+
.as_nanos()
|
|
255
|
+
));
|
|
256
|
+
std::fs::create_dir_all(&d).unwrap();
|
|
257
|
+
d
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
fn batches(dir: &Path) -> Vec<String> {
|
|
261
|
+
let mut v: Vec<String> = std::fs::read_dir(dir)
|
|
262
|
+
.unwrap()
|
|
263
|
+
.filter_map(|e| e.ok())
|
|
264
|
+
.map(|e| e.file_name().to_string_lossy().to_string())
|
|
265
|
+
.filter(|n| n.ends_with(".jsonl"))
|
|
266
|
+
.collect();
|
|
267
|
+
v.sort();
|
|
268
|
+
v
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
#[tokio::test]
|
|
272
|
+
async fn writes_one_batch_and_leaves_no_tmp_behind() {
|
|
273
|
+
let dir = tmpdir("basic");
|
|
274
|
+
let mut w = SpoolWriter::new(dir.clone(), DEFAULT_MAX_BATCH_BYTES, "hooks", "sess1");
|
|
275
|
+
w.push(json!({"type": "hook_triggered", "n": 1}))
|
|
276
|
+
.await
|
|
277
|
+
.unwrap();
|
|
278
|
+
w.push(json!({"type": "hook_completed", "n": 1}))
|
|
279
|
+
.await
|
|
280
|
+
.unwrap();
|
|
281
|
+
w.flush().await.unwrap();
|
|
282
|
+
|
|
283
|
+
let files = batches(&dir);
|
|
284
|
+
assert_eq!(files.len(), 1, "expected one batch, got {files:?}");
|
|
285
|
+
assert!(files[0].starts_with("hooks-sess1-"));
|
|
286
|
+
|
|
287
|
+
let leftover_tmp = std::fs::read_dir(&dir)
|
|
288
|
+
.unwrap()
|
|
289
|
+
.filter_map(|e| e.ok())
|
|
290
|
+
.any(|e| e.file_name().to_string_lossy().ends_with(".tmp"));
|
|
291
|
+
assert!(!leftover_tmp, "a .tmp file survived the rename");
|
|
292
|
+
|
|
293
|
+
let body = std::fs::read_to_string(dir.join(&files[0])).unwrap();
|
|
294
|
+
assert_eq!(body.lines().count(), 2);
|
|
295
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
#[tokio::test]
|
|
299
|
+
async fn rolls_to_a_new_batch_at_the_size_cap() {
|
|
300
|
+
let dir = tmpdir("roll");
|
|
301
|
+
let mut w = SpoolWriter::new(dir.clone(), MIN_BATCH_BYTES, "claude", "s");
|
|
302
|
+
for i in 0..100 {
|
|
303
|
+
w.push(json!({"type": "tool_use", "n": i, "pad": "x".repeat(2000)}))
|
|
304
|
+
.await
|
|
305
|
+
.unwrap();
|
|
306
|
+
}
|
|
307
|
+
w.flush().await.unwrap();
|
|
308
|
+
assert!(
|
|
309
|
+
batches(&dir).len() > 1,
|
|
310
|
+
"200KB of events under a 64KB cap must roll into several batches"
|
|
311
|
+
);
|
|
312
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
#[tokio::test]
|
|
316
|
+
async fn no_written_line_ever_exceeds_the_batch_cap() {
|
|
317
|
+
// The invariant the uploader depends on: a line bigger than one
|
|
318
|
+
// request can never be delivered, so it must never reach the spool.
|
|
319
|
+
let dir = tmpdir("cap");
|
|
320
|
+
let mut w = SpoolWriter::new(dir.clone(), MIN_BATCH_BYTES, "claude", "s");
|
|
321
|
+
w.push(json!({"type": "tool_result", "output": "y".repeat(3 * 1024 * 1024)}))
|
|
322
|
+
.await
|
|
323
|
+
.unwrap();
|
|
324
|
+
w.flush().await.unwrap();
|
|
325
|
+
|
|
326
|
+
for f in batches(&dir) {
|
|
327
|
+
let body = std::fs::read_to_string(dir.join(&f)).unwrap();
|
|
328
|
+
for line in body.lines() {
|
|
329
|
+
assert!(
|
|
330
|
+
line.len() as u64 <= MIN_BATCH_BYTES,
|
|
331
|
+
"a {}-byte line was written under a {MIN_BATCH_BYTES}-byte cap",
|
|
332
|
+
line.len()
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
#[tokio::test]
|
|
340
|
+
async fn drops_an_event_that_is_still_over_cap_after_truncation() {
|
|
341
|
+
// Many small fields: no single field is big enough for truncation to
|
|
342
|
+
// shrink, so the whole line stays over cap and must be dropped rather
|
|
343
|
+
// than written into a batch that could never be delivered.
|
|
344
|
+
let dir = tmpdir("drop");
|
|
345
|
+
let mut w = SpoolWriter::new(dir.clone(), MIN_BATCH_BYTES, "claude", "s");
|
|
346
|
+
let mut obj = serde_json::Map::new();
|
|
347
|
+
obj.insert("type".into(), json!("tool_result"));
|
|
348
|
+
for i in 0..2000 {
|
|
349
|
+
obj.insert(format!("f{i}"), json!("x".repeat(64)));
|
|
350
|
+
}
|
|
351
|
+
w.push(Value::Object(obj)).await.unwrap();
|
|
352
|
+
w.flush().await.unwrap();
|
|
353
|
+
|
|
354
|
+
assert!(
|
|
355
|
+
batches(&dir).is_empty(),
|
|
356
|
+
"an undeliverable event must be dropped, never spooled"
|
|
357
|
+
);
|
|
358
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#[test]
|
|
362
|
+
fn truncation_is_deterministic() {
|
|
363
|
+
// Non-negotiable: the server dedups on a content hash, so a re-read of
|
|
364
|
+
// the same source bytes must produce identical output.
|
|
365
|
+
let big = "a".repeat(3 * 1024 * 1024);
|
|
366
|
+
let mut a = json!({"output": big.clone()});
|
|
367
|
+
let mut b = json!({"output": big});
|
|
368
|
+
truncate_strings(&mut a, MAX_FIELD_BYTES);
|
|
369
|
+
truncate_strings(&mut b, MAX_FIELD_BYTES);
|
|
370
|
+
assert_eq!(a, b);
|
|
371
|
+
let s = a["output"].as_str().unwrap();
|
|
372
|
+
assert!(s.contains("truncated"));
|
|
373
|
+
assert!(s.len() < 2 * 1024 * 1024);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#[test]
|
|
377
|
+
fn truncation_cuts_on_a_char_boundary() {
|
|
378
|
+
// Cutting mid-codepoint would panic on the slice, or emit invalid
|
|
379
|
+
// UTF-8 that the server rejects for the whole batch.
|
|
380
|
+
let mut v = json!({ "output": "é".repeat(200) });
|
|
381
|
+
truncate_strings(&mut v, 101); // lands mid-codepoint: 'é' is 2 bytes
|
|
382
|
+
assert!(v["output"].as_str().unwrap().contains("truncated"));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#[test]
|
|
386
|
+
fn a_tag_cannot_smuggle_retry_state_into_a_filename() {
|
|
387
|
+
// Retry state is encoded as filename suffixes, so a tag containing a
|
|
388
|
+
// dot could make a brand-new batch parse as an already-retried one.
|
|
389
|
+
assert_eq!(sanitize_tag("sess.a1.c404"), "sessa1c404");
|
|
390
|
+
assert_eq!(sanitize_tag("../../etc/passwd"), "etcpasswd");
|
|
391
|
+
assert_eq!(sanitize_tag(""), "session");
|
|
392
|
+
assert_eq!(sanitize_tag("!!!"), "session");
|
|
393
|
+
assert_eq!(sanitize_tag(&"a".repeat(64)).len(), 16);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#[tokio::test]
|
|
397
|
+
async fn flushing_an_empty_writer_creates_nothing() {
|
|
398
|
+
let dir = tmpdir("empty");
|
|
399
|
+
let mut w = SpoolWriter::new(dir.clone(), DEFAULT_MAX_BATCH_BYTES, "hooks", "s");
|
|
400
|
+
w.flush().await.unwrap();
|
|
401
|
+
assert!(batches(&dir).is_empty());
|
|
402
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#[tokio::test]
|
|
406
|
+
async fn secrets_are_scrubbed_before_anything_touches_disk() {
|
|
407
|
+
// The choke-point guarantee: a source that never thinks about
|
|
408
|
+
// redaction still cannot write a credential into the spool.
|
|
409
|
+
let dir = tmpdir("redact");
|
|
410
|
+
let mut w = SpoolWriter::new(dir.clone(), DEFAULT_MAX_BATCH_BYTES, "claude", "s");
|
|
411
|
+
w.push(json!({
|
|
412
|
+
"type": "tool_use",
|
|
413
|
+
"input": {"command": "curl -H 'Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz0123'"}
|
|
414
|
+
}))
|
|
415
|
+
.await
|
|
416
|
+
.unwrap();
|
|
417
|
+
w.flush().await.unwrap();
|
|
418
|
+
|
|
419
|
+
let name = batches(&dir).remove(0);
|
|
420
|
+
let body = std::fs::read_to_string(dir.join(&name)).unwrap();
|
|
421
|
+
assert!(
|
|
422
|
+
!body.contains("ghp_abcdefghij"),
|
|
423
|
+
"a credential reached disk: {body}"
|
|
424
|
+
);
|
|
425
|
+
assert!(
|
|
426
|
+
body.contains("[redacted:"),
|
|
427
|
+
"redaction marker missing: {body}"
|
|
428
|
+
);
|
|
429
|
+
assert!(w.redacted_count() >= 1);
|
|
430
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#[tokio::test]
|
|
434
|
+
async fn redaction_can_be_turned_off() {
|
|
435
|
+
let dir = tmpdir("redact-off");
|
|
436
|
+
let mut w = SpoolWriter::new(dir.clone(), DEFAULT_MAX_BATCH_BYTES, "claude", "s")
|
|
437
|
+
.with_redact(Redact::Off);
|
|
438
|
+
w.push(json!({"output": "ghp_abcdefghijklmnopqrstuvwxyz0123"}))
|
|
439
|
+
.await
|
|
440
|
+
.unwrap();
|
|
441
|
+
w.flush().await.unwrap();
|
|
442
|
+
let name = batches(&dir).remove(0);
|
|
443
|
+
assert!(
|
|
444
|
+
std::fs::read_to_string(dir.join(&name))
|
|
445
|
+
.unwrap()
|
|
446
|
+
.contains("ghp_abcdefghij")
|
|
447
|
+
);
|
|
448
|
+
std::fs::remove_dir_all(&dir).ok();
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
#[test]
|
|
452
|
+
fn only_jsonl_is_a_batch() {
|
|
453
|
+
assert!(is_batch_file(Path::new("/s/hooks-a-1-0.jsonl")));
|
|
454
|
+
// The atomic-write contract depends on this exclusion.
|
|
455
|
+
assert!(!is_batch_file(Path::new("/s/hooks-a-1-0.tmp")));
|
|
456
|
+
assert!(!is_batch_file(Path::new("/s/hooks-a-1-0.jsonl.poison")));
|
|
457
|
+
}
|
|
458
|
+
}
|