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,369 @@
1
+ //! Per-source liveness, written to `~/.failproofai/collector-health.json`.
2
+ //!
3
+ //! # Why per-source, and not one flag
4
+ //!
5
+ //! With a dozen sources, a single "collector ok" bit is worse than useless: a
6
+ //! source whose root vanished, whose cursor desynced, or whose vendor changed
7
+ //! its format goes quiet, and quiet is exactly what a healthy idle source looks
8
+ //! like. The failure that motivates this is not hypothetical — a collector that
9
+ //! captures nothing reads identically to one capturing everything, and the only
10
+ //! way anyone notices is that a dashboard is emptier than expected weeks later.
11
+ //!
12
+ //! So every source reports three things it cannot fake: whether its root exists,
13
+ //! when it last produced an event, and what went wrong most recently. A reader
14
+ //! comparing "root present, zero events, no errors" against "root absent" can
15
+ //! tell a broken source from an unused one.
16
+ //!
17
+ //! # Deliberately not a health CHECK
18
+ //!
19
+ //! This records facts and does not decide whether they are good. "No events for
20
+ //! six hours" is an outage on a busy machine and completely normal on a laptop
21
+ //! that was shut. Whoever consumes this has the context to judge; the daemon
22
+ //! does not, and a threshold guessed here would produce alerts nobody trusts.
23
+
24
+ use std::collections::BTreeMap;
25
+ use std::path::{Path, PathBuf};
26
+ use std::sync::Mutex;
27
+ use std::sync::atomic::{AtomicU64, Ordering};
28
+
29
+ use serde::{Deserialize, Serialize};
30
+
31
+ /// Longest error message kept. An error is a diagnostic, not a log — a
32
+ /// multi-megabyte parse failure would make this file unreadable and could push
33
+ /// a credential-bearing string into a file the daemon writes.
34
+ const MAX_ERROR_LEN: usize = 500;
35
+
36
+ /// What one source reports about itself.
37
+ #[derive(Debug, Default, Clone, Deserialize, Serialize)]
38
+ pub struct SourceHealth {
39
+ /// Whether the directory or database this source reads exists at all.
40
+ /// Distinguishes "this agent is not installed" from "this agent is broken".
41
+ pub root_present: bool,
42
+ /// Unix seconds of the last event this source produced. Zero means none
43
+ /// since the daemon started.
44
+ pub last_event_ts: u64,
45
+ /// Events produced since the daemon started.
46
+ pub events: u64,
47
+ /// How far through its input the source is, in whatever unit it uses
48
+ /// (bytes for a file tailer, rows for a database poller).
49
+ pub cursor: u64,
50
+ /// Most recent error, truncated. `None` once a poll succeeds again, so a
51
+ /// transient failure does not look permanent.
52
+ pub last_error: Option<String>,
53
+ /// Total failed polls. Kept even after `last_error` clears, so a source
54
+ /// that fails intermittently is distinguishable from one that never has.
55
+ pub errors: u64,
56
+ }
57
+
58
+ /// The whole record, as written.
59
+ #[derive(Debug, Default, Deserialize, Serialize)]
60
+ pub struct HealthFile {
61
+ /// When this snapshot was written, so a reader can tell a stale file from a
62
+ /// current one — a daemon that died leaves its last record behind.
63
+ pub ts: u64,
64
+ pub sources: BTreeMap<String, SourceHealth>,
65
+ }
66
+
67
+ /// Shared, cheap-to-update health state.
68
+ ///
69
+ /// A `Mutex` rather than per-source atomics because the map is written whole:
70
+ /// contention is a handful of updates a second across a dozen sources, and the
71
+ /// alternative is a lock-free structure nobody needs.
72
+ #[derive(Debug, Default)]
73
+ pub struct Health {
74
+ sources: Mutex<BTreeMap<String, SourceHealth>>,
75
+ writes: AtomicU64,
76
+ }
77
+
78
+ impl Health {
79
+ pub fn new() -> Self {
80
+ Self::default()
81
+ }
82
+
83
+ /// Record that a source polled successfully.
84
+ ///
85
+ /// Clears `last_error` on purpose: a source that recovered should not keep
86
+ /// showing a fault from an hour ago, and the `errors` counter preserves the
87
+ /// fact that it happened.
88
+ pub fn record_poll(&self, source: &str, root_present: bool, events: u64, cursor: u64) {
89
+ let Ok(mut map) = self.sources.lock() else {
90
+ return; // poisoned: health is never worth propagating a panic
91
+ };
92
+ let entry = map.entry(source.to_string()).or_default();
93
+ entry.root_present = root_present;
94
+ entry.cursor = cursor;
95
+ entry.last_error = None;
96
+ if events > 0 {
97
+ entry.events += events;
98
+ entry.last_event_ts = now_secs();
99
+ }
100
+ }
101
+
102
+ /// Record that a source's poll failed.
103
+ pub fn record_error(&self, source: &str, error: &str) {
104
+ let Ok(mut map) = self.sources.lock() else {
105
+ return;
106
+ };
107
+ let entry = map.entry(source.to_string()).or_default();
108
+ entry.errors += 1;
109
+ entry.last_error = Some(truncate(error, MAX_ERROR_LEN));
110
+ }
111
+
112
+ /// Snapshot for writing.
113
+ pub fn snapshot(&self) -> HealthFile {
114
+ let sources = self.sources.lock().map(|m| m.clone()).unwrap_or_default();
115
+ HealthFile {
116
+ ts: now_secs(),
117
+ sources,
118
+ }
119
+ }
120
+
121
+ /// Write the record atomically (tmp → rename).
122
+ ///
123
+ /// No fsync: this is a heartbeat, not data. Losing the last few seconds of
124
+ /// it on a power cut costs nothing, and paying an fsync every interval for
125
+ /// a file nobody reconstructs state from would be the wrong trade.
126
+ pub fn write(&self, path: &Path) -> std::io::Result<()> {
127
+ if let Some(dir) = path.parent() {
128
+ std::fs::create_dir_all(dir)?;
129
+ }
130
+ let body = serde_json::to_string_pretty(&self.snapshot()).map_err(std::io::Error::other)?;
131
+ let tmp = path.with_extension("json.tmp");
132
+ std::fs::write(&tmp, body)?;
133
+ std::fs::rename(&tmp, path)?;
134
+ self.writes.fetch_add(1, Ordering::Relaxed);
135
+ Ok(())
136
+ }
137
+
138
+ pub fn writes(&self) -> u64 {
139
+ self.writes.load(Ordering::Relaxed)
140
+ }
141
+ }
142
+
143
+ /// The process-wide health registry.
144
+ ///
145
+ /// A global rather than a field on every `Spec`, for the same reason `tracing`
146
+ /// is: health is process-scoped telemetry that every engine touches, and
147
+ /// threading it through each source's configuration would add a parameter to
148
+ /// nine call sites and every test that builds one, to carry a value none of
149
+ /// them vary. Unset until the daemon installs one, and every method on the
150
+ /// returned handle is a no-op in that state — so a source reports health
151
+ /// unconditionally and a test that never installs one pays nothing.
152
+ static GLOBAL: std::sync::OnceLock<std::sync::Arc<Health>> = std::sync::OnceLock::new();
153
+
154
+ /// Install the process health registry. The first call wins; later ones are
155
+ /// ignored, so a stray second install cannot silently orphan the record the
156
+ /// writer task is publishing.
157
+ pub fn install(health: std::sync::Arc<Health>) {
158
+ let _ = GLOBAL.set(health);
159
+ }
160
+
161
+ /// Report a successful poll to the process registry, if one is installed.
162
+ pub fn report_poll(source: &str, root_present: bool, events: u64, cursor: u64) {
163
+ if let Some(h) = GLOBAL.get() {
164
+ h.record_poll(source, root_present, events, cursor);
165
+ }
166
+ }
167
+
168
+ /// Report a failed poll to the process registry, if one is installed.
169
+ pub fn report_error(source: &str, error: &str) {
170
+ if let Some(h) = GLOBAL.get() {
171
+ h.record_error(source, error);
172
+ }
173
+ }
174
+
175
+ /// Where the record lives.
176
+ pub fn health_path(home: &Path) -> PathBuf {
177
+ home.join("collector-health.json")
178
+ }
179
+
180
+ /// Remove the record on a clean shutdown.
181
+ ///
182
+ /// Absence means "no daemon running", which is unambiguous. Leaving a stale
183
+ /// file behind makes a stopped daemon look like a running one whose sources all
184
+ /// went quiet — the exact confusion this module exists to prevent.
185
+ pub fn remove(path: &Path) {
186
+ let _ = std::fs::remove_file(path);
187
+ }
188
+
189
+ /// Rewrite the record on an interval until shutdown, then delete it.
190
+ ///
191
+ /// A task rather than a write-on-every-change: sources update health many times
192
+ /// a second, and rewriting the file each time would turn a diagnostic into a
193
+ /// disk-IO cost on the hot path.
194
+ pub async fn writer_task(
195
+ health: std::sync::Arc<Health>,
196
+ path: PathBuf,
197
+ interval: std::time::Duration,
198
+ sd: crate::supervisor::Shutdown,
199
+ ) -> Result<(), crate::supervisor::TaskError> {
200
+ loop {
201
+ if let Err(err) = health.write(&path) {
202
+ // Logged, never fatal: failing to write a heartbeat must not stop
203
+ // the collection it is describing.
204
+ tracing::warn!(path = %path.display(), %err, "could not write the collector health record");
205
+ }
206
+ if !sd.sleep(interval).await {
207
+ // Clean shutdown: absence of the file means "no daemon running",
208
+ // which is unambiguous in a way a stale record is not.
209
+ remove(&path);
210
+ return Ok(());
211
+ }
212
+ }
213
+ }
214
+
215
+ /// How often the record is rewritten.
216
+ pub const WRITE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
217
+
218
+ fn now_secs() -> u64 {
219
+ std::time::SystemTime::now()
220
+ .duration_since(std::time::UNIX_EPOCH)
221
+ .map(|d| d.as_secs())
222
+ .unwrap_or(0)
223
+ }
224
+
225
+ fn truncate(s: &str, cap: usize) -> String {
226
+ if s.len() <= cap {
227
+ return s.to_string();
228
+ }
229
+ let mut end = cap;
230
+ while end > 0 && !s.is_char_boundary(end) {
231
+ end -= 1;
232
+ }
233
+ format!("{}…", &s[..end])
234
+ }
235
+
236
+ #[cfg(test)]
237
+ mod tests {
238
+ use super::*;
239
+ use std::time::{SystemTime, UNIX_EPOCH};
240
+
241
+ fn tmpdir(name: &str) -> PathBuf {
242
+ let d = std::env::temp_dir().join(format!(
243
+ "fpai-health-{}-{}-{}",
244
+ name,
245
+ std::process::id(),
246
+ SystemTime::now()
247
+ .duration_since(UNIX_EPOCH)
248
+ .unwrap()
249
+ .as_nanos()
250
+ ));
251
+ std::fs::create_dir_all(&d).unwrap();
252
+ d
253
+ }
254
+
255
+ #[test]
256
+ fn a_broken_source_is_distinguishable_from_an_unused_one() {
257
+ // The whole point. Both produce zero events; only one is a problem.
258
+ let h = Health::new();
259
+ h.record_poll("claude", true, 0, 0); // installed, quiet
260
+ h.record_poll("goose", false, 0, 0); // not installed at all
261
+
262
+ let snap = h.snapshot();
263
+ assert!(snap.sources["claude"].root_present);
264
+ assert!(!snap.sources["goose"].root_present);
265
+ assert_eq!(snap.sources["claude"].events, 0);
266
+ }
267
+
268
+ #[test]
269
+ fn a_recovered_source_stops_showing_a_stale_error_but_keeps_the_count() {
270
+ // A source that failed an hour ago and works now must not read as
271
+ // broken — while still being distinguishable from one that never failed.
272
+ let h = Health::new();
273
+ h.record_error("codex", "permission denied");
274
+ assert_eq!(h.snapshot().sources["codex"].errors, 1);
275
+ assert!(h.snapshot().sources["codex"].last_error.is_some());
276
+
277
+ h.record_poll("codex", true, 3, 900);
278
+ let s = &h.snapshot().sources["codex"];
279
+ assert!(
280
+ s.last_error.is_none(),
281
+ "a recovered source must clear its error"
282
+ );
283
+ assert_eq!(s.errors, 1, "but the fact it happened must survive");
284
+ }
285
+
286
+ #[test]
287
+ fn events_accumulate_and_stamp_a_time_only_when_there_were_some() {
288
+ // A poll that found nothing must not advance last_event_ts, or an idle
289
+ // source would look permanently busy.
290
+ let h = Health::new();
291
+ h.record_poll("claude", true, 0, 10);
292
+ assert_eq!(h.snapshot().sources["claude"].last_event_ts, 0);
293
+
294
+ h.record_poll("claude", true, 5, 20);
295
+ let s = &h.snapshot().sources["claude"];
296
+ assert_eq!(s.events, 5);
297
+ assert!(s.last_event_ts > 0);
298
+ assert_eq!(s.cursor, 20);
299
+
300
+ h.record_poll("claude", true, 2, 30);
301
+ assert_eq!(
302
+ h.snapshot().sources["claude"].events,
303
+ 7,
304
+ "events accumulate"
305
+ );
306
+ }
307
+
308
+ #[test]
309
+ fn an_enormous_error_cannot_bloat_the_record() {
310
+ // An error is a diagnostic, not a log. A multi-megabyte parse failure
311
+ // would make the file unreadable.
312
+ let h = Health::new();
313
+ h.record_error("pi", &"x".repeat(100_000));
314
+ let msg = h.snapshot().sources["pi"].last_error.clone().unwrap();
315
+ assert!(msg.len() <= MAX_ERROR_LEN + 4, "got {} bytes", msg.len());
316
+ }
317
+
318
+ #[test]
319
+ fn a_multibyte_error_is_truncated_on_a_char_boundary() {
320
+ // Slicing mid-codepoint would panic inside the health writer, which is
321
+ // the last place a panic is acceptable.
322
+ let h = Health::new();
323
+ h.record_error("pi", &"é".repeat(1000));
324
+ assert!(h.snapshot().sources["pi"].last_error.is_some());
325
+ }
326
+
327
+ #[test]
328
+ fn the_record_round_trips_through_disk() {
329
+ let dir = tmpdir("write");
330
+ let path = health_path(&dir);
331
+ let h = Health::new();
332
+ h.record_poll("claude", true, 4, 100);
333
+ h.record_error("codex", "boom");
334
+ h.write(&path).unwrap();
335
+
336
+ let parsed: HealthFile =
337
+ serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
338
+ assert!(parsed.ts > 0, "a reader must be able to spot a stale file");
339
+ assert_eq!(parsed.sources["claude"].events, 4);
340
+ assert_eq!(parsed.sources["codex"].last_error.as_deref(), Some("boom"));
341
+ std::fs::remove_dir_all(&dir).ok();
342
+ }
343
+
344
+ #[test]
345
+ fn writing_leaves_no_tmp_file_behind() {
346
+ let dir = tmpdir("tmp");
347
+ let path = health_path(&dir);
348
+ Health::new().write(&path).unwrap();
349
+ let stray = std::fs::read_dir(&dir)
350
+ .unwrap()
351
+ .filter_map(|e| e.ok())
352
+ .any(|e| e.file_name().to_string_lossy().ends_with(".tmp"));
353
+ assert!(!stray, "the atomic write left its tmp behind");
354
+ std::fs::remove_dir_all(&dir).ok();
355
+ }
356
+
357
+ #[test]
358
+ fn a_clean_shutdown_removes_the_record() {
359
+ // Absence means "no daemon". A stale file makes a stopped daemon look
360
+ // like a running one whose sources all went quiet.
361
+ let dir = tmpdir("remove");
362
+ let path = health_path(&dir);
363
+ Health::new().write(&path).unwrap();
364
+ assert!(path.exists());
365
+ remove(&path);
366
+ assert!(!path.exists());
367
+ std::fs::remove_dir_all(&dir).ok();
368
+ }
369
+ }
@@ -0,0 +1,34 @@
1
+ //! Session-log and hook-activity collection for `failproofaid`.
2
+ //!
3
+ //! This crate is deliberately separate from `failproofaid` rather than a
4
+ //! module inside it. The daemon crate gates every tool call on the machine
5
+ //! (its CLI fails closed on an unreachable daemon), so it is kept small and
6
+ //! its tests kept fast; collection is a much larger body of code with a much
7
+ //! lower blast radius, and it should be buildable and testable without
8
+ //! standing up a socket server at all.
9
+ //!
10
+ //! The only thing the daemon needs from here is
11
+ //! [`supervisor::spawn_supervised`], which hands back a handle it joins during
12
+ //! shutdown. Everything else is internal.
13
+
14
+ pub mod config;
15
+ pub mod cursor;
16
+ pub mod delivery;
17
+ pub mod filetail;
18
+ pub mod health;
19
+ pub mod redact;
20
+ pub mod sources;
21
+ pub mod spool;
22
+ pub mod sqlitepoll;
23
+ pub mod supervisor;
24
+ pub mod uploader;
25
+
26
+ pub use config::{CollectorConfig, DEFAULT_INGEST_URL, HooksVerbosity, Ingest, Redact, Settings};
27
+ pub use delivery::Delivery;
28
+ pub use health::{Health, HealthFile, SourceHealth, health_path};
29
+ pub use spool::SpoolWriter;
30
+ pub use supervisor::{
31
+ CollectorHandle, DEFAULT_FLUSH_BUDGET, Shutdown, SupervisorMetrics, TaskError, TaskSpec,
32
+ spawn_supervised,
33
+ };
34
+ pub use uploader::{UploadError, UploadMetrics, Uploader};