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.
Files changed (196) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  46. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  47. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  48. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  54. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  55. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  58. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  67. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  68. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  75. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  90. 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
  91. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  93. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  94. package/.next/standalone/.next/server/pages/404.html +1 -1
  95. package/.next/standalone/.next/server/pages/500.html +1 -1
  96. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  97. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  98. package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  104. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  109. package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
  110. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  111. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  112. package/.next/standalone/Cargo.lock +1992 -198
  113. package/.next/standalone/Cargo.toml +1 -1
  114. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  115. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  116. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  117. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  118. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  119. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  120. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  121. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  122. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  123. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  124. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  125. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  126. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  127. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  128. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  129. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  130. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  131. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  132. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  133. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  134. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  135. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  136. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  137. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  138. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  139. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  140. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  141. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  142. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  143. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  144. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  145. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  146. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  147. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  148. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  149. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  150. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  151. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  152. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  153. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  154. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  155. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  156. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  157. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  158. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  159. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  160. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  161. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  162. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  163. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  164. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  165. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  166. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  167. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  168. package/.next/standalone/package.json +2 -2
  169. package/.next/standalone/server.js +1 -1
  170. package/bin/failproofai.mjs +129 -0
  171. package/dist/cli.mjs +2195 -770
  172. package/dist/worker.mjs +365 -52
  173. package/package.json +2 -2
  174. package/src/hooks/builtin-policies.ts +139 -0
  175. package/src/hooks/cloud-connection.ts +205 -0
  176. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  177. package/src/hooks/cloud-enrollment.ts +173 -0
  178. package/src/hooks/cloud-managed-policies.ts +138 -0
  179. package/src/hooks/collector-config.ts +226 -0
  180. package/src/hooks/configure-wizard.ts +221 -5
  181. package/src/hooks/custom-hooks-loader.ts +69 -25
  182. package/src/hooks/daemon-client.ts +18 -0
  183. package/src/hooks/handler.ts +144 -6
  184. package/src/hooks/hook-activity-store.ts +50 -0
  185. package/src/hooks/loader-utils.ts +6 -4
  186. package/src/hooks/policy-types.ts +35 -0
  187. package/src/hooks/session-pause-cli.ts +157 -0
  188. package/src/hooks/session-pause.ts +236 -0
  189. package/src/hooks/tui.ts +115 -0
  190. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  191. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  192. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  193. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  194. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
  195. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
  196. /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
+ }