failproofai 1.0.0-beta.2 → 1.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  46. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  47. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  48. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  54. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  55. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  58. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  67. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  68. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  75. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  93. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  94. package/.next/standalone/.next/server/pages/404.html +1 -1
  95. package/.next/standalone/.next/server/pages/500.html +1 -1
  96. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  97. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  98. package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  104. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  109. package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
  110. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  111. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  112. package/.next/standalone/Cargo.lock +1992 -198
  113. package/.next/standalone/Cargo.toml +1 -1
  114. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  115. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  116. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  117. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  118. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  119. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  120. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  121. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  122. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  123. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  124. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  125. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  126. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  127. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  128. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  129. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  130. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  131. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  132. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  133. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  134. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  135. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  136. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  137. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  138. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  139. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  140. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  141. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  142. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  143. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  144. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  145. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  146. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  147. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  148. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  149. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  150. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  151. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  152. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  153. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  154. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  155. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  156. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  157. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  158. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  159. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  160. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  161. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  162. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  163. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  164. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  165. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  166. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  167. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  168. package/.next/standalone/package.json +2 -2
  169. package/.next/standalone/server.js +1 -1
  170. package/bin/failproofai.mjs +129 -0
  171. package/dist/cli.mjs +2195 -770
  172. package/dist/worker.mjs +365 -52
  173. package/package.json +2 -2
  174. package/src/hooks/builtin-policies.ts +139 -0
  175. package/src/hooks/cloud-connection.ts +205 -0
  176. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  177. package/src/hooks/cloud-enrollment.ts +173 -0
  178. package/src/hooks/cloud-managed-policies.ts +138 -0
  179. package/src/hooks/collector-config.ts +226 -0
  180. package/src/hooks/configure-wizard.ts +221 -5
  181. package/src/hooks/custom-hooks-loader.ts +69 -25
  182. package/src/hooks/daemon-client.ts +18 -0
  183. package/src/hooks/handler.ts +144 -6
  184. package/src/hooks/hook-activity-store.ts +50 -0
  185. package/src/hooks/loader-utils.ts +6 -4
  186. package/src/hooks/policy-types.ts +35 -0
  187. package/src/hooks/session-pause-cli.ts +157 -0
  188. package/src/hooks/session-pause.ts +236 -0
  189. package/src/hooks/tui.ts +115 -0
  190. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  191. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  192. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  193. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  194. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
  195. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
@@ -0,0 +1,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
+ }