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,387 @@
1
+ //! Generic poller for agents that keep sessions in SQLite.
2
+ //!
3
+ //! Everything format-specific lives behind [`SqliteFormat`]; the engine owns
4
+ //! the connection, the watermark, and the spooling.
5
+ //!
6
+ //! # A rowid watermark is NOT sufficient, and that is the whole design
7
+ //!
8
+ //! The obvious poller reads `WHERE rowid > :last` and calls it done. Probing
9
+ //! the real databases showed that is wrong for every one of them, in three
10
+ //! different ways:
11
+ //!
12
+ //! * **opencode mutates rows for ~12 seconds after inserting them.** A tool
13
+ //! part appears with `status:"running"` and an empty output, and the actual
14
+ //! result is written by a later `UPDATE` to the same row. A rowid watermark
15
+ //! ships every tool call with no result, forever, and looks like it is
16
+ //! working.
17
+ //! * **hermes deletes rows.** Rewind and compaction issue `DELETE FROM
18
+ //! messages`, so rowids are not merely non-contiguous, the history behind the
19
+ //! watermark changes.
20
+ //! * **goose has `UPDATE messages SET content_json` and rewind deletes** in its
21
+ //! binary, even though a happy-path session only appends.
22
+ //!
23
+ //! So a format declares its [`Watermark`], and the engine asks for rows the way
24
+ //! that format actually orders its changes.
25
+ //!
26
+ //! # Open read-only, and never `immutable`
27
+ //!
28
+ //! `immutable=1` tells SQLite the file cannot change, which makes it skip the
29
+ //! WAL entirely. That is catastrophic rather than merely stale here: goose's
30
+ //! `sessions.db` main file contains **zero tables**, with all 1.25 MB of schema
31
+ //! and rows sitting in an uncheckpointed `-wal`. Opening it `immutable` reads an
32
+ //! empty database and reports success. `SQLITE_OPEN_READ_ONLY` alone reads the
33
+ //! main file and its WAL together, which is what we want.
34
+ //!
35
+ //! Verified live: a reader polling opencode's database at 4 Hz throughout a
36
+ //! 12-second tool call, while opencode was actively writing, saw zero
37
+ //! `SQLITE_BUSY`.
38
+ //!
39
+ //! # All DB work happens on `spawn_blocking`
40
+ //!
41
+ //! `rusqlite` is synchronous. The connection is created and dropped inside the
42
+ //! closure and never crosses an `await`, so it needs no `Send` bound and cannot
43
+ //! be held across a yield point.
44
+
45
+ use std::path::{Path, PathBuf};
46
+
47
+ use rusqlite::{Connection, OpenFlags};
48
+ use serde_json::Value;
49
+
50
+ use crate::cursor::{CursorStore, FileCursor};
51
+ use crate::spool::SpoolWriter;
52
+ use crate::supervisor::{Shutdown, TaskError};
53
+
54
+ /// How long to wait for a writer's lock before giving up on a query.
55
+ const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
56
+
57
+ /// How a format orders the changes the poller must pick up.
58
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
59
+ pub enum Watermark {
60
+ /// Monotonic insert id. Only correct where rows are never updated after
61
+ /// insert AND never deleted.
62
+ RowId,
63
+ /// A last-modified column, so an updated row is re-read. Required for
64
+ /// opencode, whose tool rows are filled in ~12s after they appear.
65
+ UpdatedAt,
66
+ }
67
+
68
+ /// What one poll asked for and what it produced.
69
+ #[derive(Debug, Default)]
70
+ pub struct PollOutcome {
71
+ pub events: Vec<Value>,
72
+ /// The new watermark. Advanced only after the events are durably spooled.
73
+ pub watermark: i64,
74
+ /// True when the format hit its row limit, so the caller polls again
75
+ /// immediately instead of sleeping — a backlog should drain, not trickle.
76
+ pub more: bool,
77
+ /// Rows examined. Reported so a poll that read a lot and emitted nothing is
78
+ /// distinguishable from one that read nothing at all.
79
+ pub rows_seen: u64,
80
+ }
81
+
82
+ /// One poll's inputs.
83
+ #[derive(Debug, Clone)]
84
+ pub struct PollRequest {
85
+ pub watermark: i64,
86
+ pub max_rows: u64,
87
+ pub environment: String,
88
+ pub agent_id: String,
89
+ }
90
+
91
+ /// The per-format adapter.
92
+ #[derive(Clone, Copy)]
93
+ pub struct SqliteFormat {
94
+ /// Source kind — the spool filename prefix and log tag.
95
+ pub kind: &'static str,
96
+ pub watermark: Watermark,
97
+ /// Read rows past the watermark and turn them into events.
98
+ ///
99
+ /// Runs on a blocking thread with a read-only connection. Must be pure with
100
+ /// respect to `(connection contents, request)`: the server dedups on a
101
+ /// content hash, so re-reading the same rows must produce the same bytes.
102
+ pub poll: fn(&Connection, &PollRequest) -> rusqlite::Result<PollOutcome>,
103
+ }
104
+
105
+ #[derive(Debug, Clone)]
106
+ pub struct Params {
107
+ pub agent_id: String,
108
+ pub environment: String,
109
+ pub max_rows_per_poll: u64,
110
+ pub max_batch_bytes: u64,
111
+ /// Poll passes before yielding to the shutdown check. Bounds how long a
112
+ /// backlog drain can ignore a stop request.
113
+ pub max_drain_passes: u32,
114
+ }
115
+
116
+ pub struct Spec {
117
+ pub format: SqliteFormat,
118
+ pub db_path: PathBuf,
119
+ pub spool_dir: PathBuf,
120
+ pub state_dir: PathBuf,
121
+ pub poll_interval: std::time::Duration,
122
+ pub params: Params,
123
+ }
124
+
125
+ /// Poll until shutdown.
126
+ pub async fn run(spec: Spec, sd: Shutdown) -> Result<(), TaskError> {
127
+ let mut cursors = CursorStore::load(spec.state_dir.clone());
128
+ tracing::info!(
129
+ source = spec.format.kind,
130
+ db = %spec.db_path.display(),
131
+ present = spec.db_path.exists(),
132
+ watermark = ?spec.format.watermark,
133
+ "sqlite source started"
134
+ );
135
+
136
+ loop {
137
+ for _ in 0..spec.params.max_drain_passes.max(1) {
138
+ if sd.is_set() {
139
+ return Ok(());
140
+ }
141
+ match poll_once(&spec, &mut cursors).await {
142
+ Ok((events, more)) => {
143
+ crate::health::report_poll(
144
+ spec.format.kind,
145
+ spec.db_path.exists(),
146
+ events,
147
+ cursors.len() as u64,
148
+ );
149
+ if !more {
150
+ break; // nothing more waiting
151
+ }
152
+ // Hit the row limit — drain rather than trickle.
153
+ }
154
+ Err(err) => {
155
+ tracing::warn!(source = spec.format.kind, %err, "poll failed; retrying next tick");
156
+ crate::health::report_error(spec.format.kind, &err.to_string());
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ if !sd.sleep(spec.poll_interval).await {
162
+ return Ok(());
163
+ }
164
+ }
165
+ }
166
+
167
+ /// One pass. Returns `(events spooled, more rows waiting)`.
168
+ async fn poll_once(spec: &Spec, cursors: &mut CursorStore) -> Result<(u64, bool), TaskError> {
169
+ if !spec.db_path.exists() {
170
+ // Normal: the agent simply is not installed.
171
+ return Ok((0, false));
172
+ }
173
+
174
+ // The cursor store is keyed on (dev, inode) for file tailers. A database is
175
+ // one logical stream, so a fixed synthetic key keeps one entry per source
176
+ // regardless of the file being replaced underneath us (a vacuum or a
177
+ // restore changes the inode without changing the data).
178
+ let key_inode = 0u64;
179
+ let watermark = cursors
180
+ .resume(0, key_inode, &spec.db_path)
181
+ .map(|c| c.offset as i64)
182
+ .unwrap_or(0);
183
+
184
+ let request = PollRequest {
185
+ watermark,
186
+ max_rows: spec.params.max_rows_per_poll,
187
+ environment: spec.params.environment.clone(),
188
+ agent_id: spec.params.agent_id.clone(),
189
+ };
190
+
191
+ let db_path = spec.db_path.clone();
192
+ let poll = spec.format.poll;
193
+ // The connection is created and dropped INSIDE the closure, so it never
194
+ // crosses an await and needs no Send bound.
195
+ let outcome = tokio::task::spawn_blocking(move || -> rusqlite::Result<PollOutcome> {
196
+ let conn = open_readonly(&db_path)?;
197
+ poll(&conn, &request)
198
+ })
199
+ .await
200
+ .map_err(|e| TaskError::from(format!("sqlite poll task failed: {e}")))?
201
+ .map_err(|e| TaskError::from(e.to_string()))?;
202
+
203
+ if outcome.events.is_empty() {
204
+ // Still advance the watermark: a pass that legitimately produced no
205
+ // events (rows filtered out, say) must not re-read them forever.
206
+ if outcome.watermark > watermark {
207
+ store_watermark(cursors, spec, outcome.watermark)?;
208
+ }
209
+ return Ok((0, outcome.more));
210
+ }
211
+
212
+ let mut writer = SpoolWriter::new(
213
+ spec.spool_dir.clone(),
214
+ spec.params.max_batch_bytes,
215
+ spec.format.kind,
216
+ spec.format.kind,
217
+ );
218
+ let emitted = outcome.events.len() as u64;
219
+ for event in outcome.events {
220
+ writer.push(event).await.map_err(io_err)?;
221
+ }
222
+ // Flush BEFORE advancing. A crash between the two costs a re-ship the
223
+ // server dedups; the other order loses the rows outright.
224
+ writer.flush().await.map_err(io_err)?;
225
+ store_watermark(cursors, spec, outcome.watermark)?;
226
+
227
+ Ok((emitted, outcome.more))
228
+ }
229
+
230
+ fn store_watermark(
231
+ cursors: &mut CursorStore,
232
+ spec: &Spec,
233
+ watermark: i64,
234
+ ) -> Result<(), TaskError> {
235
+ cursors.set(FileCursor {
236
+ path: spec.db_path.clone(),
237
+ dev: 0,
238
+ inode: 0,
239
+ offset: watermark.max(0) as u64,
240
+ size_seen: 0,
241
+ ..Default::default()
242
+ });
243
+ cursors.save().map_err(io_err)
244
+ }
245
+
246
+ /// Open read-only, WAL-aware, with a busy timeout.
247
+ ///
248
+ /// `SQLITE_OPEN_READ_ONLY` and deliberately NOT a `?immutable=1` URI — see the
249
+ /// module docs for why that would read goose's database as empty.
250
+ pub fn open_readonly(path: &Path) -> rusqlite::Result<Connection> {
251
+ let conn = Connection::open_with_flags(
252
+ path,
253
+ OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
254
+ )?;
255
+ conn.busy_timeout(BUSY_TIMEOUT)?;
256
+ Ok(conn)
257
+ }
258
+
259
+ fn io_err(e: std::io::Error) -> TaskError {
260
+ TaskError::from(e.to_string())
261
+ }
262
+
263
+ /// Coerce a timestamp column to epoch milliseconds.
264
+ ///
265
+ /// The four SQLite agents disagree on units: hermes writes REAL epoch SECONDS,
266
+ /// goose an INTEGER epoch seconds, opencode INTEGER milliseconds. Guessing from
267
+ /// magnitude is the only thing that works across all of them without a
268
+ /// per-source flag that would be wrong the first time a vendor changes.
269
+ pub fn epoch_to_millis(v: f64) -> i64 {
270
+ if v > 1e12 {
271
+ v as i64 // already milliseconds
272
+ } else if v > 1e9 {
273
+ (v * 1000.0) as i64 // seconds
274
+ } else {
275
+ 0 // implausible; caller decides what to do with it
276
+ }
277
+ }
278
+
279
+ #[cfg(test)]
280
+ mod tests {
281
+ use super::*;
282
+
283
+ #[test]
284
+ fn timestamp_units_are_inferred_from_magnitude() {
285
+ // hermes REAL seconds, goose INTEGER seconds, opencode INTEGER millis.
286
+ assert_eq!(epoch_to_millis(1_785_744_251.686), 1_785_744_251_686);
287
+ assert_eq!(epoch_to_millis(1_785_744_251.0), 1_785_744_251_000);
288
+ assert_eq!(epoch_to_millis(1_785_744_251_686.0), 1_785_744_251_686);
289
+ // Implausible values are reported as zero rather than silently becoming
290
+ // 1970, which would put events at the start of every timeline.
291
+ assert_eq!(epoch_to_millis(0.0), 0);
292
+ assert_eq!(epoch_to_millis(42.0), 0);
293
+ }
294
+
295
+ #[test]
296
+ fn a_read_only_connection_cannot_write() {
297
+ // The guarantee that matters: the daemon reads other products'
298
+ // databases and must never be able to corrupt one.
299
+ let dir = std::env::temp_dir().join(format!("fpai-sq-{}", std::process::id()));
300
+ std::fs::create_dir_all(&dir).unwrap();
301
+ let db = dir.join("t.db");
302
+ {
303
+ let c = Connection::open(&db).unwrap();
304
+ c.execute_batch("CREATE TABLE t(a); INSERT INTO t VALUES (1);")
305
+ .unwrap();
306
+ }
307
+ let ro = open_readonly(&db).unwrap();
308
+ assert_eq!(
309
+ ro.query_row("SELECT count(*) FROM t", [], |r| r.get::<_, i64>(0))
310
+ .unwrap(),
311
+ 1
312
+ );
313
+ assert!(
314
+ ro.execute("INSERT INTO t VALUES (2)", []).is_err(),
315
+ "a read-only handle must refuse writes"
316
+ );
317
+ std::fs::remove_dir_all(&dir).ok();
318
+ }
319
+
320
+ #[test]
321
+ fn a_wal_database_is_read_through_its_wal_not_just_the_main_file() {
322
+ // goose's main DB file contains ZERO tables, with everything in an
323
+ // uncheckpointed -wal. A reader that only sees the main file reads an
324
+ // empty database and reports success.
325
+ let dir = std::env::temp_dir().join(format!("fpai-sqwal-{}", std::process::id()));
326
+ std::fs::create_dir_all(&dir).unwrap();
327
+ let db = dir.join("wal.db");
328
+ let writer = Connection::open(&db).unwrap();
329
+ writer.pragma_update(None, "journal_mode", "WAL").unwrap();
330
+ writer
331
+ .execute_batch("CREATE TABLE m(id INTEGER PRIMARY KEY, v TEXT);")
332
+ .unwrap();
333
+ writer
334
+ .execute("INSERT INTO m(v) VALUES ('in-wal')", [])
335
+ .unwrap();
336
+ // Deliberately NOT checkpointed and NOT closed — this is goose's state.
337
+
338
+ let ro = open_readonly(&db).unwrap();
339
+ let n: i64 = ro
340
+ .query_row("SELECT count(*) FROM m", [], |r| r.get(0))
341
+ .unwrap();
342
+ assert_eq!(
343
+ n, 1,
344
+ "the WAL contents must be visible to a read-only reader"
345
+ );
346
+
347
+ drop(writer);
348
+ std::fs::remove_dir_all(&dir).ok();
349
+ }
350
+
351
+ #[test]
352
+ fn reading_works_while_a_writer_holds_the_database() {
353
+ // Verified live against opencode at 4 Hz through a 12s tool call with
354
+ // zero SQLITE_BUSY; this pins the property in CI.
355
+ let dir = std::env::temp_dir().join(format!("fpai-sqbusy-{}", std::process::id()));
356
+ std::fs::create_dir_all(&dir).unwrap();
357
+ let db = dir.join("busy.db");
358
+ let writer = Connection::open(&db).unwrap();
359
+ writer.pragma_update(None, "journal_mode", "WAL").unwrap();
360
+ writer
361
+ .execute_batch("CREATE TABLE m(id INTEGER PRIMARY KEY, v TEXT);")
362
+ .unwrap();
363
+
364
+ let ro = open_readonly(&db).unwrap();
365
+ for i in 0..20 {
366
+ writer
367
+ .execute("INSERT INTO m(v) VALUES (?1)", [format!("row{i}")])
368
+ .unwrap();
369
+ let n: i64 = ro
370
+ .query_row("SELECT count(*) FROM m", [], |r| r.get(0))
371
+ .unwrap();
372
+ assert_eq!(
373
+ n,
374
+ i + 1,
375
+ "a concurrent reader must see committed rows immediately"
376
+ );
377
+ }
378
+ drop(writer);
379
+ std::fs::remove_dir_all(&dir).ok();
380
+ }
381
+
382
+ #[test]
383
+ fn a_missing_database_is_an_error_not_a_panic() {
384
+ // The agent simply is not installed. Common, and not a fault.
385
+ assert!(open_readonly(Path::new("/nonexistent/fpai/none.db")).is_err());
386
+ }
387
+ }