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,484 @@
|
|
|
1
|
+
//! Generic tailer for agents that keep sessions in append-structured JSONL.
|
|
2
|
+
//!
|
|
3
|
+
//! Everything format-specific lives behind [`Format`], a table of pure
|
|
4
|
+
//! functions each source supplies. The engine itself knows nothing about any
|
|
5
|
+
//! transcript grammar.
|
|
6
|
+
//!
|
|
7
|
+
//! # The invariant everything rests on
|
|
8
|
+
//!
|
|
9
|
+
//! **Every event is a pure function of one line plus its byte offset.** Nothing
|
|
10
|
+
//! is folded or aggregated across a poll window. So a live tail that happens to
|
|
11
|
+
//! split a turn across two polls produces byte-identical events to a single
|
|
12
|
+
//! full re-read, which is what lets the server's content-hash dedup collapse a
|
|
13
|
+
//! re-read instead of storing it twice. Any cross-line state a format needs is
|
|
14
|
+
//! persisted in the cursor ([`TailState`]) precisely so it holds the same value
|
|
15
|
+
//! at the same offset either way.
|
|
16
|
+
//!
|
|
17
|
+
//! # Not every source is append-only, and the ones that aren't lie quietly
|
|
18
|
+
//!
|
|
19
|
+
//! A byte cursor is correct only where files are strictly appended. Probing the
|
|
20
|
+
//! real CLIs showed two that are not, and neither announces itself:
|
|
21
|
+
//!
|
|
22
|
+
//! * **factory/droid** rewrites its first line in place when it names a
|
|
23
|
+
//! session, changing that line's length and shifting every later offset. Same
|
|
24
|
+
//! inode, and on a manual rename it restores the mtime — so neither
|
|
25
|
+
//! inode-watching nor a size+mtime check notices.
|
|
26
|
+
//! * **cursor** rewrites the whole file on the first write of every turn.
|
|
27
|
+
//!
|
|
28
|
+
//! [`RereadPolicy`] is how a format declares which of those it is, so the
|
|
29
|
+
//! engine can do the right thing instead of silently desyncing.
|
|
30
|
+
|
|
31
|
+
use std::path::{Path, PathBuf};
|
|
32
|
+
|
|
33
|
+
use serde_json::Value;
|
|
34
|
+
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
|
35
|
+
|
|
36
|
+
use crate::cursor::{CursorStore, FileCursor, TailState, identity};
|
|
37
|
+
use crate::spool::SpoolWriter;
|
|
38
|
+
use crate::supervisor::{Shutdown, TaskError};
|
|
39
|
+
|
|
40
|
+
/// Leading lines read once per file, and reused for every question that needs
|
|
41
|
+
/// the head of the transcript: the session's start event, its agent id, and any
|
|
42
|
+
/// state a format seeds before transforming its first line.
|
|
43
|
+
pub const HEADER_LINES: usize = 64;
|
|
44
|
+
/// Cap on the header read, so a transcript whose first line is enormous cannot
|
|
45
|
+
/// pull an unbounded amount into memory.
|
|
46
|
+
const HEADER_MAX_BYTES: u64 = 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
/// How a format's files change after they are written.
|
|
49
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
50
|
+
pub enum RereadPolicy {
|
|
51
|
+
/// Strictly appended. A byte offset is sufficient.
|
|
52
|
+
ByteCursor,
|
|
53
|
+
/// Appended, but the first line may be rewritten in place, moving every
|
|
54
|
+
/// later offset. The engine re-reads line 1 each poll and rebases the
|
|
55
|
+
/// cursor by the difference before reading further.
|
|
56
|
+
ValidatePrefix,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/// Identity shared by every event of one session. Immutable for the session's
|
|
60
|
+
/// life and persisted in the cursor, so it is identical across polls and
|
|
61
|
+
/// restarts.
|
|
62
|
+
#[derive(Debug, Clone, Default)]
|
|
63
|
+
pub struct Ctx {
|
|
64
|
+
pub session_id: String,
|
|
65
|
+
pub agent_id: String,
|
|
66
|
+
pub environment: String,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// The per-format adapter: a table of pure functions.
|
|
70
|
+
///
|
|
71
|
+
/// Function items coerce to these `fn` pointers, so a source declares a
|
|
72
|
+
/// `Format` const wiring its own helpers — no trait objects, no generics
|
|
73
|
+
/// rippling through the engine.
|
|
74
|
+
#[derive(Clone, Copy)]
|
|
75
|
+
pub struct Format {
|
|
76
|
+
/// Source kind. Becomes the derived batch filename prefix and log tag.
|
|
77
|
+
pub kind: &'static str,
|
|
78
|
+
/// True if this path is one of the format's transcripts.
|
|
79
|
+
///
|
|
80
|
+
/// The directory walk is recursive, so this predicate carries the whole
|
|
81
|
+
/// filter — including excluding sibling files that must never be tailed.
|
|
82
|
+
pub is_source_file: fn(&Path) -> bool,
|
|
83
|
+
/// Session id from the path. `None` falls back to a synthetic id derived
|
|
84
|
+
/// from the inode, with a warning.
|
|
85
|
+
pub session_id_from_path: fn(&Path) -> Option<String>,
|
|
86
|
+
/// Agent id from the path and/or the header lines.
|
|
87
|
+
///
|
|
88
|
+
/// Takes the header because a path is not always enough: several CLIs
|
|
89
|
+
/// encode their working directory into a folder name lossily, and the real
|
|
90
|
+
/// value is inside the file.
|
|
91
|
+
pub agent_id_from_path: fn(&Path, &[String]) -> Option<String>,
|
|
92
|
+
/// Build the session's `agent_start` from its header lines, plus the
|
|
93
|
+
/// timestamp that seeds the cursor.
|
|
94
|
+
///
|
|
95
|
+
/// Must produce one whenever the file is a real session: the server selects
|
|
96
|
+
/// sessions on this event, so a session without one never appears in the
|
|
97
|
+
/// product at all, and its `agent_end` is gated on the same flag.
|
|
98
|
+
pub agent_start: AgentStartFn,
|
|
99
|
+
/// Prime carried state from the header before the first line is
|
|
100
|
+
/// transformed.
|
|
101
|
+
///
|
|
102
|
+
/// ⚠️ Not dedup-safe between a live tail and a later full re-read: this
|
|
103
|
+
/// runs when the file is first discovered, which for a live session is
|
|
104
|
+
/// while it is nearly empty, so it seeds nothing — whereas re-reading the
|
|
105
|
+
/// finished file seeds from a header that has since grown. Only wire it up
|
|
106
|
+
/// where a blank first row is worse than an occasional duplicate.
|
|
107
|
+
pub seed_state: fn(&[String], &mut TailState),
|
|
108
|
+
/// The single `agent_end`, derived deterministically from the last
|
|
109
|
+
/// timestamp and the file size.
|
|
110
|
+
pub agent_end: fn(&Ctx, &str, u64) -> Value,
|
|
111
|
+
/// One content line to its timestamp and the events it yields.
|
|
112
|
+
///
|
|
113
|
+
/// `None` for the timestamp means the line was skipped. Must stay a pure
|
|
114
|
+
/// function of `(line, ctx, offset, state)`.
|
|
115
|
+
pub transform_line: TransformLineFn,
|
|
116
|
+
pub reread: RereadPolicy,
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Builds a session's `agent_start` from its header, plus the timestamp that
|
|
120
|
+
/// seeds the cursor.
|
|
121
|
+
pub type AgentStartFn = fn(&[String], &Ctx, u64) -> Option<(Value, Option<String>)>;
|
|
122
|
+
/// Transforms one content line into its timestamp and the events it yields.
|
|
123
|
+
pub type TransformLineFn = fn(&str, &Ctx, u64, &mut TailState) -> (Option<String>, Vec<Value>);
|
|
124
|
+
|
|
125
|
+
/// A format that carries no cross-line state.
|
|
126
|
+
pub fn no_seed_state(_lines: &[String], _state: &mut TailState) {}
|
|
127
|
+
|
|
128
|
+
/// Runtime knobs, built by a source from its resolved config.
|
|
129
|
+
#[derive(Debug, Clone)]
|
|
130
|
+
pub struct Params {
|
|
131
|
+
pub agent_id: String,
|
|
132
|
+
pub environment: String,
|
|
133
|
+
/// Minutes a file must be idle at EOF before `agent_end` is emitted.
|
|
134
|
+
pub end_idle_mins: u64,
|
|
135
|
+
pub max_read_bytes: u64,
|
|
136
|
+
pub max_batch_bytes: u64,
|
|
137
|
+
/// Skip files older than this many days on first discovery. `None` reads
|
|
138
|
+
/// everything, which on a normal machine is gigabytes of history.
|
|
139
|
+
pub since_days: Option<u64>,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
pub struct Spec {
|
|
143
|
+
pub format: Format,
|
|
144
|
+
pub roots: Vec<PathBuf>,
|
|
145
|
+
pub spool_dir: PathBuf,
|
|
146
|
+
pub state_dir: PathBuf,
|
|
147
|
+
pub poll_interval: std::time::Duration,
|
|
148
|
+
pub params: Params,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Tail every discovered file until shutdown.
|
|
152
|
+
pub async fn run(spec: Spec, sd: Shutdown) -> Result<(), TaskError> {
|
|
153
|
+
let mut cursors = CursorStore::load(spec.state_dir.clone());
|
|
154
|
+
|
|
155
|
+
// Log which roots actually exist. Without this an absent root is
|
|
156
|
+
// indistinguishable from a quiet one, and a source that captures nothing
|
|
157
|
+
// reads exactly as healthy as one that captures everything.
|
|
158
|
+
for root in &spec.roots {
|
|
159
|
+
tracing::info!(
|
|
160
|
+
source = spec.format.kind,
|
|
161
|
+
root = %root.display(),
|
|
162
|
+
present = root.exists(),
|
|
163
|
+
"file source root"
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
tracing::info!(
|
|
167
|
+
source = spec.format.kind,
|
|
168
|
+
resumed = cursors.len(),
|
|
169
|
+
"file source started"
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
loop {
|
|
173
|
+
match poll_once(&spec, &mut cursors).await {
|
|
174
|
+
Ok(events) => crate::health::report_poll(
|
|
175
|
+
spec.format.kind,
|
|
176
|
+
spec.roots.iter().any(|r| r.exists()),
|
|
177
|
+
events,
|
|
178
|
+
cursors.len() as u64,
|
|
179
|
+
),
|
|
180
|
+
Err(err) => {
|
|
181
|
+
tracing::warn!(source = spec.format.kind, %err, "poll failed; retrying next tick");
|
|
182
|
+
crate::health::report_error(spec.format.kind, &err.to_string());
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if !sd.sleep(spec.poll_interval).await {
|
|
186
|
+
return Ok(());
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/// One pass. Returns how many events were spooled, for the health record.
|
|
192
|
+
async fn poll_once(spec: &Spec, cursors: &mut CursorStore) -> Result<u64, TaskError> {
|
|
193
|
+
let files = discover(&spec.roots, spec.format.is_source_file).await;
|
|
194
|
+
let mut events = 0u64;
|
|
195
|
+
for path in files {
|
|
196
|
+
match process_file(spec, cursors, &path).await {
|
|
197
|
+
Ok(n) => events += n,
|
|
198
|
+
// Per-file, so one malformed transcript cannot stop the others.
|
|
199
|
+
Err(err) => tracing::warn!(file = %path.display(), %err, "could not process file"),
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
cursors.retain_existing();
|
|
203
|
+
cursors.save().map_err(io_err)?;
|
|
204
|
+
Ok(events)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/// Tail one file. Returns how many events it spooled, for the health record.
|
|
208
|
+
async fn process_file(
|
|
209
|
+
spec: &Spec,
|
|
210
|
+
cursors: &mut CursorStore,
|
|
211
|
+
path: &Path,
|
|
212
|
+
) -> Result<u64, TaskError> {
|
|
213
|
+
let meta = tokio::fs::metadata(path).await.map_err(io_err)?;
|
|
214
|
+
let (dev, inode) = identity(&meta);
|
|
215
|
+
let size = meta.len();
|
|
216
|
+
|
|
217
|
+
let mut cursor = match cursors.resume(dev, inode, path) {
|
|
218
|
+
Some(c) => c.clone(),
|
|
219
|
+
None => match new_cursor(spec, path, dev, inode, &meta).await? {
|
|
220
|
+
Some(c) => c,
|
|
221
|
+
None => return Ok(0), // outside the backfill window
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
cursor.path = path.to_path_buf();
|
|
225
|
+
|
|
226
|
+
// A file that shrank was replaced or truncated; its offsets mean nothing.
|
|
227
|
+
if size < cursor.offset {
|
|
228
|
+
tracing::warn!(file = %path.display(), size, offset = cursor.offset, "file shrank; re-reading");
|
|
229
|
+
cursor.offset = 0;
|
|
230
|
+
cursor.state = TailState::default();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if spec.format.reread == RereadPolicy::ValidatePrefix {
|
|
234
|
+
rebase_on_first_line(path, &mut cursor).await;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let ctx = Ctx {
|
|
238
|
+
session_id: cursor.session_id.clone().unwrap_or_default(),
|
|
239
|
+
agent_id: cursor
|
|
240
|
+
.agent_id
|
|
241
|
+
.clone()
|
|
242
|
+
.unwrap_or_else(|| spec.params.agent_id.clone()),
|
|
243
|
+
environment: spec.params.environment.clone(),
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
let mut writer = SpoolWriter::new(
|
|
247
|
+
spec.spool_dir.clone(),
|
|
248
|
+
spec.params.max_batch_bytes,
|
|
249
|
+
spec.format.kind,
|
|
250
|
+
&ctx.session_id,
|
|
251
|
+
);
|
|
252
|
+
let mut emitted = 0u64;
|
|
253
|
+
|
|
254
|
+
// A session with no start event never appears in the product, so retry it
|
|
255
|
+
// on every poll until it succeeds rather than only at discovery.
|
|
256
|
+
if !cursor.agent_start_emitted {
|
|
257
|
+
let header = read_header(path).await.unwrap_or_default();
|
|
258
|
+
if let Some((event, ts)) = (spec.format.agent_start)(&header, &ctx, 0) {
|
|
259
|
+
writer.push(event).await.map_err(io_err)?;
|
|
260
|
+
emitted += 1;
|
|
261
|
+
cursor.agent_start_emitted = true;
|
|
262
|
+
if cursor.last_ts.is_none() {
|
|
263
|
+
cursor.last_ts = ts;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Growth resumes a session that had already ended, so it can end again
|
|
269
|
+
// rather than freezing at its first end time.
|
|
270
|
+
if cursor.ended && size > cursor.size_seen {
|
|
271
|
+
cursor.ended = false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let mut consumed = 0u64;
|
|
275
|
+
if size > cursor.offset {
|
|
276
|
+
let read_end = size.min(cursor.offset + spec.params.max_read_bytes);
|
|
277
|
+
let chunk = read_range(path, cursor.offset, read_end)
|
|
278
|
+
.await
|
|
279
|
+
.map_err(io_err)?;
|
|
280
|
+
|
|
281
|
+
// Hold back a partial final line: half a JSON object parses as nothing,
|
|
282
|
+
// and consuming it would skip the record when it completes.
|
|
283
|
+
if let Some(last_nl) = chunk.iter().rposition(|b| *b == b'\n') {
|
|
284
|
+
let usable = &chunk[..=last_nl];
|
|
285
|
+
let mut line_offset = cursor.offset;
|
|
286
|
+
for line in usable.split_inclusive(|b| *b == b'\n') {
|
|
287
|
+
let at = line_offset;
|
|
288
|
+
line_offset += line.len() as u64;
|
|
289
|
+
let Ok(text) = std::str::from_utf8(line) else {
|
|
290
|
+
continue;
|
|
291
|
+
};
|
|
292
|
+
let text = text.trim();
|
|
293
|
+
if text.is_empty() {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
let (ts, events) = (spec.format.transform_line)(text, &ctx, at, &mut cursor.state);
|
|
297
|
+
if let Some(ts) = ts {
|
|
298
|
+
cursor.last_ts = Some(ts);
|
|
299
|
+
}
|
|
300
|
+
for e in events {
|
|
301
|
+
writer.push(e).await.map_err(io_err)?;
|
|
302
|
+
emitted += 1;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
consumed = usable.len() as u64;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// `agent_end` once the file is fully read, has actually started, and has
|
|
310
|
+
// been idle long enough that more writes are unlikely.
|
|
311
|
+
let at_eof = cursor.offset + consumed >= size;
|
|
312
|
+
if !cursor.ended
|
|
313
|
+
&& at_eof
|
|
314
|
+
&& cursor.agent_start_emitted
|
|
315
|
+
&& let Some(last_ts) = cursor.last_ts.clone()
|
|
316
|
+
&& is_idle(&meta, spec.params.end_idle_mins)
|
|
317
|
+
{
|
|
318
|
+
writer
|
|
319
|
+
.push((spec.format.agent_end)(&ctx, &last_ts, size))
|
|
320
|
+
.await
|
|
321
|
+
.map_err(io_err)?;
|
|
322
|
+
cursor.ended = true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Flush BEFORE advancing the cursor. A crash between the two costs a
|
|
326
|
+
// re-ship the server dedups; the other order loses events outright.
|
|
327
|
+
writer.flush().await.map_err(io_err)?;
|
|
328
|
+
|
|
329
|
+
cursor.offset += consumed;
|
|
330
|
+
cursor.size_seen = size;
|
|
331
|
+
cursors.set(cursor);
|
|
332
|
+
Ok(emitted)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/// Build a cursor for a newly discovered file, or `None` to skip it.
|
|
336
|
+
async fn new_cursor(
|
|
337
|
+
spec: &Spec,
|
|
338
|
+
path: &Path,
|
|
339
|
+
dev: u64,
|
|
340
|
+
inode: u64,
|
|
341
|
+
meta: &std::fs::Metadata,
|
|
342
|
+
) -> Result<Option<FileCursor>, TaskError> {
|
|
343
|
+
if let Some(days) = spec.params.since_days
|
|
344
|
+
&& !within_days(meta, days)
|
|
345
|
+
{
|
|
346
|
+
return Ok(None);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let header = read_header(path).await.unwrap_or_default();
|
|
350
|
+
let session_id = (spec.format.session_id_from_path)(path).unwrap_or_else(|| {
|
|
351
|
+
// A synthetic id keeps the session distinct rather than merging it with
|
|
352
|
+
// another, but it will not match anything else, so say so.
|
|
353
|
+
tracing::warn!(
|
|
354
|
+
file = %path.display(),
|
|
355
|
+
"no session id in the path; using a synthetic one that will not correlate"
|
|
356
|
+
);
|
|
357
|
+
format!("{}-{dev}-{inode}", spec.format.kind)
|
|
358
|
+
});
|
|
359
|
+
let agent_id = (spec.format.agent_id_from_path)(path, &header)
|
|
360
|
+
.unwrap_or_else(|| spec.params.agent_id.clone());
|
|
361
|
+
|
|
362
|
+
let mut state = TailState::default();
|
|
363
|
+
(spec.format.seed_state)(&header, &mut state);
|
|
364
|
+
|
|
365
|
+
Ok(Some(FileCursor {
|
|
366
|
+
path: path.to_path_buf(),
|
|
367
|
+
dev,
|
|
368
|
+
inode,
|
|
369
|
+
offset: 0,
|
|
370
|
+
size_seen: 0,
|
|
371
|
+
session_id: Some(session_id),
|
|
372
|
+
agent_id: Some(agent_id),
|
|
373
|
+
agent_start_emitted: false,
|
|
374
|
+
ended: false,
|
|
375
|
+
last_ts: None,
|
|
376
|
+
first_line_len: first_line_len(&header),
|
|
377
|
+
state,
|
|
378
|
+
}))
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/// Re-read line 1 and shift the cursor by any change in its length.
|
|
382
|
+
///
|
|
383
|
+
/// factory/droid rewrites that line in place when it names a session. The file
|
|
384
|
+
/// keeps growing normally, but every offset past line 1 moves — so a cursor
|
|
385
|
+
/// left alone resumes mid-record and produces garbage from then on. Observed
|
|
386
|
+
/// live: line 1 grew 191 → 268 bytes while the file grew 191 → 9145.
|
|
387
|
+
async fn rebase_on_first_line(path: &Path, cursor: &mut FileCursor) {
|
|
388
|
+
let Ok(header) = read_header(path).await else {
|
|
389
|
+
return;
|
|
390
|
+
};
|
|
391
|
+
let Some(now_len) = first_line_len(&header) else {
|
|
392
|
+
return;
|
|
393
|
+
};
|
|
394
|
+
let Some(was_len) = cursor.first_line_len else {
|
|
395
|
+
cursor.first_line_len = Some(now_len);
|
|
396
|
+
return;
|
|
397
|
+
};
|
|
398
|
+
if now_len == was_len {
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let delta = now_len as i64 - was_len as i64;
|
|
403
|
+
let rebased = (cursor.offset as i64 + delta).max(0) as u64;
|
|
404
|
+
tracing::info!(
|
|
405
|
+
file = %path.display(),
|
|
406
|
+
was_len, now_len, delta,
|
|
407
|
+
old_offset = cursor.offset, new_offset = rebased,
|
|
408
|
+
"first line was rewritten in place; rebasing the cursor"
|
|
409
|
+
);
|
|
410
|
+
cursor.offset = rebased;
|
|
411
|
+
cursor.first_line_len = Some(now_len);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
fn first_line_len(header: &[String]) -> Option<u64> {
|
|
415
|
+
header.first().map(|l| l.len() as u64 + 1) // +1 for the newline
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/// Files under `roots` matching `predicate`, walked iteratively.
|
|
419
|
+
///
|
|
420
|
+
/// Missing or unreadable directories are skipped silently: the agent may
|
|
421
|
+
/// simply not be installed, which is not an error.
|
|
422
|
+
async fn discover(roots: &[PathBuf], predicate: fn(&Path) -> bool) -> Vec<PathBuf> {
|
|
423
|
+
let mut out = Vec::new();
|
|
424
|
+
let mut stack: Vec<PathBuf> = roots.to_vec();
|
|
425
|
+
while let Some(dir) = stack.pop() {
|
|
426
|
+
let Ok(mut rd) = tokio::fs::read_dir(&dir).await else {
|
|
427
|
+
continue;
|
|
428
|
+
};
|
|
429
|
+
while let Ok(Some(entry)) = rd.next_entry().await {
|
|
430
|
+
let path = entry.path();
|
|
431
|
+
match entry.file_type().await {
|
|
432
|
+
Ok(ft) if ft.is_dir() => stack.push(path),
|
|
433
|
+
Ok(ft) if ft.is_file() && predicate(&path) => out.push(path),
|
|
434
|
+
_ => {}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
out.sort();
|
|
439
|
+
out
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async fn read_header(path: &Path) -> std::io::Result<Vec<String>> {
|
|
443
|
+
let chunk = read_range(path, 0, HEADER_MAX_BYTES).await?;
|
|
444
|
+
Ok(String::from_utf8_lossy(&chunk)
|
|
445
|
+
.lines()
|
|
446
|
+
.take(HEADER_LINES)
|
|
447
|
+
.map(|s| s.to_string())
|
|
448
|
+
.collect())
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/// Read `[from, to)`, tolerating a file shorter than `to`.
|
|
452
|
+
async fn read_range(path: &Path, from: u64, to: u64) -> std::io::Result<Vec<u8>> {
|
|
453
|
+
let mut f = tokio::fs::File::open(path).await?;
|
|
454
|
+
f.seek(std::io::SeekFrom::Start(from)).await?;
|
|
455
|
+
let mut buf = Vec::new();
|
|
456
|
+
f.take(to.saturating_sub(from))
|
|
457
|
+
.read_to_end(&mut buf)
|
|
458
|
+
.await?;
|
|
459
|
+
Ok(buf)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
fn is_idle(meta: &std::fs::Metadata, mins: u64) -> bool {
|
|
463
|
+
let Ok(modified) = meta.modified() else {
|
|
464
|
+
return false;
|
|
465
|
+
};
|
|
466
|
+
std::time::SystemTime::now()
|
|
467
|
+
.duration_since(modified)
|
|
468
|
+
.map(|d| d.as_secs() >= mins.saturating_mul(60))
|
|
469
|
+
.unwrap_or(false)
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
fn within_days(meta: &std::fs::Metadata, days: u64) -> bool {
|
|
473
|
+
let Ok(modified) = meta.modified() else {
|
|
474
|
+
return true; // unreadable mtime: include rather than silently drop
|
|
475
|
+
};
|
|
476
|
+
std::time::SystemTime::now()
|
|
477
|
+
.duration_since(modified)
|
|
478
|
+
.map(|d| d.as_secs() <= days.saturating_mul(86_400))
|
|
479
|
+
.unwrap_or(true)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
fn io_err(e: std::io::Error) -> TaskError {
|
|
483
|
+
TaskError::from(e.to_string())
|
|
484
|
+
}
|