failproofai 1.0.0-beta.2 → 1.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +6 -6
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +1 -1
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
- package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
- package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/Cargo.lock +1992 -198
- package/.next/standalone/Cargo.toml +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
- package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
- package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
- package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
- package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
- package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
- package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
- package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
- package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
- package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
- package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
- package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
- package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
- package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
- package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
- package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
- package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
- package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
- package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
- package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
- package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
- package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
- package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
- package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
- package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
- package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
- package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
- package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
- package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2195 -770
- package/dist/worker.mjs +365 -52
- package/package.json +2 -2
- package/src/hooks/builtin-policies.ts +139 -0
- package/src/hooks/cloud-connection.ts +205 -0
- package/src/hooks/cloud-enrollment-cli.ts +227 -0
- package/src/hooks/cloud-enrollment.ts +173 -0
- package/src/hooks/cloud-managed-policies.ts +138 -0
- package/src/hooks/collector-config.ts +226 -0
- package/src/hooks/configure-wizard.ts +221 -5
- package/src/hooks/custom-hooks-loader.ts +69 -25
- package/src/hooks/daemon-client.ts +18 -0
- package/src/hooks/handler.ts +144 -6
- package/src/hooks/hook-activity-store.ts +50 -0
- package/src/hooks/loader-utils.ts +6 -4
- package/src/hooks/policy-types.ts +35 -0
- package/src/hooks/session-pause-cli.ts +157 -0
- package/src/hooks/session-pause.ts +236 -0
- package/src/hooks/tui.ts +115 -0
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,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};
|