failproofai 1.0.0-beta.2 → 1.0.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) 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 +2 -2
  30. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  39. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  40. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  41. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/.next/server/app/index.html +1 -1
  44. package/.next/standalone/.next/server/app/index.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  47. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  48. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  49. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  50. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  51. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  55. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  56. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  57. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  58. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  60. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/page.js +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  63. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  66. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  67. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +1 -1
  68. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  71. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  72. package/.next/standalone/.next/server/app/projects/page.js +1 -1
  73. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/chunks/[externals]__0h9_83k._.js +3 -0
  76. package/.next/standalone/.next/server/chunks/[externals]__1ifpkx-._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-dh5jy._.js +1 -1
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0f8nu8d._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0h3lfmp._.js +1 -1
  80. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0l4vuqm._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0longh-._.js → [root-of-the-server]__0szqzl8._.js} +2 -2
  82. package/.next/standalone/.next/server/chunks/[root-of-the-server]__10oa-v9._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/[root-of-the-server]__18d-9oi._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  85. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  86. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  87. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__16le-kd._.js → [root-of-the-server]__1p7s8-d._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  89. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -1
  90. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -1
  91. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/[externals]__1aq139v._.js +3 -0
  93. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  94. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__0cmdyn_._.js} +2 -2
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  98. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0uno39i._.js → [root-of-the-server]__0n_uvv0._.js} +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__01od0m6._.js → [root-of-the-server]__0rag5dg._.js} +2 -2
  101. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1apb7k_._.js → [root-of-the-server]__0rtdfof._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__0sdrz-j._.js} +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  104. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  105. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  106. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1qapotl._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  108. package/.next/standalone/.next/server/chunks/ssr/_1e19atl._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  112. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_1q9xxca.js} +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +2 -1
  114. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  116. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  117. package/.next/standalone/.next/server/pages/404.html +1 -1
  118. package/.next/standalone/.next/server/pages/500.html +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  120. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  121. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 06jakt_xhoolk.js} +1 -1
  122. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  123. package/.next/standalone/.next/static/chunks/0oe2-hp0_2tzs.js +6 -0
  124. package/.next/standalone/.next/static/chunks/{1hdhteui932bq.js → 0qmo2t50kpb30.js} +1 -1
  125. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 0ue9ak_xexiqh.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  127. package/.next/standalone/.next/static/chunks/1fw3pxobk_otl.js +1 -0
  128. package/.next/standalone/.next/static/chunks/23kev26by1e4f.js +1 -0
  129. package/.next/standalone/.next/static/chunks/2c90h1g6x8z8y.js +1 -0
  130. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 2zb3m1tzq6h98.js} +1 -1
  131. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  132. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 31cqiv_cpgj9m.js} +1 -1
  133. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 3h5_3u46gu9jx.js} +1 -1
  134. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  135. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 43a1ywqsg9bq9.js} +1 -1
  136. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  137. package/.next/standalone/Cargo.lock +1992 -198
  138. package/.next/standalone/Cargo.toml +1 -1
  139. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  140. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  141. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  142. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  143. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  144. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  145. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  146. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  147. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  148. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  149. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  150. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  151. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  152. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  153. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  154. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  155. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  156. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  157. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  158. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  159. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  160. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  161. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  162. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  163. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  164. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  165. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  166. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  167. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  168. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  169. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  170. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  171. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  172. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  173. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  174. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  175. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  176. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  177. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  178. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  179. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  180. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  181. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  182. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  183. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  184. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  185. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  186. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  187. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  188. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  189. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  190. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  191. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  192. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  193. package/.next/standalone/package.json +15 -9
  194. package/.next/standalone/server.js +1 -1
  195. package/bin/failproofai.mjs +129 -0
  196. package/dist/cli.mjs +2255 -779
  197. package/dist/worker.mjs +365 -52
  198. package/package.json +15 -9
  199. package/scripts/build-daemon-packages.mjs +210 -0
  200. package/scripts/daemon-platforms.mjs +42 -0
  201. package/scripts/prune-standalone.mjs +6 -0
  202. package/scripts/publish-aliases.mjs +62 -41
  203. package/src/hooks/builtin-policies.ts +139 -0
  204. package/src/hooks/cloud-connection.ts +205 -0
  205. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  206. package/src/hooks/cloud-enrollment.ts +173 -0
  207. package/src/hooks/cloud-managed-policies.ts +138 -0
  208. package/src/hooks/collector-config.ts +226 -0
  209. package/src/hooks/configure-wizard.ts +221 -5
  210. package/src/hooks/custom-hooks-loader.ts +69 -25
  211. package/src/hooks/daemon-client.ts +18 -0
  212. package/src/hooks/daemon-download.ts +137 -27
  213. package/src/hooks/daemon-service.ts +23 -8
  214. package/src/hooks/handler.ts +144 -6
  215. package/src/hooks/hook-activity-store.ts +50 -0
  216. package/src/hooks/loader-utils.ts +6 -4
  217. package/src/hooks/policy-types.ts +35 -0
  218. package/src/hooks/session-pause-cli.ts +157 -0
  219. package/src/hooks/session-pause.ts +236 -0
  220. package/src/hooks/tui.ts +115 -0
  221. package/.next/standalone/.next/server/chunks/[externals]__14odj07._.js +0 -3
  222. package/.next/standalone/.next/server/chunks/[externals]__1s61mel._.js +0 -3
  223. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +0 -3
  224. package/.next/standalone/.next/server/chunks/ssr/[externals]__1_g_b3t._.js +0 -3
  225. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  226. package/.next/standalone/.next/static/chunks/2mazwb1qmur1p.js +0 -1
  227. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  228. package/.next/standalone/.next/static/chunks/31feojxhjyjpl.js +0 -1
  229. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  230. package/.next/standalone/.next/static/chunks/3s7myk_co0gbh.js +0 -1
  231. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  232. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_buildManifest.js +0 -0
  233. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_clientMiddlewareManifest.js +0 -0
  234. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → StV6Lay7Q1cU4ysYCFJmH}/_ssgManifest.js +0 -0
@@ -0,0 +1,520 @@
1
+ //! Persistent read positions, so a restart resumes instead of re-shipping.
2
+ //!
3
+ //! # Why files are keyed by (device, inode) rather than by path
4
+ //!
5
+ //! The hook-activity store rotates by RENAMING `current.jsonl` to
6
+ //! `page-<ts>-<seq>.jsonl` and creating a fresh `current.jsonl`. A path-keyed
7
+ //! cursor gets both halves of that wrong at once: it thinks the rotated file
8
+ //! is brand new (and re-ships all of it), and it carries the old offset onto
9
+ //! the fresh `current.jsonl` (and skips its first records). Keying on the
10
+ //! inode means the cursor simply follows the file it belongs to, and the new
11
+ //! `current.jsonl` correctly starts at zero.
12
+ //!
13
+ //! The same shape shows up in every source that rotates or moves its files, so
14
+ //! this store is deliberately general — the format-specific tailing engine in
15
+ //! the next phase reuses it unchanged.
16
+ //!
17
+ //! # Inode reuse
18
+ //!
19
+ //! An inode freed by a deleted file can be handed to an unrelated new one. A
20
+ //! cursor keyed only on the inode would then apply a stale offset to different
21
+ //! content and silently skip its beginning. [`FileCursor::matches`] guards
22
+ //! that by also comparing the recorded path, so a reused inode under a
23
+ //! different name is treated as new rather than resumed.
24
+
25
+ use std::collections::BTreeMap;
26
+ use std::path::{Path, PathBuf};
27
+
28
+ use serde::{Deserialize, Serialize};
29
+
30
+ /// Bumped only for a change no `#[serde(default)]` can absorb. A mismatch is
31
+ /// treated as an empty store — re-reading costs a re-ship the server dedups,
32
+ /// while a half-understood cursor could skip records permanently.
33
+ const SCHEMA: u32 = 1;
34
+
35
+ const CURSOR_FILE: &str = "cursors.json";
36
+
37
+ /// State a format carries from one line to the next within a session.
38
+ ///
39
+ /// Persisted with the cursor rather than held in memory, so a resumed read
40
+ /// reproduces byte-identical events — which is what lets the server's
41
+ /// content-hash dedup collapse a re-read instead of storing it twice.
42
+ ///
43
+ /// Fields are format-specific but live here because the cursor is what makes
44
+ /// them durable. A format that carries no state leaves them all unset.
45
+ #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
46
+ #[serde(default)]
47
+ pub struct TailState {
48
+ /// Model of the most recent assistant turn. A user line carries none and
49
+ /// inherits it, so without this the first prompt of a session renders with
50
+ /// no model at all.
51
+ pub last_model: Option<String>,
52
+ /// The last message id whose token usage was already attributed.
53
+ ///
54
+ /// One API response is written across several lines that each repeat the
55
+ /// SAME usage object, so counting per line inflates token totals several
56
+ /// times over. Gating on the group id is cross-line state, but of the safe
57
+ /// kind: persisted here, so a resumed read holds the same value at the same
58
+ /// offset as a full re-read would.
59
+ pub last_usage_message_id: Option<String>,
60
+ /// In-flight `tool_use` id → tool name.
61
+ ///
62
+ /// A tool result names no tool, and the server builds a result row's
63
+ /// summary from the tool name alone — so without this every tool result is
64
+ /// a blank row. A `Vec` rather than a map because eviction has to be
65
+ /// deterministic AND drop the oldest first; a map keyed by random ids would
66
+ /// evict in an order unrelated to age.
67
+ pub pending_tools: Vec<(String, String)>,
68
+ }
69
+
70
+ /// Most in-flight tool calls remembered per session. Bounds the cursor file
71
+ /// when a transcript has calls that never produce a result.
72
+ pub const MAX_PENDING_TOOLS: usize = 64;
73
+
74
+ impl TailState {
75
+ pub fn remember_tool(&mut self, id: String, name: String) {
76
+ if self.pending_tools.iter().any(|(i, _)| *i == id) {
77
+ return;
78
+ }
79
+ self.pending_tools.push((id, name));
80
+ if self.pending_tools.len() > MAX_PENDING_TOOLS {
81
+ self.pending_tools.remove(0);
82
+ }
83
+ }
84
+
85
+ pub fn tool_name(&self, id: &str) -> Option<&str> {
86
+ self.pending_tools
87
+ .iter()
88
+ .find(|(i, _)| i == id)
89
+ .map(|(_, n)| n.as_str())
90
+ }
91
+ }
92
+
93
+ /// Where one file has been read up to, and what the format knows about it.
94
+ #[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
95
+ pub struct FileCursor {
96
+ /// Last known path. Compared on resume to catch inode reuse; not the key.
97
+ pub path: PathBuf,
98
+ pub dev: u64,
99
+ pub inode: u64,
100
+ /// Bytes consumed. Only ever advanced after the events derived from them
101
+ /// have been durably spooled.
102
+ pub offset: u64,
103
+ /// File size when the offset was last advanced. Used to detect truncation.
104
+ pub size_seen: u64,
105
+
106
+ // Everything below is `serde(default)` so adding a field needs no SCHEMA
107
+ // bump: an older cursor file simply reads them as unset, which is the same
108
+ // state a freshly-discovered file starts in.
109
+ /// Identity stamped on every event from this file. Immutable once set, so
110
+ /// it cannot drift mid-session.
111
+ #[serde(default)]
112
+ pub session_id: Option<String>,
113
+ #[serde(default)]
114
+ pub agent_id: Option<String>,
115
+ /// Whether this session's `agent_start` has been emitted.
116
+ ///
117
+ /// Load-bearing: the server selects sessions on `agent_start`, so a session
118
+ /// without one is not merely incomplete, it is absent from the product
119
+ /// entirely — and `agent_end` is gated on the same flag, so it never ends
120
+ /// either.
121
+ #[serde(default)]
122
+ pub agent_start_emitted: bool,
123
+ /// Whether `agent_end` has been emitted. Cleared if the file grows again,
124
+ /// so a resumed session can end a second time rather than freezing.
125
+ #[serde(default)]
126
+ pub ended: bool,
127
+ /// Most recent timestamp seen, which `agent_end` is derived from.
128
+ #[serde(default)]
129
+ pub last_ts: Option<String>,
130
+ /// Length of the file's first line, for formats that rewrite it in place.
131
+ #[serde(default)]
132
+ pub first_line_len: Option<u64>,
133
+ #[serde(default)]
134
+ pub state: TailState,
135
+ }
136
+
137
+ impl FileCursor {
138
+ /// Whether this cursor may be resumed for `path`.
139
+ ///
140
+ /// A file that moved but kept its inode (the rotation case) still matches
141
+ /// only if we choose to allow it — see [`CursorStore::resume`], which
142
+ /// treats a same-inode different-path file as the same file. What must
143
+ /// never match is a *different* file that inherited a freed inode, and
144
+ /// that is what a differing name catches.
145
+ pub fn matches(&self, dev: u64, inode: u64) -> bool {
146
+ self.dev == dev && self.inode == inode
147
+ }
148
+ }
149
+
150
+ /// Every cursor for one source, persisted as a single atomically-written file.
151
+ ///
152
+ /// One store per source, never shared. The whole map is written at once, so
153
+ /// two sources sharing a file would clobber each other and the loser would
154
+ /// re-ship from zero after every restart.
155
+ #[derive(Debug, Default)]
156
+ pub struct CursorStore {
157
+ dir: PathBuf,
158
+ cursors: BTreeMap<String, FileCursor>,
159
+ }
160
+
161
+ #[derive(Debug, Deserialize, Serialize)]
162
+ struct OnDisk {
163
+ schema: u32,
164
+ /// `BTreeMap` rather than `HashMap` so the file has a stable order and a
165
+ /// diff between two runs shows real movement rather than rehashing.
166
+ cursors: BTreeMap<String, FileCursor>,
167
+ }
168
+
169
+ fn key(dev: u64, inode: u64) -> String {
170
+ format!("{dev}:{inode}")
171
+ }
172
+
173
+ impl CursorStore {
174
+ /// Load the store for `dir`, or an empty one if it is absent or unreadable.
175
+ ///
176
+ /// Corruption is logged and treated as empty rather than propagated: a
177
+ /// source that refuses to start because its cursor file is damaged
178
+ /// collects nothing until a human intervenes, whereas starting over
179
+ /// re-ships records the server already dedups.
180
+ pub fn load(dir: PathBuf) -> Self {
181
+ let path = dir.join(CURSOR_FILE);
182
+ let cursors = match std::fs::read_to_string(&path) {
183
+ Ok(text) => match serde_json::from_str::<OnDisk>(&text) {
184
+ Ok(d) if d.schema == SCHEMA => d.cursors,
185
+ Ok(d) => {
186
+ tracing::warn!(
187
+ path = %path.display(),
188
+ found = d.schema,
189
+ expected = SCHEMA,
190
+ "cursor schema mismatch; starting from scratch"
191
+ );
192
+ BTreeMap::new()
193
+ }
194
+ Err(err) => {
195
+ tracing::warn!(
196
+ path = %path.display(),
197
+ %err,
198
+ "cursor file is unreadable; starting from scratch"
199
+ );
200
+ BTreeMap::new()
201
+ }
202
+ },
203
+ Err(_) => BTreeMap::new(),
204
+ };
205
+ CursorStore { dir, cursors }
206
+ }
207
+
208
+ pub fn is_empty(&self) -> bool {
209
+ self.cursors.is_empty()
210
+ }
211
+
212
+ pub fn len(&self) -> usize {
213
+ self.cursors.len()
214
+ }
215
+
216
+ /// The cursor to resume from for a file, if one applies.
217
+ ///
218
+ /// A same-inode file whose path changed IS resumed: that is exactly the
219
+ /// rotation case, and re-reading a rotated page from zero would duplicate
220
+ /// every record in it. A cursor whose recorded path names a *different*
221
+ /// file that still exists is refused, since the inode was reused.
222
+ pub fn resume(&self, dev: u64, inode: u64, path: &Path) -> Option<&FileCursor> {
223
+ let c = self.cursors.get(&key(dev, inode))?;
224
+ if !c.matches(dev, inode) {
225
+ return None;
226
+ }
227
+ if c.path != path && still_the_same_inode(&c.path, dev, inode) {
228
+ // The recorded path exists AND still holds THIS inode, so the file
229
+ // we recorded has not moved — yet we are being asked about the same
230
+ // inode under a different name. That is a hardlink or an
231
+ // inconsistency, not a rotation, and resuming would apply one
232
+ // file's offset to another's content.
233
+ //
234
+ // Mere existence of the recorded path is NOT enough to conclude
235
+ // this, which is what an earlier version got wrong: after the hook
236
+ // store rotates, `current.jsonl` exists again immediately — as a
237
+ // brand-new inode — so an existence check refused to resume the
238
+ // rotated page and re-shipped every row in it.
239
+ tracing::warn!(
240
+ recorded = %c.path.display(),
241
+ found = %path.display(),
242
+ "same inode under two live paths; not resuming"
243
+ );
244
+ return None;
245
+ }
246
+ Some(c)
247
+ }
248
+
249
+ pub fn set(&mut self, cursor: FileCursor) {
250
+ self.cursors.insert(key(cursor.dev, cursor.inode), cursor);
251
+ }
252
+
253
+ /// Drop cursors whose files no longer exist, so the store does not grow
254
+ /// without bound as pages are pruned.
255
+ pub fn retain_existing(&mut self) {
256
+ self.cursors.retain(|_, c| c.path.exists());
257
+ }
258
+
259
+ /// Persist atomically (tmp → fsync → rename) at owner-only permissions.
260
+ ///
261
+ /// Callers write this AFTER the events derived from the new offsets are
262
+ /// durable. A crash between the two costs a re-ship, which the server
263
+ /// dedups; the other order loses records outright.
264
+ pub fn save(&self) -> std::io::Result<()> {
265
+ std::fs::create_dir_all(&self.dir)?;
266
+ let body = serde_json::to_string_pretty(&OnDisk {
267
+ schema: SCHEMA,
268
+ cursors: self.cursors.clone(),
269
+ })
270
+ .map_err(std::io::Error::other)?;
271
+
272
+ let tmp = self.dir.join(format!("{CURSOR_FILE}.tmp"));
273
+ write_private(&tmp, body.as_bytes())?;
274
+ std::fs::rename(&tmp, self.dir.join(CURSOR_FILE))
275
+ }
276
+ }
277
+
278
+ #[cfg(unix)]
279
+ fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
280
+ use std::io::Write;
281
+ use std::os::unix::fs::OpenOptionsExt;
282
+ let mut f = std::fs::OpenOptions::new()
283
+ .write(true)
284
+ .create(true)
285
+ .truncate(true)
286
+ .mode(0o600)
287
+ .open(path)?;
288
+ f.write_all(bytes)?;
289
+ f.sync_all()
290
+ }
291
+
292
+ #[cfg(not(unix))]
293
+ fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
294
+ std::fs::write(path, bytes)
295
+ }
296
+
297
+ /// Whether `path` currently exists AND holds exactly `(dev, inode)`.
298
+ ///
299
+ /// Distinguishes "the recorded file is still where we left it" from "a new
300
+ /// file has taken that name", which is the difference between an ambiguous
301
+ /// cursor and an ordinary rotation.
302
+ fn still_the_same_inode(path: &Path, dev: u64, inode: u64) -> bool {
303
+ match std::fs::metadata(path) {
304
+ Ok(meta) => identity(&meta) == (dev, inode),
305
+ Err(_) => false,
306
+ }
307
+ }
308
+
309
+ /// `(device, inode)` for a file. On non-unix, a hash of the path — which
310
+ /// cannot detect rotation, so those platforms re-read a rotated file once.
311
+ #[cfg(unix)]
312
+ pub fn identity(meta: &std::fs::Metadata) -> (u64, u64) {
313
+ use std::os::unix::fs::MetadataExt;
314
+ (meta.dev(), meta.ino())
315
+ }
316
+
317
+ #[cfg(not(unix))]
318
+ pub fn identity(_meta: &std::fs::Metadata) -> (u64, u64) {
319
+ (0, 0)
320
+ }
321
+
322
+ #[cfg(test)]
323
+ mod tests {
324
+ use super::*;
325
+ use std::time::{SystemTime, UNIX_EPOCH};
326
+
327
+ fn tmpdir(name: &str) -> PathBuf {
328
+ let d = std::env::temp_dir().join(format!(
329
+ "fpai-cur-{}-{}-{}",
330
+ name,
331
+ std::process::id(),
332
+ SystemTime::now()
333
+ .duration_since(UNIX_EPOCH)
334
+ .unwrap()
335
+ .as_nanos()
336
+ ));
337
+ std::fs::create_dir_all(&d).unwrap();
338
+ d
339
+ }
340
+
341
+ fn cursor(path: &Path, dev: u64, inode: u64, offset: u64) -> FileCursor {
342
+ FileCursor {
343
+ path: path.to_path_buf(),
344
+ dev,
345
+ inode,
346
+ offset,
347
+ size_seen: offset,
348
+ ..Default::default()
349
+ }
350
+ }
351
+
352
+ #[test]
353
+ fn a_saved_cursor_round_trips() {
354
+ let dir = tmpdir("roundtrip");
355
+ let mut s = CursorStore::load(dir.clone());
356
+ s.set(cursor(Path::new("/x/current.jsonl"), 1, 42, 1024));
357
+ s.save().unwrap();
358
+
359
+ let reloaded = CursorStore::load(dir.clone());
360
+ assert_eq!(reloaded.len(), 1);
361
+ assert_eq!(
362
+ reloaded
363
+ .resume(1, 42, Path::new("/x/current.jsonl"))
364
+ .unwrap()
365
+ .offset,
366
+ 1024
367
+ );
368
+ std::fs::remove_dir_all(&dir).ok();
369
+ }
370
+
371
+ #[test]
372
+ fn a_rotated_file_keeps_its_position() {
373
+ // The hook store renames current.jsonl to page-*.jsonl. Re-reading
374
+ // that page from zero would duplicate every record in it.
375
+ let dir = tmpdir("rotate");
376
+ let mut s = CursorStore::load(dir.clone());
377
+ s.set(cursor(Path::new("/x/current.jsonl"), 1, 42, 900));
378
+
379
+ let resumed = s.resume(1, 42, Path::new("/x/page-123-0.jsonl"));
380
+ assert!(
381
+ resumed.is_some(),
382
+ "a renamed file must resume at its recorded offset"
383
+ );
384
+ assert_eq!(resumed.unwrap().offset, 900);
385
+ std::fs::remove_dir_all(&dir).ok();
386
+ }
387
+
388
+ #[test]
389
+ fn a_fresh_file_after_rotation_starts_at_zero() {
390
+ // The new current.jsonl has a different inode, so nothing resumes and
391
+ // its first records are not skipped.
392
+ let dir = tmpdir("fresh");
393
+ let mut s = CursorStore::load(dir.clone());
394
+ s.set(cursor(Path::new("/x/current.jsonl"), 1, 42, 900));
395
+ assert!(s.resume(1, 99, Path::new("/x/current.jsonl")).is_none());
396
+ std::fs::remove_dir_all(&dir).ok();
397
+ }
398
+
399
+ #[test]
400
+ fn the_same_inode_under_two_live_paths_is_not_resumed() {
401
+ // A hardlink, or an inconsistency. Resuming would apply one file's
402
+ // offset to another's content.
403
+ //
404
+ // Uses REAL inodes: with synthetic ones this test passed for the wrong
405
+ // reason, because the guard could never look up what it was comparing.
406
+ #[cfg(unix)]
407
+ {
408
+ let dir = tmpdir("twopaths");
409
+ let recorded = dir.join("recorded.jsonl");
410
+ std::fs::write(&recorded, "content\n").unwrap();
411
+ let link = dir.join("link.jsonl");
412
+ std::fs::hard_link(&recorded, &link).unwrap();
413
+
414
+ let meta = std::fs::metadata(&recorded).unwrap();
415
+ let (dev, ino) = identity(&meta);
416
+
417
+ let mut s = CursorStore::load(dir.clone());
418
+ s.set(cursor(&recorded, dev, ino, 5));
419
+
420
+ assert!(
421
+ s.resume(dev, ino, &link).is_none(),
422
+ "the recorded path still holds this inode, so a second live path is ambiguous"
423
+ );
424
+ // The recorded path itself still resumes normally.
425
+ assert!(s.resume(dev, ino, &recorded).is_some());
426
+ std::fs::remove_dir_all(&dir).ok();
427
+ }
428
+ }
429
+
430
+ #[test]
431
+ fn a_rotated_file_resumes_even_though_its_old_name_was_recreated() {
432
+ // The exact hook-store sequence, with real inodes: current.jsonl is
433
+ // renamed to a page and a NEW current.jsonl appears immediately. An
434
+ // existence-only guard refuses here and re-ships the whole page.
435
+ #[cfg(unix)]
436
+ {
437
+ let dir = tmpdir("rotate-real");
438
+ let current = dir.join("current.jsonl");
439
+ std::fs::write(&current, "row\n").unwrap();
440
+ let (dev, ino) = identity(&std::fs::metadata(&current).unwrap());
441
+
442
+ let mut s = CursorStore::load(dir.clone());
443
+ s.set(cursor(&current, dev, ino, 4));
444
+
445
+ let page = dir.join("page-1-0.jsonl");
446
+ std::fs::rename(&current, &page).unwrap();
447
+ std::fs::write(&current, "").unwrap(); // fresh file, new inode
448
+
449
+ let resumed = s.resume(dev, ino, &page);
450
+ assert!(
451
+ resumed.is_some(),
452
+ "the rotated page must resume; its old name being recreated is not inode reuse"
453
+ );
454
+ assert_eq!(resumed.unwrap().offset, 4);
455
+ std::fs::remove_dir_all(&dir).ok();
456
+ }
457
+ }
458
+
459
+ #[test]
460
+ fn a_corrupt_cursor_file_starts_from_scratch_rather_than_failing() {
461
+ // Refusing to start would mean collecting nothing until a human
462
+ // noticed. Starting over costs a re-ship the server dedups.
463
+ let dir = tmpdir("corrupt");
464
+ std::fs::write(dir.join(CURSOR_FILE), "{not json").unwrap();
465
+ let s = CursorStore::load(dir.clone());
466
+ assert!(s.is_empty());
467
+ std::fs::remove_dir_all(&dir).ok();
468
+ }
469
+
470
+ #[test]
471
+ fn a_future_schema_is_not_half_understood() {
472
+ let dir = tmpdir("schema");
473
+ std::fs::write(
474
+ dir.join(CURSOR_FILE),
475
+ r#"{"schema":99,"cursors":{"1:42":{"path":"/x","dev":1,"inode":42,"offset":5,"size_seen":5}}}"#,
476
+ )
477
+ .unwrap();
478
+ let s = CursorStore::load(dir.clone());
479
+ assert!(
480
+ s.is_empty(),
481
+ "a newer schema must be discarded, not partially read"
482
+ );
483
+ std::fs::remove_dir_all(&dir).ok();
484
+ }
485
+
486
+ #[test]
487
+ fn pruned_files_are_dropped_so_the_store_stays_bounded() {
488
+ let dir = tmpdir("prune");
489
+ let live = dir.join("live.jsonl");
490
+ std::fs::write(&live, "x").unwrap();
491
+
492
+ let mut s = CursorStore::load(dir.clone());
493
+ s.set(cursor(&live, 1, 1, 10));
494
+ s.set(cursor(&dir.join("gone.jsonl"), 1, 2, 10));
495
+ assert_eq!(s.len(), 2);
496
+
497
+ s.retain_existing();
498
+ assert_eq!(s.len(), 1, "a cursor for a deleted page must be dropped");
499
+ std::fs::remove_dir_all(&dir).ok();
500
+ }
501
+
502
+ #[test]
503
+ fn the_cursor_file_is_owner_only() {
504
+ #[cfg(unix)]
505
+ {
506
+ use std::os::unix::fs::PermissionsExt;
507
+ let dir = tmpdir("mode");
508
+ let mut s = CursorStore::load(dir.clone());
509
+ s.set(cursor(Path::new("/x/a.jsonl"), 1, 1, 1));
510
+ s.save().unwrap();
511
+ let mode = std::fs::metadata(dir.join(CURSOR_FILE))
512
+ .unwrap()
513
+ .permissions()
514
+ .mode()
515
+ & 0o777;
516
+ assert_eq!(mode, 0o600, "got {mode:o}");
517
+ std::fs::remove_dir_all(&dir).ok();
518
+ }
519
+ }
520
+ }