failproofai 1.0.0-beta.2 → 1.0.0-beta.4
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 +2 -2
- 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 +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 +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 +1 -1
- 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 +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/[externals]__0h9_83k._.js +3 -0
- package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-dh5jy._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0f8nu8d._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0h3lfmp._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l4vuqm._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0longh-._.js → [root-of-the-server]__0szqzl8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__10oa-v9._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__18d-9oi._.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/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__1p7s8-d._.js} +2 -2
- 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/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
- 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]__0nb0syg._.js → [root-of-the-server]__0cmdyn_._.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]__0uno39i._.js → [root-of-the-server]__0n_uvv0._.js} +2 -2
- 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]__01od0m6._.js → [root-of-the-server]__0rag5dg._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0rtdfof._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__0sdrz-j._.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]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
- 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_1q9xxca.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -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/{18rvdw5v72364.js → 06jakt_xhoolk.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/0oe2-hp0_2tzs.js +6 -0
- package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 0ue9ak_xexiqh.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/1fw3pxobk_otl.js +1 -0
- package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
- package/.next/standalone/.next/static/chunks/2c90h1g6x8z8y.js +1 -0
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 2zb3m1tzq6h98.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 31cqiv_cpgj9m.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 3h5_3u46gu9jx.js} +1 -1
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 43a1ywqsg9bq9.js} +1 -1
- 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 +15 -9
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2255 -779
- package/dist/worker.mjs +365 -52
- package/package.json +15 -9
- package/scripts/build-daemon-packages.mjs +210 -0
- package/scripts/daemon-platforms.mjs +42 -0
- package/scripts/prune-standalone.mjs +6 -0
- package/scripts/publish-aliases.mjs +62 -41
- 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/daemon-download.ts +137 -27
- package/src/hooks/daemon-service.ts +23 -8
- 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/server/chunks/[externals]__14odj07._.js +0 -3
- package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/31feojxhjyjpl.js +0 -1
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
//! Turning a failproofai hook-activity row into AgentEye events.
|
|
2
|
+
//!
|
|
3
|
+
//! # Why this maps onto an existing schema
|
|
4
|
+
//!
|
|
5
|
+
//! AgentEye already has `hook_triggered` and `hook_completed` as first-class
|
|
6
|
+
//! event types, with `hook_name` and `hook_id` promoted to their own columns, a
|
|
7
|
+
//! `/hooks` page, and a latency endpoint that pairs the two legs on `hook_id`.
|
|
8
|
+
//! So this needs no server or dashboard work — it only has to emit the shape
|
|
9
|
+
//! that machinery already reads.
|
|
10
|
+
//!
|
|
11
|
+
//! One activity row carries a duration, so it yields BOTH legs with exact
|
|
12
|
+
//! latency rather than a start whose end has to be inferred.
|
|
13
|
+
//!
|
|
14
|
+
//! # `hook_id` uniqueness is load-bearing
|
|
15
|
+
//!
|
|
16
|
+
//! The server pairs legs on `hook_id` and dedups on a content hash. Two rows
|
|
17
|
+
//! colliding on an id would collapse into one row in the product — the
|
|
18
|
+
//! measured corpus has 8,613 `PreToolUse` rows in a single session, and a
|
|
19
|
+
//! per-session id would have merged all of them. The id therefore carries the
|
|
20
|
+
//! row's byte offset, which is unique within a file by construction and stable
|
|
21
|
+
//! across a re-read.
|
|
22
|
+
|
|
23
|
+
use serde::Deserialize;
|
|
24
|
+
use serde_json::{Map, Value, json};
|
|
25
|
+
|
|
26
|
+
/// One row of `~/.failproofai/cache/hook-activity/*.jsonl`.
|
|
27
|
+
///
|
|
28
|
+
/// Field names match `HookActivityEntry` in `hook-activity-store.ts`. Every
|
|
29
|
+
/// field except the timestamp is optional here even where TypeScript declares
|
|
30
|
+
/// it required: this parses rows written by older versions, and a row that
|
|
31
|
+
/// fails to deserialize is a row that never reaches the dashboard.
|
|
32
|
+
/// `Default` is derived so test literals can use `..Default::default()`. The
|
|
33
|
+
/// store gains fields as the product does, and every construction site
|
|
34
|
+
/// enumerating all of them turns each addition into unrelated breakage.
|
|
35
|
+
#[derive(Debug, Clone, Default, Deserialize)]
|
|
36
|
+
pub struct HookRow {
|
|
37
|
+
/// Epoch milliseconds.
|
|
38
|
+
pub timestamp: i64,
|
|
39
|
+
#[serde(rename = "eventType")]
|
|
40
|
+
pub event_type: Option<String>,
|
|
41
|
+
/// Which agent CLI fired the hook.
|
|
42
|
+
pub integration: Option<String>,
|
|
43
|
+
#[serde(rename = "toolName")]
|
|
44
|
+
pub tool_name: Option<String>,
|
|
45
|
+
#[serde(rename = "policyName")]
|
|
46
|
+
pub policy_name: Option<String>,
|
|
47
|
+
#[serde(rename = "matchedPolicies")]
|
|
48
|
+
pub matched_policies: Option<Vec<String>>,
|
|
49
|
+
/// `allow` | `deny` | `instruct`.
|
|
50
|
+
pub decision: Option<String>,
|
|
51
|
+
pub reason: Option<String>,
|
|
52
|
+
#[serde(rename = "durationMs")]
|
|
53
|
+
pub duration_ms: Option<f64>,
|
|
54
|
+
#[serde(rename = "sessionId")]
|
|
55
|
+
pub session_id: Option<String>,
|
|
56
|
+
pub cwd: Option<String>,
|
|
57
|
+
#[serde(rename = "permissionMode")]
|
|
58
|
+
pub permission_mode: Option<String>,
|
|
59
|
+
|
|
60
|
+
// ---- Decision attribution -------------------------------------------
|
|
61
|
+
// Which policy decided, and under what deployment. Without these the
|
|
62
|
+
// dashboard can group decisions only by the policy's display NAME, which
|
|
63
|
+
// is exactly the substring-parsing this data was added to replace: every
|
|
64
|
+
// row arrives unattributed, so "how much is my org's policy actually
|
|
65
|
+
// doing" has no answer.
|
|
66
|
+
/// `builtin` | `custom` | `convention` | `cloud`. Absent on rows written
|
|
67
|
+
/// before attribution existed, which is meaningful — see the note in
|
|
68
|
+
/// `hook-activity-store.ts` about not guessing a bucket.
|
|
69
|
+
#[serde(rename = "policySource")]
|
|
70
|
+
pub policy_source: Option<String>,
|
|
71
|
+
#[serde(rename = "cloudPolicyId")]
|
|
72
|
+
pub cloud_policy_id: Option<String>,
|
|
73
|
+
#[serde(rename = "cloudRevision")]
|
|
74
|
+
pub cloud_revision: Option<i64>,
|
|
75
|
+
/// Present on EVERY row of a managed machine, not just cloud-decided ones:
|
|
76
|
+
/// "what was deployed here" is a different question from "what decided",
|
|
77
|
+
/// and only the former separates a rollout that changed no outcomes from
|
|
78
|
+
/// one that never reached the machine.
|
|
79
|
+
#[serde(rename = "cloudGeneration")]
|
|
80
|
+
pub cloud_generation: Option<i64>,
|
|
81
|
+
|
|
82
|
+
// ---- Suspension ------------------------------------------------------
|
|
83
|
+
/// Set while `failproofai config --pause` is in effect. An `allow` on such
|
|
84
|
+
/// a row proves nothing, so shipping it without this would assert a clean
|
|
85
|
+
/// window over exactly the window that was not enforced.
|
|
86
|
+
#[serde(rename = "pausedBy")]
|
|
87
|
+
pub paused_by: Option<String>,
|
|
88
|
+
#[serde(rename = "pauseExpiresAt")]
|
|
89
|
+
pub pause_expires_at: Option<i64>,
|
|
90
|
+
|
|
91
|
+
/// Verdicts from observe-mode policies: evaluated, then discarded. The
|
|
92
|
+
/// whole measurement a trial exists to produce.
|
|
93
|
+
pub observed: Option<Value>,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// The attribution facts for one row.
|
|
97
|
+
///
|
|
98
|
+
/// This is both what gets emitted AND part of the aggregation key. Those have
|
|
99
|
+
/// to be the same set: an aggregate that mixed two policy sources could carry
|
|
100
|
+
/// no honest attribution at all, so anything emitted here must be something
|
|
101
|
+
/// rows were grouped by.
|
|
102
|
+
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)]
|
|
103
|
+
pub struct Attribution {
|
|
104
|
+
pub policy_source: Option<String>,
|
|
105
|
+
pub cloud_policy_id: Option<String>,
|
|
106
|
+
pub cloud_revision: Option<i64>,
|
|
107
|
+
pub cloud_generation: Option<i64>,
|
|
108
|
+
pub paused: bool,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
impl Attribution {
|
|
112
|
+
pub fn of(row: &HookRow) -> Self {
|
|
113
|
+
Self {
|
|
114
|
+
policy_source: row.policy_source.clone(),
|
|
115
|
+
cloud_policy_id: row.cloud_policy_id.clone(),
|
|
116
|
+
cloud_revision: row.cloud_revision,
|
|
117
|
+
cloud_generation: row.cloud_generation,
|
|
118
|
+
paused: row.paused_by.is_some(),
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Write the attribution onto an outgoing event.
|
|
123
|
+
///
|
|
124
|
+
/// Names are snake_case to match every other payload key the server reads.
|
|
125
|
+
/// `paused` is emitted as a real boolean because the server tests it with
|
|
126
|
+
/// `JSONExtractBool`, which a string would fail.
|
|
127
|
+
fn apply(&self, m: &mut Map<String, Value>) {
|
|
128
|
+
if let Some(s) = &self.policy_source {
|
|
129
|
+
m.insert("policy_source".into(), json!(s));
|
|
130
|
+
}
|
|
131
|
+
if let Some(id) = &self.cloud_policy_id {
|
|
132
|
+
m.insert("cloud_policy_id".into(), json!(id));
|
|
133
|
+
}
|
|
134
|
+
if let Some(r) = self.cloud_revision {
|
|
135
|
+
m.insert("cloud_revision".into(), json!(r));
|
|
136
|
+
}
|
|
137
|
+
if let Some(g) = self.cloud_generation {
|
|
138
|
+
m.insert("cloud_generation".into(), json!(g));
|
|
139
|
+
}
|
|
140
|
+
// Always emitted, never conditionally: an absent key and `false` must
|
|
141
|
+
// not be distinguishable to a reader counting unenforced calls.
|
|
142
|
+
m.insert("paused".into(), json!(self.paused));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
impl HookRow {
|
|
147
|
+
pub fn decision_str(&self) -> &str {
|
|
148
|
+
self.decision.as_deref().unwrap_or("allow")
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// True when observe-mode policies recorded a would-be verdict.
|
|
152
|
+
///
|
|
153
|
+
/// Such a row is an `allow` by construction — the verdict was discarded —
|
|
154
|
+
/// so it would otherwise be swept into an allow aggregate and the trial's
|
|
155
|
+
/// only measurement would be erased by the roll-up.
|
|
156
|
+
pub fn has_observation(&self) -> bool {
|
|
157
|
+
match &self.observed {
|
|
158
|
+
Some(Value::Array(a)) => !a.is_empty(),
|
|
159
|
+
Some(Value::Null) | None => false,
|
|
160
|
+
Some(_) => true,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// True for the 99.1% of rows that are plain no-ops.
|
|
165
|
+
pub fn is_allow(&self) -> bool {
|
|
166
|
+
self.decision_str() == "allow"
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// Format epoch milliseconds as the RFC3339-with-microseconds string ingest
|
|
171
|
+
/// requires, offsetting by `index` microseconds.
|
|
172
|
+
///
|
|
173
|
+
/// The offset exists because the server orders events by `(ts, random id)`.
|
|
174
|
+
/// Both legs of a pair derived from one row share a millisecond, so without it
|
|
175
|
+
/// `hook_completed` can sort before its own `hook_triggered` — visibly wrong
|
|
176
|
+
/// on a timeline. Index 0 keeps the row's exact time, so the start leg is
|
|
177
|
+
/// never moved.
|
|
178
|
+
pub fn to_rfc3339_micros(epoch_ms: i64, index: u32) -> Option<String> {
|
|
179
|
+
let nanos = (epoch_ms as i128) * 1_000_000 + (index as i128) * 1_000;
|
|
180
|
+
let dt = time::OffsetDateTime::from_unix_timestamp_nanos(nanos).ok()?;
|
|
181
|
+
// Microseconds are forced rather than left to RFC3339's variable
|
|
182
|
+
// precision, so every event has one shape and the server parses one
|
|
183
|
+
// format. It also matches what the session sources emit, so the two
|
|
184
|
+
// streams sort against each other correctly on a shared timeline.
|
|
185
|
+
const FMT: &[time::format_description::BorrowedFormatItem<'_>] = time::macros::format_description!(
|
|
186
|
+
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6]Z"
|
|
187
|
+
);
|
|
188
|
+
dt.format(FMT).ok()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// Sanitize one component of a derived agent id.
|
|
192
|
+
fn sanitize_id_part(s: &str) -> String {
|
|
193
|
+
let cleaned: String = s
|
|
194
|
+
.chars()
|
|
195
|
+
.map(|c| {
|
|
196
|
+
if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
|
|
197
|
+
c
|
|
198
|
+
} else {
|
|
199
|
+
'-'
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
.collect();
|
|
203
|
+
let collapsed = cleaned
|
|
204
|
+
.split('-')
|
|
205
|
+
.filter(|p| !p.is_empty())
|
|
206
|
+
.collect::<Vec<_>>()
|
|
207
|
+
.join("-");
|
|
208
|
+
collapsed.chars().take(48).collect()
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/// The agent id an event is filed under.
|
|
212
|
+
///
|
|
213
|
+
/// `<integration>-<project>` where a working directory is known, matching the
|
|
214
|
+
/// scheme the session sources use, so a hook event and the transcript events
|
|
215
|
+
/// for the same run land under one agent rather than two that look unrelated.
|
|
216
|
+
/// Falls back to the bare integration name when there is no cwd — a gateway
|
|
217
|
+
/// session, say.
|
|
218
|
+
pub fn agent_id(row: &HookRow) -> String {
|
|
219
|
+
let base = row.integration.as_deref().unwrap_or("failproofai");
|
|
220
|
+
match row.cwd.as_deref().and_then(project_name) {
|
|
221
|
+
Some(project) => format!("{}-{}", sanitize_id_part(base), project),
|
|
222
|
+
None => sanitize_id_part(base),
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/// Last path component of a working directory.
|
|
227
|
+
fn project_name(cwd: &str) -> Option<String> {
|
|
228
|
+
let trimmed = cwd.trim_end_matches('/');
|
|
229
|
+
let last = trimmed.rsplit('/').find(|p| !p.is_empty())?;
|
|
230
|
+
let cleaned = sanitize_id_part(last);
|
|
231
|
+
(!cleaned.is_empty()).then_some(cleaned)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/// Common envelope every emitted event carries.
|
|
235
|
+
fn base(
|
|
236
|
+
row: &HookRow,
|
|
237
|
+
session_id: &str,
|
|
238
|
+
environment: &str,
|
|
239
|
+
kind: &str,
|
|
240
|
+
index: u32,
|
|
241
|
+
) -> Option<Map<String, Value>> {
|
|
242
|
+
let mut m = Map::new();
|
|
243
|
+
m.insert(
|
|
244
|
+
"timestamp".into(),
|
|
245
|
+
json!(to_rfc3339_micros(row.timestamp, index)?),
|
|
246
|
+
);
|
|
247
|
+
m.insert("session_id".into(), json!(session_id));
|
|
248
|
+
m.insert("agent_id".into(), json!(agent_id(row)));
|
|
249
|
+
m.insert("type".into(), json!(kind));
|
|
250
|
+
m.insert("environment".into(), json!(environment));
|
|
251
|
+
Some(m)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/// Build the `hook_triggered` / `hook_completed` pair for one row.
|
|
255
|
+
///
|
|
256
|
+
/// `offset` is the row's byte position in its file, which is what makes
|
|
257
|
+
/// `hook_id` unique and stable across a re-read.
|
|
258
|
+
///
|
|
259
|
+
/// Returns nothing when the row has no session id: ingest requires one, so
|
|
260
|
+
/// emitting would produce a line the server silently skips.
|
|
261
|
+
pub fn to_events(row: &HookRow, offset: u64, environment: &str) -> Vec<Value> {
|
|
262
|
+
let Some(session_id) = row.session_id.as_deref().filter(|s| !s.is_empty()) else {
|
|
263
|
+
return Vec::new();
|
|
264
|
+
};
|
|
265
|
+
let event_name = row.event_type.as_deref().unwrap_or("Hook");
|
|
266
|
+
let hook_id = format!("{session_id}:{}:{offset}", row.timestamp);
|
|
267
|
+
|
|
268
|
+
let Some(mut start) = base(row, session_id, environment, "hook_triggered", 0) else {
|
|
269
|
+
return Vec::new();
|
|
270
|
+
};
|
|
271
|
+
start.insert("hook_name".into(), json!(event_name));
|
|
272
|
+
start.insert("hook_id".into(), json!(hook_id));
|
|
273
|
+
// `trigger_event` is emitted on the START leg only. The server filters on
|
|
274
|
+
// it via a semijoin over hook_id, so duplicating it on the end leg would
|
|
275
|
+
// double-count every hook in that view.
|
|
276
|
+
start.insert("trigger_event".into(), json!(event_name));
|
|
277
|
+
if let Some(d) = row.duration_ms {
|
|
278
|
+
start.insert("duration_ms".into(), json!(d));
|
|
279
|
+
}
|
|
280
|
+
let mut input = Map::new();
|
|
281
|
+
if let Some(t) = &row.tool_name {
|
|
282
|
+
input.insert("tool_name".into(), json!(t));
|
|
283
|
+
}
|
|
284
|
+
if let Some(c) = &row.cwd {
|
|
285
|
+
input.insert("cwd".into(), json!(c));
|
|
286
|
+
}
|
|
287
|
+
if let Some(p) = &row.permission_mode {
|
|
288
|
+
input.insert("permission_mode".into(), json!(p));
|
|
289
|
+
}
|
|
290
|
+
if !input.is_empty() {
|
|
291
|
+
start.insert("input".into(), Value::Object(input));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let Some(mut end) = base(row, session_id, environment, "hook_completed", 1) else {
|
|
295
|
+
return Vec::new();
|
|
296
|
+
};
|
|
297
|
+
end.insert("hook_name".into(), json!(event_name));
|
|
298
|
+
end.insert("hook_id".into(), json!(hook_id));
|
|
299
|
+
end.insert("outcome".into(), json!(row.decision_str()));
|
|
300
|
+
if let Some(d) = row.duration_ms {
|
|
301
|
+
end.insert("duration_ms".into(), json!(d));
|
|
302
|
+
}
|
|
303
|
+
if let Some(p) = &row.policy_name {
|
|
304
|
+
end.insert("failproofai_policy".into(), json!(p));
|
|
305
|
+
}
|
|
306
|
+
if let Some(m) = &row.matched_policies {
|
|
307
|
+
end.insert("failproofai_matched".into(), json!(m));
|
|
308
|
+
}
|
|
309
|
+
if let Some(t) = &row.tool_name {
|
|
310
|
+
end.insert("tool_name".into(), json!(t));
|
|
311
|
+
}
|
|
312
|
+
// Attribution rides the END leg only: it is a property of the decision,
|
|
313
|
+
// and the start leg is emitted before one exists.
|
|
314
|
+
Attribution::of(row).apply(&mut end);
|
|
315
|
+
if let Some(by) = &row.paused_by {
|
|
316
|
+
end.insert("paused_by".into(), json!(by));
|
|
317
|
+
}
|
|
318
|
+
if let Some(exp) = row.pause_expires_at {
|
|
319
|
+
end.insert("pause_expires_at".into(), json!(exp));
|
|
320
|
+
}
|
|
321
|
+
if row.has_observation() {
|
|
322
|
+
// Carried whole rather than flattened: a row can observe several
|
|
323
|
+
// policies at once, and the id/revision/decision only mean anything
|
|
324
|
+
// together.
|
|
325
|
+
end.insert(
|
|
326
|
+
"failproofai_observed".into(),
|
|
327
|
+
row.observed.clone().unwrap_or(Value::Null),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if !row.is_allow() {
|
|
331
|
+
// The server's `is_error` is a truthiness check, so this must never be
|
|
332
|
+
// an empty string — a deny with no reason would otherwise render as a
|
|
333
|
+
// success.
|
|
334
|
+
let reason = row
|
|
335
|
+
.reason
|
|
336
|
+
.as_deref()
|
|
337
|
+
.filter(|r| !r.trim().is_empty())
|
|
338
|
+
.unwrap_or("blocked by failproofai");
|
|
339
|
+
end.insert("error".into(), json!(reason));
|
|
340
|
+
end.insert(
|
|
341
|
+
"error_type".into(),
|
|
342
|
+
json!(format!("failproofai_{}", row.decision_str())),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
vec![Value::Object(start), Value::Object(end)]
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/// One rolled-up `allow` bucket.
|
|
350
|
+
///
|
|
351
|
+
/// Under the default verbosity, `allow` rows are aggregated rather than
|
|
352
|
+
/// dropped. Keeping the count is what preserves the denominator: "we evaluated
|
|
353
|
+
/// 19,000 calls and blocked 15" stays answerable, which it would not be if the
|
|
354
|
+
/// no-ops were simply discarded.
|
|
355
|
+
#[derive(Debug, Clone)]
|
|
356
|
+
pub struct AllowBucket {
|
|
357
|
+
pub session_id: String,
|
|
358
|
+
pub agent_id: String,
|
|
359
|
+
pub event_name: String,
|
|
360
|
+
pub tool_name: Option<String>,
|
|
361
|
+
/// Truncated to the minute; also the bucket's emitted timestamp.
|
|
362
|
+
pub minute_ms: i64,
|
|
363
|
+
pub count: u64,
|
|
364
|
+
pub total_duration_ms: f64,
|
|
365
|
+
pub max_duration_ms: f64,
|
|
366
|
+
/// Shared by every row in the bucket — see `BucketKey`.
|
|
367
|
+
pub attribution: Attribution,
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/// The key rows are grouped under: same session, event, tool, minute — and the
|
|
371
|
+
/// same attribution.
|
|
372
|
+
///
|
|
373
|
+
/// Attribution is part of the key rather than a field sampled from the first
|
|
374
|
+
/// row because a bucket is emitted as ONE event carrying ONE set of facts. Group
|
|
375
|
+
/// a cloud-decided allow with an unattributed one and whichever attribution is
|
|
376
|
+
/// emitted is wrong for the rest, which is worse than no attribution: it moves
|
|
377
|
+
/// a count into a bucket someone is using to judge a rollout. Splitting instead
|
|
378
|
+
/// costs extra events only when a minute genuinely mixed sources.
|
|
379
|
+
pub type BucketKey = (String, String, Option<String>, i64, Attribution);
|
|
380
|
+
|
|
381
|
+
pub fn bucket_key(row: &HookRow) -> Option<BucketKey> {
|
|
382
|
+
let session = row.session_id.clone().filter(|s| !s.is_empty())?;
|
|
383
|
+
let event = row.event_type.clone().unwrap_or_else(|| "Hook".into());
|
|
384
|
+
let minute = row.timestamp - row.timestamp.rem_euclid(60_000);
|
|
385
|
+
Some((
|
|
386
|
+
session,
|
|
387
|
+
event,
|
|
388
|
+
row.tool_name.clone(),
|
|
389
|
+
minute,
|
|
390
|
+
Attribution::of(row),
|
|
391
|
+
))
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
impl AllowBucket {
|
|
395
|
+
pub fn add(&mut self, row: &HookRow) {
|
|
396
|
+
self.count += 1;
|
|
397
|
+
let d = row.duration_ms.unwrap_or(0.0);
|
|
398
|
+
self.total_duration_ms += d;
|
|
399
|
+
if d > self.max_duration_ms {
|
|
400
|
+
self.max_duration_ms = d;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/// A single `hook_completed` standing for every allow in the bucket.
|
|
405
|
+
///
|
|
406
|
+
/// One leg, not a pair: an aggregate has no single invocation to pair
|
|
407
|
+
/// with, and inventing a `hook_triggered` would make the latency endpoint
|
|
408
|
+
/// report a duration that belongs to no real hook call.
|
|
409
|
+
pub fn to_event(&self, environment: &str) -> Option<Value> {
|
|
410
|
+
let mut m = Map::new();
|
|
411
|
+
m.insert(
|
|
412
|
+
"timestamp".into(),
|
|
413
|
+
json!(to_rfc3339_micros(self.minute_ms, 0)?),
|
|
414
|
+
);
|
|
415
|
+
m.insert("session_id".into(), json!(self.session_id));
|
|
416
|
+
m.insert("agent_id".into(), json!(self.agent_id));
|
|
417
|
+
m.insert("type".into(), json!("hook_completed"));
|
|
418
|
+
m.insert("environment".into(), json!(environment));
|
|
419
|
+
m.insert("hook_name".into(), json!(self.event_name));
|
|
420
|
+
m.insert(
|
|
421
|
+
"hook_id".into(),
|
|
422
|
+
json!(format!(
|
|
423
|
+
"{}:{}:{}:{}:agg",
|
|
424
|
+
self.session_id,
|
|
425
|
+
self.minute_ms,
|
|
426
|
+
self.event_name,
|
|
427
|
+
self.tool_name.as_deref().unwrap_or("-")
|
|
428
|
+
)),
|
|
429
|
+
);
|
|
430
|
+
m.insert("outcome".into(), json!("allow"));
|
|
431
|
+
m.insert("failproofai_allow_count".into(), json!(self.count));
|
|
432
|
+
m.insert(
|
|
433
|
+
"duration_ms".into(),
|
|
434
|
+
json!((self.total_duration_ms / self.count as f64 * 1000.0).round() / 1000.0),
|
|
435
|
+
);
|
|
436
|
+
m.insert(
|
|
437
|
+
"failproofai_max_duration_ms".into(),
|
|
438
|
+
json!(self.max_duration_ms),
|
|
439
|
+
);
|
|
440
|
+
if let Some(t) = &self.tool_name {
|
|
441
|
+
m.insert("tool_name".into(), json!(t));
|
|
442
|
+
}
|
|
443
|
+
// Honest by construction: every row in this bucket was grouped BY this
|
|
444
|
+
// attribution, so it describes all of them.
|
|
445
|
+
self.attribution.apply(&mut m);
|
|
446
|
+
Some(Value::Object(m))
|
|
447
|
+
}
|
|
448
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//! Per-source capture. Each module owns one agent's on-disk format and turns
|
|
2
|
+
//! it into AgentEye events dropped into the shared spool; everything
|
|
3
|
+
//! downstream — spool, cursor persistence, redaction, delivery — is common.
|
|
4
|
+
//!
|
|
5
|
+
//! Sources fall into three shapes:
|
|
6
|
+
//!
|
|
7
|
+
//! * **File tailers** ([`crate::filetail`]) — claude, codex, copilot, openclaw,
|
|
8
|
+
//! pi. Each supplies a `Format` table of pure functions.
|
|
9
|
+
//! * **SQLite pollers** ([`crate::sqlitepoll`]) — goose, opencode, hermes. Each
|
|
10
|
+
//! supplies a `SqliteFormat` and declares how its database orders changes.
|
|
11
|
+
//! * **The hook stream** ([`hooks`]) — CLI-agnostic, and the one capability
|
|
12
|
+
//! that comes from failproofai sitting in the hook path rather than reading
|
|
13
|
+
//! somebody else's files.
|
|
14
|
+
|
|
15
|
+
pub mod claude;
|
|
16
|
+
pub mod codex;
|
|
17
|
+
pub mod copilot;
|
|
18
|
+
pub mod goose;
|
|
19
|
+
pub mod hermes;
|
|
20
|
+
pub mod hooks;
|
|
21
|
+
pub mod openclaw;
|
|
22
|
+
pub mod opencode;
|
|
23
|
+
pub mod pi;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
//! OpenClaw session capture — a [`filetail`](crate::filetail) adapter.
|
|
2
|
+
//!
|
|
3
|
+
//! OpenClaw writes live-appended JSONL transcripts at
|
|
4
|
+
//! `<state>/agents/<agentId>/sessions/<sessionId>.jsonl`, where `<state>` is
|
|
5
|
+
//! `$OPENCLAW_STATE_DIR`, else `$OPENCLAW_HOME`, else `~/.openclaw`. We open
|
|
6
|
+
//! them read-only; OpenClaw's own files are never written, moved or deleted.
|
|
7
|
+
//!
|
|
8
|
+
//! # The sibling that must never be discovered
|
|
9
|
+
//!
|
|
10
|
+
//! `<sessionId>.trajectory.jsonl` sits *directly beside* the transcript, shares
|
|
11
|
+
//! its stem, and has the same `.jsonl` extension — so the obvious "tail every
|
|
12
|
+
//! `.jsonl` under the sessions directory" predicate picks it up. It is an
|
|
13
|
+
//! OpenTelemetry trace of the same session, and it is enormous: measured on the
|
|
14
|
+
//! probe capture, the transcript is 5,640 bytes and the trajectory beside it is
|
|
15
|
+
//! 334,468 — 59x for zero additional information, since every turn it describes
|
|
16
|
+
//! is already in the transcript. Shipping it turns ~6 KB per session into
|
|
17
|
+
//! ~340 KB, i.e. it decides whether this source is affordable at all. That is
|
|
18
|
+
//! why [`is_transcript`] rejects it by name *before* any other check, even
|
|
19
|
+
//! though the bare-UUID stem rule would already exclude it — one rule silently
|
|
20
|
+
//! loosened must not be able to cost 59x.
|
|
21
|
+
//!
|
|
22
|
+
//! Three more neighbours are excluded for their own reasons:
|
|
23
|
+
//!
|
|
24
|
+
//! * `<sessionId>.trajectory-path.json` — a pointer at the trace above.
|
|
25
|
+
//! Excluded by requiring `.jsonl`.
|
|
26
|
+
//! * `sessions.json` — the per-agent session index, **rewritten in place** on
|
|
27
|
+
//! every interaction. A byte cursor over a rewritten file re-ships its whole
|
|
28
|
+
//! contents forever. Excluded by requiring `.jsonl` and a UUID stem.
|
|
29
|
+
//! * `sessions/skills-prompts/sha256/**` — content-addressed prompt blobs,
|
|
30
|
+
//! nested below the sessions directory. Excluded by requiring the transcript
|
|
31
|
+
//! to sit *directly* in `sessions/`.
|
|
32
|
+
//!
|
|
33
|
+
//! `<state>/state/openclaw.sqlite` (964 KB on the probe capture) and the
|
|
34
|
+
//! agent's `workspace/` git checkout are outside the root entirely — see
|
|
35
|
+
//! [`default_roots`], which points at `agents/` rather than the state directory
|
|
36
|
+
//! so neither is even walked.
|
|
37
|
+
//!
|
|
38
|
+
//! # Grouping is by agent, not by working directory
|
|
39
|
+
//!
|
|
40
|
+
//! Every other source derives its agent id from the session's `cwd`. OpenClaw's
|
|
41
|
+
//! `cwd` is the agent *workspace* (`/root/.openclaw/workspace` on the capture —
|
|
42
|
+
//! identical in the `session` header and in every tool result's
|
|
43
|
+
//! `details.cwd`), which is a fixed per-agent scratch directory and has nothing
|
|
44
|
+
//! to do with what the session was about: the probe session read and counted
|
|
45
|
+
//! files in `/work`, and `cwd` never mentions it. Grouping on it would file
|
|
46
|
+
//! every session of every agent under one meaningless project. The `agentId`
|
|
47
|
+
//! path component is the real axis — see [`agent_id_from_path`].
|
|
48
|
+
//!
|
|
49
|
+
//! # Scope
|
|
50
|
+
//!
|
|
51
|
+
//! Covers the records that carry the session: its `session` header, the model
|
|
52
|
+
//! it runs on, user prompts, assistant text, tool calls and tool results.
|
|
53
|
+
//! `thinking_level_change` and `custom` records (bootstrap-context markers,
|
|
54
|
+
//! model snapshots) advance the session clock but emit nothing — they describe
|
|
55
|
+
//! the harness, not the conversation.
|
|
56
|
+
|
|
57
|
+
pub mod transform;
|
|
58
|
+
|
|
59
|
+
use std::path::{Path, PathBuf};
|
|
60
|
+
|
|
61
|
+
use crate::filetail::{Format, RereadPolicy, no_seed_state};
|
|
62
|
+
|
|
63
|
+
/// Main transcripts: `<uuid>.jsonl` sitting directly inside a `sessions/`
|
|
64
|
+
/// directory.
|
|
65
|
+
pub const FORMAT: Format = Format {
|
|
66
|
+
kind: "openclaw",
|
|
67
|
+
is_source_file: is_transcript,
|
|
68
|
+
session_id_from_path,
|
|
69
|
+
agent_id_from_path,
|
|
70
|
+
agent_start: transform::agent_start,
|
|
71
|
+
// No seeding. OpenClaw announces its model in a `model_change` RECORD
|
|
72
|
+
// written before the first user prompt (line 2 of the capture, against the
|
|
73
|
+
// first prompt on line 5), so the model reaches `TailState` through the
|
|
74
|
+
// ordinary line stream. That sidesteps the live-tail-versus-re-read
|
|
75
|
+
// divergence documented on `Format::seed_state` entirely: a session
|
|
76
|
+
// discovered while nearly empty and a full re-read of the finished file
|
|
77
|
+
// both learn the model at the same byte offset.
|
|
78
|
+
seed_state: no_seed_state,
|
|
79
|
+
agent_end: transform::agent_end,
|
|
80
|
+
transform_line: transform::transform_line,
|
|
81
|
+
reread: RereadPolicy::ByteCursor,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/// The agent id used when nothing can be derived from the path.
|
|
85
|
+
pub const DEFAULT_AGENT_ID: &str = "openclaw";
|
|
86
|
+
|
|
87
|
+
/// The directory a transcript must sit directly inside.
|
|
88
|
+
const SESSIONS_DIR: &str = "sessions";
|
|
89
|
+
/// The directory holding one subdirectory per agent.
|
|
90
|
+
const AGENTS_DIR: &str = "agents";
|
|
91
|
+
/// The OpenTelemetry trace written beside every transcript. 59x its size.
|
|
92
|
+
const TRAJECTORY_SUFFIX: &str = ".trajectory.jsonl";
|
|
93
|
+
|
|
94
|
+
fn is_uuid36(s: &str) -> bool {
|
|
95
|
+
s.len() == 36 && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Whether `path`'s immediate parent is the `sessions/` directory.
|
|
99
|
+
///
|
|
100
|
+
/// "Immediate" is load-bearing: the walk is recursive, and
|
|
101
|
+
/// `sessions/skills-prompts/sha256/<ab>/<hash>.txt` is a real subtree of
|
|
102
|
+
/// content-addressed prompt blobs. Requiring the parent — rather than any
|
|
103
|
+
/// ancestor — keeps a future blob that happens to be named `.jsonl` from being
|
|
104
|
+
/// tailed as if it were a conversation.
|
|
105
|
+
fn in_sessions_dir(path: &Path) -> bool {
|
|
106
|
+
path.parent()
|
|
107
|
+
.and_then(|p| p.file_name())
|
|
108
|
+
.is_some_and(|n| n == SESSIONS_DIR)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// A transcript is `<uuid>.jsonl` directly inside `sessions/`.
|
|
112
|
+
///
|
|
113
|
+
/// The directory walk is recursive, so this predicate carries the whole filter.
|
|
114
|
+
/// See the module docs for what each clause keeps out; the trajectory check is
|
|
115
|
+
/// first and by name because it is the one whose failure is measured in
|
|
116
|
+
/// hundreds of kilobytes per session rather than in wrong rows.
|
|
117
|
+
fn is_transcript(path: &Path) -> bool {
|
|
118
|
+
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
|
119
|
+
return false;
|
|
120
|
+
};
|
|
121
|
+
if name.ends_with(TRAJECTORY_SUFFIX) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
let Some(stem) = name.strip_suffix(".jsonl") else {
|
|
125
|
+
return false;
|
|
126
|
+
};
|
|
127
|
+
is_uuid36(stem) && in_sessions_dir(path)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// The filename stem IS the session id.
|
|
131
|
+
///
|
|
132
|
+
/// Deliberately the only source of it, even though the `session` header record
|
|
133
|
+
/// repeats it: two answers that can disagree is worse than one, and the path is
|
|
134
|
+
/// what the cursor keys on.
|
|
135
|
+
fn session_id_from_path(path: &Path) -> Option<String> {
|
|
136
|
+
let stem = path.file_name()?.to_str()?.strip_suffix(".jsonl")?;
|
|
137
|
+
is_uuid36(stem).then(|| stem.to_string())
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/// `openclaw-<agentId>`, from the `agents/<agentId>/sessions/` path shape.
|
|
141
|
+
///
|
|
142
|
+
/// NOT from the transcript's `cwd`, which is the agent's fixed workspace and
|
|
143
|
+
/// identical across every session it ever runs — see the module docs. The
|
|
144
|
+
/// header is accepted and ignored so the signature matches
|
|
145
|
+
/// [`crate::filetail::Format`].
|
|
146
|
+
///
|
|
147
|
+
/// The full `agents/<id>/sessions/` shape is required rather than searched for,
|
|
148
|
+
/// because a bare "find the component after `agents`" scan would pick the wrong
|
|
149
|
+
/// one for a state directory living under, say, `/home/agents/`. Returning
|
|
150
|
+
/// `None` when the shape does not hold leaves the engine to fall back to the
|
|
151
|
+
/// configured default id, which files the session under a plainly-generic agent
|
|
152
|
+
/// instead of a confidently wrong one.
|
|
153
|
+
fn agent_id_from_path(path: &Path, _header: &[String]) -> Option<String> {
|
|
154
|
+
let sessions = path.parent()?;
|
|
155
|
+
let agent_dir = sessions.parent()?;
|
|
156
|
+
if agent_dir.parent()?.file_name()? != AGENTS_DIR {
|
|
157
|
+
return None;
|
|
158
|
+
}
|
|
159
|
+
let id = transform::sanitize_id_part(agent_dir.file_name()?.to_str()?);
|
|
160
|
+
(!id.is_empty()).then(|| format!("openclaw-{id}"))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// Where transcripts live, honoring OpenClaw's own state-directory overrides.
|
|
164
|
+
///
|
|
165
|
+
/// Points at `agents/` rather than the state directory itself. The state
|
|
166
|
+
/// directory also holds `state/openclaw.sqlite` (964 KB on the capture) and the
|
|
167
|
+
/// agent's `workspace/`, which is a git checkout — re-walking a `.git` tree on
|
|
168
|
+
/// every poll is work that can never produce an event.
|
|
169
|
+
pub fn default_roots() -> Vec<PathBuf> {
|
|
170
|
+
let state = std::env::var_os("OPENCLAW_STATE_DIR")
|
|
171
|
+
.or_else(|| std::env::var_os("OPENCLAW_HOME"))
|
|
172
|
+
.map(PathBuf::from)
|
|
173
|
+
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".openclaw")))
|
|
174
|
+
.unwrap_or_else(|| PathBuf::from(".openclaw"));
|
|
175
|
+
vec![state.join(AGENTS_DIR)]
|
|
176
|
+
}
|