failproofai 1.0.0-beta.3 → 1.0.0-beta.5

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 (157) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  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/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  13. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  21. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  22. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +9 -9
  23. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  24. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  25. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  26. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/run/route.js +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +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/server-reference-manifest.json +2 -2
  37. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  39. package/.next/standalone/.next/server/app/index.html +1 -1
  40. package/.next/standalone/.next/server/app/index.rsc +14 -14
  41. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  42. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  43. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  44. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +9 -9
  45. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  46. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  47. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +10 -10
  50. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  53. package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
  54. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  56. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  57. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  58. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  62. package/.next/standalone/.next/server/app/projects/page.js +1 -1
  63. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  64. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  65. package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
  66. package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
  67. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-dh5jy._.js +1 -1
  68. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0f8nu8d._.js +3 -0
  69. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0h3lfmp._.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l4vuqm._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0longh-._.js → [root-of-the-server]__0szqzl8._.js} +2 -2
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__10oa-v9._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__18d-9oi._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  77. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__1p7s8-d._.js} +2 -2
  78. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
  79. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
  80. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__10s_z4l._.js → [root-of-the-server]__094nop3._.js} +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  85. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ls0gsr._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0uno39i._.js → [root-of-the-server]__0n_uvv0._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  89. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__0rag5dg._.js} +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0rtdfof._.js} +2 -2
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0spkm68._.js +2 -2
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ky3im9._.js → [root-of-the-server]__1gak4pd._.js} +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1j4u3yd._.js +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
  98. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  99. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0jfts82.js → node_modules_html-to-image_es_index_12c_2cz.js} +1 -1
  102. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
  103. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  104. package/.next/standalone/.next/server/pages/404.html +1 -1
  105. package/.next/standalone/.next/server/pages/500.html +1 -1
  106. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  107. package/.next/standalone/.next/server/server-reference-manifest.json +12 -12
  108. package/.next/standalone/.next/static/chunks/{29g8ykjk6sx_8.js → 0cr85wuc9gc9k.js} +1 -1
  109. package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
  110. package/.next/standalone/.next/static/chunks/{0cvd6bcdwchc_.js → 1bkynffp3q1f7.js} +1 -1
  111. package/.next/standalone/.next/static/chunks/{2uvyh8y0naofj.js → 1bw4dfzflelcv.js} +1 -1
  112. package/.next/standalone/.next/static/chunks/{1h0_yaoq5f3yx.js → 1tp9dex23zs8y.js} +1 -1
  113. package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
  114. package/.next/standalone/.next/static/chunks/2cqk4a9s893oy.js +1 -0
  115. package/.next/standalone/.next/static/chunks/{13-obgukzg_ke.js → 2k285z07qce47.js} +1 -1
  116. package/.next/standalone/.next/static/chunks/{0a7k_x4rp_97z.js → 2s9g1dsde_ts1.js} +1 -1
  117. package/.next/standalone/.next/static/chunks/{2rqm-klet_gbl.js → 2v1rpzrh2i1wv.js} +1 -1
  118. package/.next/standalone/.next/static/chunks/{3p-n_j80zlzi3.js → 34sjdw8lv46e3.js} +2 -2
  119. package/.next/standalone/Cargo.lock +3 -3
  120. package/.next/standalone/Cargo.toml +1 -1
  121. package/.next/standalone/crates/failproofaid/src/main.rs +118 -11
  122. package/.next/standalone/crates/fpai-collect/src/config.rs +8 -0
  123. package/.next/standalone/crates/fpai-collect/src/filetail.rs +4 -1
  124. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +5 -1
  125. package/.next/standalone/crates/fpai-collect/src/spool.rs +77 -0
  126. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +4 -1
  127. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +1 -0
  128. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +1 -0
  129. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +1 -0
  130. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1 -0
  131. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1 -0
  132. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1 -0
  133. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +1 -0
  134. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +1 -0
  135. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1 -0
  136. package/.next/standalone/package.json +14 -8
  137. package/.next/standalone/server.js +1 -1
  138. package/dist/cli.mjs +81 -28
  139. package/dist/worker.mjs +1 -1
  140. package/package.json +14 -8
  141. package/scripts/build-daemon-packages.mjs +210 -0
  142. package/scripts/daemon-platforms.mjs +42 -0
  143. package/scripts/prune-standalone.mjs +6 -0
  144. package/scripts/publish-aliases.mjs +62 -41
  145. package/src/hooks/cloud-connection.ts +3 -0
  146. package/src/hooks/collector-config.ts +10 -0
  147. package/src/hooks/daemon-download.ts +137 -27
  148. package/src/hooks/daemon-service.ts +23 -8
  149. package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
  150. package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
  151. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
  152. package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
  153. package/.next/standalone/.next/static/chunks/01h6lfjz1_oie.js +0 -1
  154. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  155. /package/.next/standalone/.next/static/{pM1veX5Llx4YKEaiFc8Sf → LOuwMmfomO6hQYfMQYs4K}/_buildManifest.js +0 -0
  156. /package/.next/standalone/.next/static/{pM1veX5Llx4YKEaiFc8Sf → LOuwMmfomO6hQYfMQYs4K}/_clientMiddlewareManifest.js +0 -0
  157. /package/.next/standalone/.next/static/{pM1veX5Llx4YKEaiFc8Sf → LOuwMmfomO6hQYfMQYs4K}/_ssgManifest.js +0 -0
@@ -93,23 +93,26 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
93
93
  // machine (see fpai_collect::supervisor). It observes the same `shutdown`
94
94
  // flag the server and the cloud monitor do, so one SIGTERM stops all three.
95
95
  //
96
- // INERT until ingest is configured: `collector_tasks()` returns an empty
97
- // list, `spawn_supervised` then declines to start a thread or a runtime at
98
- // all, and the daemon behaves byte-for-byte as it did before this existed.
99
- let collector = fpai_collect::spawn_supervised(collector_tasks(), shutdown.clone());
96
+ // INERT until ingest is configured. But the config is not necessarily
97
+ // ready at startup: `failproofai config` installs the daemon and THEN
98
+ // connects, so the very first daemon start usually sees no ingest yet. A
99
+ // manager thread waits for the config to become enabled and starts the
100
+ // collector once it is — the collector's analogue of the cloud lane
101
+ // re-resolving enrolment per tick, so enabling collection takes effect
102
+ // within one interval with no restart and no root (see
103
+ // `spawn_collector_manager`). The manager owns the collector's lifecycle,
104
+ // including draining it on shutdown.
105
+ let collector_mgr = spawn_collector_manager(shutdown.clone());
100
106
 
101
107
  let srv = server::Server::bind(&socket_path, worker)?;
102
108
  eprintln!("[failproofaid] listening on {}", socket_path.display());
103
109
 
104
110
  let run_result = srv.run_until(shutdown);
105
111
 
106
- // Drain both background workers before returning. The collector gets a
107
- // bounded budget rather than an unbounded join: process exit must not wait
108
- // on a task that has wedged. Done before `?` so a server error still gets
109
- // the collector a chance to flush instead of dropping buffered events.
110
- if let Some(collector) = collector {
111
- collector.join_with_flush(fpai_collect::DEFAULT_FLUSH_BUDGET);
112
- }
112
+ // Join the manager, which drains the collector within its flush budget
113
+ // before returning. Done before `?` so a server error still gives the
114
+ // collector a chance to flush instead of dropping buffered events.
115
+ let _ = collector_mgr.join();
113
116
  let _ = cloud_monitor.join();
114
117
 
115
118
  run_result?;
@@ -128,6 +131,92 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
128
131
  /// serving the socket, because the CLI fails closed and a daemon that refused
129
132
  /// to boot over a malformed `ingest.json` would deny every tool call on the
130
133
  /// machine. The error is loud so it is fixable, not silent.
134
+ /// Own the collector's lifecycle on a dedicated thread.
135
+ ///
136
+ /// The collector config is not necessarily complete when the daemon starts:
137
+ /// `failproofai config` installs the service, so the daemon comes up, and only
138
+ /// THEN runs the connect step that writes `ingest.json` and the collector
139
+ /// block. Resolving the config once at startup therefore left a freshly-set-up
140
+ /// machine shipping nothing until the next manual restart — the exact confusion
141
+ /// a user hit while testing.
142
+ ///
143
+ /// This mirrors what the cloud-policy lane already does: it re-resolves its
144
+ /// config every tick precisely so `--connect` needs no root (restarting a
145
+ /// system unit does). The collector follows the same rule — it waits for the
146
+ /// config to become enabled, then starts once. Enabling collection thus takes
147
+ /// effect within one poll interval, no restart, no sudo.
148
+ ///
149
+ /// It starts the collector once and does not tear it back down on a later
150
+ /// `--disconnect` — that remains a restart, matching today's behaviour and
151
+ /// avoiding a second start against the set-once health registry. The gap this
152
+ /// closes is the common one: enabled *after* startup never taking effect.
153
+ fn spawn_collector_manager(daemon_shutdown: Arc<AtomicBool>) -> std::thread::JoinHandle<()> {
154
+ let interval = collector_config_poll_interval();
155
+ std::thread::Builder::new()
156
+ .name("fpai-collect-mgr".to_string())
157
+ .spawn(move || {
158
+ // Wait until collection is enabled. A cheap config read each tick,
159
+ // not the full task build, so an incomplete config waits quietly
160
+ // rather than logging on every interval.
161
+ loop {
162
+ if daemon_shutdown.load(Ordering::Relaxed) {
163
+ return;
164
+ }
165
+ if collector_is_enabled() {
166
+ break;
167
+ }
168
+ let deadline = std::time::Instant::now() + interval;
169
+ while std::time::Instant::now() < deadline {
170
+ if daemon_shutdown.load(Ordering::Relaxed) {
171
+ return;
172
+ }
173
+ std::thread::sleep(Duration::from_millis(200));
174
+ }
175
+ }
176
+
177
+ // Enabled — build and start. `collector_tasks()` logs "collector
178
+ // enabled" once; `spawn_supervised` returns None only if the config
179
+ // flipped back to disabled between the check and the build, or the
180
+ // runtime failed to start.
181
+ let Some(collector) =
182
+ fpai_collect::spawn_supervised(collector_tasks(), daemon_shutdown.clone())
183
+ else {
184
+ return;
185
+ };
186
+
187
+ while !daemon_shutdown.load(Ordering::Relaxed) {
188
+ std::thread::sleep(Duration::from_millis(200));
189
+ }
190
+ collector.join_with_flush(fpai_collect::DEFAULT_FLUSH_BUDGET);
191
+ })
192
+ .expect("failed to spawn the collector manager thread")
193
+ }
194
+
195
+ /// Cheap "should the collector be running?" check — reads the two small config
196
+ /// files. Any error resolves to `false`; the full `collector_tasks()` build
197
+ /// logs the reason when it acts on an enabled config.
198
+ fn collector_is_enabled() -> bool {
199
+ let Ok(home) = paths::failproofai_home() else {
200
+ return false;
201
+ };
202
+ fpai_collect::config::load(&home)
203
+ .map(|cfg| cfg.is_enabled())
204
+ .unwrap_or(false)
205
+ }
206
+
207
+ /// How often to re-check whether the collector config has become enabled.
208
+ /// Short by default — it is two small JSON reads — so enabling collection after
209
+ /// a fresh setup is prompt. `FAILPROOFAI_COLLECTOR_CONFIG_POLL_MS` overrides it.
210
+ fn collector_config_poll_interval() -> Duration {
211
+ const DEFAULT_MS: u64 = 5_000;
212
+ const MINIMUM_MS: u64 = 500;
213
+ let ms = std::env::var("FAILPROOFAI_COLLECTOR_CONFIG_POLL_MS")
214
+ .ok()
215
+ .and_then(|v| v.parse::<u64>().ok())
216
+ .unwrap_or(DEFAULT_MS);
217
+ Duration::from_millis(ms.max(MINIMUM_MS))
218
+ }
219
+
131
220
  fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
132
221
  let home = match paths::failproofai_home() {
133
222
  Ok(home) => home,
@@ -211,6 +300,7 @@ fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
211
300
  let spool_dir = cfg.own_spool_dir.clone();
212
301
  let verbosity = cfg.settings.hooks_verbosity;
213
302
  let environment = cfg.settings.environment.clone();
303
+ let machine_id = cfg.settings.machine_id.clone();
214
304
  tasks.push(fpai_collect::TaskSpec::new("hook-activity", move |sd| {
215
305
  fpai_collect::sources::hooks::run(
216
306
  store_dir.clone(),
@@ -218,6 +308,7 @@ fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
218
308
  spool_dir.clone(),
219
309
  verbosity,
220
310
  environment.clone(),
311
+ machine_id.clone(),
221
312
  sd,
222
313
  )
223
314
  }));
@@ -232,6 +323,7 @@ fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
232
323
  // uses, so adding one is a row here plus its own module.
233
324
  let spool = cfg.own_spool_dir.clone();
234
325
  let env = cfg.settings.environment.clone();
326
+ let machine = cfg.settings.machine_id.clone();
235
327
  let cursors = home.join("cursors");
236
328
 
237
329
  use fpai_collect::sources::{claude, codex, copilot, openclaw, pi};
@@ -244,6 +336,7 @@ fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
244
336
  &spool,
245
337
  &cursors,
246
338
  &env,
339
+ machine.as_deref(),
247
340
  );
248
341
  // Subagent transcripts live under the SAME root, claimed by a second
249
342
  // format. A separate source, not a second predicate: `is_source_file`
@@ -259,6 +352,7 @@ fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
259
352
  &spool,
260
353
  &cursors,
261
354
  &env,
355
+ machine.as_deref(),
262
356
  );
263
357
  file_source(
264
358
  &mut tasks,
@@ -269,6 +363,7 @@ fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
269
363
  &spool,
270
364
  &cursors,
271
365
  &env,
366
+ machine.as_deref(),
272
367
  );
273
368
  file_source(
274
369
  &mut tasks,
@@ -279,6 +374,7 @@ fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
279
374
  &spool,
280
375
  &cursors,
281
376
  &env,
377
+ machine.as_deref(),
282
378
  );
283
379
  file_source(
284
380
  &mut tasks,
@@ -289,6 +385,7 @@ fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
289
385
  &spool,
290
386
  &cursors,
291
387
  &env,
388
+ machine.as_deref(),
292
389
  );
293
390
  file_source(
294
391
  &mut tasks,
@@ -299,6 +396,7 @@ fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
299
396
  &spool,
300
397
  &cursors,
301
398
  &env,
399
+ machine.as_deref(),
302
400
  );
303
401
 
304
402
  use fpai_collect::sources::{goose, hermes, opencode};
@@ -311,6 +409,7 @@ fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
311
409
  &spool,
312
410
  cursors.join("goose"),
313
411
  &env,
412
+ machine.as_deref(),
314
413
  );
315
414
  sqlite_source(
316
415
  &mut tasks,
@@ -321,6 +420,7 @@ fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
321
420
  &spool,
322
421
  cursors.join("opencode"),
323
422
  &env,
423
+ machine.as_deref(),
324
424
  );
325
425
 
326
426
  // Hermes profiles are SEPARATE databases, and the SQLite poller keys its
@@ -338,6 +438,7 @@ fn collector_tasks() -> Vec<fpai_collect::TaskSpec> {
338
438
  &spool,
339
439
  state,
340
440
  &env,
441
+ machine.as_deref(),
341
442
  );
342
443
  }
343
444
  }
@@ -389,6 +490,7 @@ fn file_source(
389
490
  spool_dir: &std::path::Path,
390
491
  cursor_root: &std::path::Path,
391
492
  environment: &str,
493
+ machine_id: Option<&str>,
392
494
  ) {
393
495
  let spool_dir = spool_dir.to_path_buf();
394
496
  // One cursor store per source, never shared: the store writes its whole map
@@ -396,6 +498,7 @@ fn file_source(
396
498
  // loser would re-read from zero after every restart.
397
499
  let state_dir = cursor_root.join(name);
398
500
  let environment = environment.to_string();
501
+ let machine_id = machine_id.map(str::to_string);
399
502
  tasks.push(fpai_collect::TaskSpec::new(name, move |sd| {
400
503
  fpai_collect::filetail::run(
401
504
  fpai_collect::filetail::Spec {
@@ -407,6 +510,7 @@ fn file_source(
407
510
  params: fpai_collect::filetail::Params {
408
511
  agent_id: default_agent_id.to_string(),
409
512
  environment: environment.clone(),
513
+ machine_id: machine_id.clone(),
410
514
  end_idle_mins: 10,
411
515
  max_read_bytes: 32 * 1024 * 1024,
412
516
  max_batch_bytes: fpai_collect::spool::DEFAULT_MAX_BATCH_BYTES,
@@ -432,9 +536,11 @@ fn sqlite_source(
432
536
  spool_dir: &std::path::Path,
433
537
  state_dir: std::path::PathBuf,
434
538
  environment: &str,
539
+ machine_id: Option<&str>,
435
540
  ) {
436
541
  let spool_dir = spool_dir.to_path_buf();
437
542
  let environment = environment.to_string();
543
+ let machine_id = machine_id.map(str::to_string);
438
544
  tasks.push(fpai_collect::TaskSpec::new(name, move |sd| {
439
545
  fpai_collect::sqlitepoll::run(
440
546
  fpai_collect::sqlitepoll::Spec {
@@ -446,6 +552,7 @@ fn sqlite_source(
446
552
  params: fpai_collect::sqlitepoll::Params {
447
553
  agent_id: default_agent_id.to_string(),
448
554
  environment: environment.clone(),
555
+ machine_id: machine_id.clone(),
449
556
  max_rows_per_poll: 2000,
450
557
  max_batch_bytes: fpai_collect::spool::DEFAULT_MAX_BATCH_BYTES,
451
558
  max_drain_passes: 20,
@@ -117,6 +117,13 @@ pub struct Settings {
117
117
  /// here would silently drop every event this machine produced.
118
118
  #[serde(default = "default_environment")]
119
119
  pub environment: String,
120
+ /// The id of the machine this daemon runs on, written by
121
+ /// `failproofai config --connect --machine-id <id>`. Stamped on every
122
+ /// collected event so the server groups by machine rather than by
123
+ /// `agent_id` (a per-project identity). Absent on config written before
124
+ /// this field existed; such events carry no machine.
125
+ #[serde(default, rename = "machineId")]
126
+ pub machine_id: Option<String>,
120
127
  }
121
128
 
122
129
  fn default_true() -> bool {
@@ -135,6 +142,7 @@ impl Default for Settings {
135
142
  hooks_verbosity: HooksVerbosity::default(),
136
143
  redact: Redact::default(),
137
144
  environment: default_environment(),
145
+ machine_id: None,
138
146
  }
139
147
  }
140
148
  }
@@ -130,6 +130,8 @@ pub fn no_seed_state(_lines: &[String], _state: &mut TailState) {}
130
130
  pub struct Params {
131
131
  pub agent_id: String,
132
132
  pub environment: String,
133
+ /// Machine this daemon runs on, stamped on every event. See `SpoolWriter`.
134
+ pub machine_id: Option<String>,
133
135
  /// Minutes a file must be idle at EOF before `agent_end` is emitted.
134
136
  pub end_idle_mins: u64,
135
137
  pub max_read_bytes: u64,
@@ -248,7 +250,8 @@ async fn process_file(
248
250
  spec.params.max_batch_bytes,
249
251
  spec.format.kind,
250
252
  &ctx.session_id,
251
- );
253
+ )
254
+ .with_machine_id(spec.params.machine_id.clone());
252
255
  let mut emitted = 0u64;
253
256
 
254
257
  // A session with no start event never appears in the product, so retry it
@@ -81,6 +81,7 @@ pub async fn run(
81
81
  spool_dir: PathBuf,
82
82
  verbosity: HooksVerbosity,
83
83
  environment: String,
84
+ machine_id: Option<String>,
84
85
  sd: Shutdown,
85
86
  ) -> Result<(), TaskError> {
86
87
  if verbosity == HooksVerbosity::Off {
@@ -108,6 +109,7 @@ pub async fn run(
108
109
  &mut cursors,
109
110
  verbosity,
110
111
  &environment,
112
+ machine_id.as_deref(),
111
113
  )
112
114
  .await
113
115
  {
@@ -138,6 +140,7 @@ async fn poll_once(
138
140
  cursors: &mut CursorStore,
139
141
  verbosity: HooksVerbosity,
140
142
  environment: &str,
143
+ machine_id: Option<&str>,
141
144
  ) -> Result<u64, TaskError> {
142
145
  let files = list_pages(store_dir).await;
143
146
  if files.is_empty() {
@@ -149,7 +152,8 @@ async fn poll_once(
149
152
  DEFAULT_MAX_BATCH_BYTES,
150
153
  "hooks",
151
154
  "activity",
152
- );
155
+ )
156
+ .with_machine_id(machine_id.map(str::to_string));
153
157
  // Allow rows are rolled up across the whole pass, so a bucket spanning two
154
158
  // files still emits once.
155
159
  let mut buckets: BTreeMap<transform::BucketKey, AllowBucket> = BTreeMap::new();
@@ -56,6 +56,14 @@ pub struct SpoolWriter {
56
56
  buf_bytes: u64,
57
57
  redact: Redact,
58
58
  redacted: u64,
59
+ /// The machine this daemon runs on, stamped on every event so the server
60
+ /// can tell one machine from another. Without it, events carry only
61
+ /// `agent_id` — a per-project/per-harness identity — and the fleet views
62
+ /// mistake every project for a separate machine. `None` on a machine that
63
+ /// was never enrolled with a machine id (older config); such events simply
64
+ /// carry no machine and are excluded from machine-level counts rather than
65
+ /// guessed into one.
66
+ machine_id: Option<String>,
59
67
  }
60
68
 
61
69
  impl SpoolWriter {
@@ -84,6 +92,7 @@ impl SpoolWriter {
84
92
  // which is the safe direction for the mistake to fall.
85
93
  redact: Redact::Minimal,
86
94
  redacted: 0,
95
+ machine_id: None,
87
96
  }
88
97
  }
89
98
 
@@ -95,6 +104,13 @@ impl SpoolWriter {
95
104
  self
96
105
  }
97
106
 
107
+ /// Set the machine id stamped on every event. An empty string is treated as
108
+ /// absent, so a misconfigured blank id never becomes a machine of its own.
109
+ pub fn with_machine_id(mut self, id: Option<String>) -> Self {
110
+ self.machine_id = id.filter(|s| !s.is_empty());
111
+ self
112
+ }
113
+
98
114
  /// How many string leaves have been scrubbed. Reported without ever
99
115
  /// logging what was removed.
100
116
  pub fn redacted_count(&self) -> u64 {
@@ -117,6 +133,17 @@ impl SpoolWriter {
117
133
  // disk.
118
134
  self.redacted += crate::redact::scrub_value(&mut event, self.redact) as u64;
119
135
 
136
+ // Stamp the machine id here, at the one point every event passes
137
+ // through — a source cannot forget it and a new source inherits it. Set
138
+ // only when absent, so an event that already names its machine (a
139
+ // re-shipped batch, say) is never overwritten.
140
+ if let Some(id) = &self.machine_id
141
+ && let Some(obj) = event.as_object_mut()
142
+ {
143
+ obj.entry("machine_id")
144
+ .or_insert_with(|| Value::String(id.clone()));
145
+ }
146
+
120
147
  let mut line = serialize(&event)?;
121
148
 
122
149
  if line.len() as u64 > self.max_batch_bytes {
@@ -430,6 +457,56 @@ mod tests {
430
457
  std::fs::remove_dir_all(&dir).ok();
431
458
  }
432
459
 
460
+ #[tokio::test]
461
+ async fn machine_id_is_stamped_on_every_event_when_set() {
462
+ // Without this the server can only group by agent_id — a per-project
463
+ // identity — so one machine's many projects each read as a machine.
464
+ let dir = tmpdir("machine-id");
465
+ let mut w = SpoolWriter::new(dir.clone(), DEFAULT_MAX_BATCH_BYTES, "claude", "s")
466
+ .with_machine_id(Some("beta-test".into()));
467
+ w.push(json!({"type": "agent_start", "agent_id": "claude-foo"}))
468
+ .await
469
+ .unwrap();
470
+ w.flush().await.unwrap();
471
+ let name = batches(&dir).remove(0);
472
+ let body = std::fs::read_to_string(dir.join(&name)).unwrap();
473
+ assert!(body.contains(r#""machine_id":"beta-test""#), "got {body}");
474
+ std::fs::remove_dir_all(&dir).ok();
475
+ }
476
+
477
+ #[tokio::test]
478
+ async fn an_absent_machine_id_stamps_nothing() {
479
+ // A machine that was never given an id must not invent one — the server
480
+ // excludes machine-less events rather than bucket them.
481
+ let dir = tmpdir("no-machine-id");
482
+ let mut w = SpoolWriter::new(dir.clone(), DEFAULT_MAX_BATCH_BYTES, "claude", "s");
483
+ w.push(json!({"type": "agent_start"})).await.unwrap();
484
+ w.flush().await.unwrap();
485
+ let name = batches(&dir).remove(0);
486
+ assert!(
487
+ !std::fs::read_to_string(dir.join(&name))
488
+ .unwrap()
489
+ .contains("machine_id")
490
+ );
491
+ std::fs::remove_dir_all(&dir).ok();
492
+ }
493
+
494
+ #[tokio::test]
495
+ async fn an_empty_machine_id_is_treated_as_absent() {
496
+ let dir = tmpdir("blank-machine-id");
497
+ let mut w = SpoolWriter::new(dir.clone(), DEFAULT_MAX_BATCH_BYTES, "claude", "s")
498
+ .with_machine_id(Some(String::new()));
499
+ w.push(json!({"type": "agent_start"})).await.unwrap();
500
+ w.flush().await.unwrap();
501
+ let name = batches(&dir).remove(0);
502
+ assert!(
503
+ !std::fs::read_to_string(dir.join(&name))
504
+ .unwrap()
505
+ .contains("machine_id")
506
+ );
507
+ std::fs::remove_dir_all(&dir).ok();
508
+ }
509
+
433
510
  #[tokio::test]
434
511
  async fn redaction_can_be_turned_off() {
435
512
  let dir = tmpdir("redact-off");
@@ -106,6 +106,8 @@ pub struct SqliteFormat {
106
106
  pub struct Params {
107
107
  pub agent_id: String,
108
108
  pub environment: String,
109
+ /// Machine this daemon runs on, stamped on every event. See `SpoolWriter`.
110
+ pub machine_id: Option<String>,
109
111
  pub max_rows_per_poll: u64,
110
112
  pub max_batch_bytes: u64,
111
113
  /// Poll passes before yielding to the shutdown check. Bounds how long a
@@ -214,7 +216,8 @@ async fn poll_once(spec: &Spec, cursors: &mut CursorStore) -> Result<(u64, bool)
214
216
  spec.params.max_batch_bytes,
215
217
  spec.format.kind,
216
218
  spec.format.kind,
217
- );
219
+ )
220
+ .with_machine_id(spec.params.machine_id.clone());
218
221
  let emitted = outcome.events.len() as u64;
219
222
  for event in outcome.events {
220
223
  writer.push(event).await.map_err(io_err)?;
@@ -505,6 +505,7 @@ fn spec(root: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
505
505
  params: Params {
506
506
  agent_id: claude::DEFAULT_AGENT_ID.into(),
507
507
  environment: "local".into(),
508
+ machine_id: None,
508
509
  end_idle_mins: 0, // end immediately, so the test need not wait
509
510
  max_read_bytes: 8 * 1024 * 1024,
510
511
  max_batch_bytes: 8 * 1024 * 1024,
@@ -268,6 +268,7 @@ fn spec(root: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
268
268
  params: Params {
269
269
  agent_id: claude::SUBAGENT_DEFAULT_AGENT_ID.into(),
270
270
  environment: "local".into(),
271
+ machine_id: None,
271
272
  end_idle_mins: 0,
272
273
  max_read_bytes: 8 * 1024 * 1024,
273
274
  max_batch_bytes: 8 * 1024 * 1024,
@@ -725,6 +725,7 @@ fn spec(root: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
725
725
  params: Params {
726
726
  agent_id: codex::DEFAULT_AGENT_ID.into(),
727
727
  environment: "local".into(),
728
+ machine_id: None,
728
729
  end_idle_mins: 0, // end immediately, so the test need not wait
729
730
  max_read_bytes: 8 * 1024 * 1024,
730
731
  max_batch_bytes: 8 * 1024 * 1024,
@@ -771,6 +771,7 @@ fn spec(root: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
771
771
  params: Params {
772
772
  agent_id: copilot::DEFAULT_AGENT_ID.into(),
773
773
  environment: "local".into(),
774
+ machine_id: None,
774
775
  end_idle_mins: 0, // end immediately, so the test need not wait
775
776
  max_read_bytes: 8 * 1024 * 1024,
776
777
  max_batch_bytes: 8 * 1024 * 1024,
@@ -871,6 +871,7 @@ fn spec(db: &Path, spool: PathBuf, state: PathBuf) -> Spec {
871
871
  params: Params {
872
872
  agent_id: goose::DEFAULT_AGENT_ID.into(),
873
873
  environment: "local".into(),
874
+ machine_id: None,
874
875
  max_rows_per_poll: 500,
875
876
  max_batch_bytes: 8 * 1024 * 1024,
876
877
  max_drain_passes: 8,
@@ -993,6 +993,7 @@ fn spec(db: PathBuf, spool: PathBuf, state: PathBuf) -> sqlitepoll::Spec {
993
993
  params: sqlitepoll::Params {
994
994
  agent_id: hermes::DEFAULT_AGENT_ID.into(),
995
995
  environment: "local".into(),
996
+ machine_id: None,
996
997
  max_rows_per_poll: 500,
997
998
  max_batch_bytes: 8 * 1024 * 1024,
998
999
  max_drain_passes: 8,
@@ -95,6 +95,7 @@ async fn run_once(store: &Path, state: &Path, spool: &Path, v: HooksVerbosity) {
95
95
  spool.to_path_buf(),
96
96
  v,
97
97
  "local".into(),
98
+ None,
98
99
  sd,
99
100
  );
100
101
  let _ = tokio::time::timeout(Duration::from_millis(1500), fut).await;
@@ -573,6 +573,7 @@ fn spec(base: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
573
573
  params: Params {
574
574
  agent_id: openclaw::DEFAULT_AGENT_ID.into(),
575
575
  environment: "local".into(),
576
+ machine_id: None,
576
577
  end_idle_mins: 0, // end immediately, so the test need not wait
577
578
  max_read_bytes: 8 * 1024 * 1024,
578
579
  max_batch_bytes: 8 * 1024 * 1024,
@@ -780,6 +780,7 @@ fn spec(root: PathBuf, spool: PathBuf, state: PathBuf) -> Spec {
780
780
  params: Params {
781
781
  agent_id: pi::DEFAULT_AGENT_ID.into(),
782
782
  environment: "local".into(),
783
+ machine_id: None,
783
784
  end_idle_mins: 0, // end immediately, so the test need not wait
784
785
  max_read_bytes: 8 * 1024 * 1024,
785
786
  max_batch_bytes: 8 * 1024 * 1024,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "failproofai",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "The easiest way to manage policies that keep your AI agents reliable, on-task, and running autonomously — for Claude Code & the Agents SDK",
5
5
  "bin": {
6
6
  "failproofai": "./dist/cli.mjs",
@@ -76,19 +76,19 @@
76
76
  "@anthropic-ai/sdk": "^0.115.0",
77
77
  "@mdx-js/mdx": "^3.1.1",
78
78
  "@tailwindcss/postcss": "^4.3.1",
79
- "@tanstack/react-virtual": "^3.14.3",
79
+ "@tanstack/react-virtual": "^3.14.9",
80
80
  "@testing-library/jest-dom": "^7.0.0",
81
81
  "@testing-library/react": "^16.3.2",
82
82
  "@testing-library/user-event": "^14.6.1",
83
- "@types/node": "26.1.1",
83
+ "@types/node": "26.1.2",
84
84
  "@types/react": "19.2.17",
85
85
  "@types/react-dom": "^19.2.3",
86
- "@vitejs/plugin-react": "^6.0.1",
86
+ "@vitejs/plugin-react": "^6.0.5",
87
87
  "clsx": "^2.1.1",
88
88
  "eslint": "^10.5.0",
89
89
  "eslint-config-next": "^16.2.9",
90
- "jsdom": "^30.0.0",
91
- "lucide-react": "^1.18.0",
90
+ "jsdom": "^30.0.1",
91
+ "lucide-react": "^1.28.0",
92
92
  "next": "^16.2.11",
93
93
  "react": "^19.2.4",
94
94
  "react-dom": "^19.2.4",
@@ -100,7 +100,7 @@
100
100
  "dependencies": {
101
101
  "html-to-image": "^1.11.13",
102
102
  "html2canvas": "^1.4.1",
103
- "posthog-node": "^5.37.1",
103
+ "posthog-node": "^5.47.7",
104
104
  "sql.js": "^1.14.1",
105
105
  "yaml": "^2.9.0"
106
106
  },
@@ -108,8 +108,14 @@
108
108
  "postcss": "8.5.23",
109
109
  "eslint-plugin-react-hooks": "7.0.1",
110
110
  "vite": "8.0.16",
111
- "undici": "7.28.0",
111
+ "undici": "7.29.0",
112
112
  "brace-expansion": "5.0.9",
113
113
  "sharp": "0.35.0"
114
+ },
115
+ "optionalDependencies": {
116
+ "@failproofai/failproofaid-linux-x64": "1.0.0-beta.5",
117
+ "@failproofai/failproofaid-linux-arm64": "1.0.0-beta.5",
118
+ "@failproofai/failproofaid-darwin-x64": "1.0.0-beta.5",
119
+ "@failproofai/failproofaid-darwin-arm64": "1.0.0-beta.5"
114
120
  }
115
121
  }