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,1129 @@
1
+ //! Hermes source: transform correctness and engine behaviour, against REAL
2
+ //! SQLite databases.
3
+ //!
4
+ //! Nothing here is a hand-shaped fixture. [`SCHEMA`] is the verbatim DDL of a
5
+ //! captured `~/.hermes/state.db` (schema_version 22) — including the FTS5
6
+ //! shadow tables and the three triggers that populate them, so every insert in
7
+ //! these tests fires the same triggers a live Hermes would — and the
8
+ //! `tool_calls` payloads, timestamps and tool outputs are copied from real rows
9
+ //! of that capture.
10
+ //!
11
+ //! Databases are created in WAL mode and read through
12
+ //! [`sqlitepoll::open_readonly`], which is exactly the pairing production uses.
13
+
14
+ use std::fs;
15
+ use std::path::{Path, PathBuf};
16
+ use std::sync::Arc;
17
+ use std::sync::atomic::AtomicBool;
18
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
19
+
20
+ use fpai_collect::sources::hermes;
21
+ use fpai_collect::sqlitepoll::{self, PollOutcome, PollRequest, Watermark};
22
+ use fpai_collect::supervisor::Shutdown;
23
+ use rusqlite::Connection;
24
+ use serde_json::Value;
25
+
26
+ /// Verbatim from the probe capture's `SCHEMA.sql`, minus the tables this source
27
+ /// deliberately never reads (`async_delegations`, `session_model_usage`,
28
+ /// `gateway_routing`, `compression_locks`, `state_meta`).
29
+ ///
30
+ /// The FTS5 virtual tables and their triggers are kept because they are the
31
+ /// trap: they mirror every message into a rowid-keyed shadow table, so a poller
32
+ /// that widened its `FROM` would ship the conversation twice.
33
+ const SCHEMA: &str = r#"
34
+ CREATE TABLE sessions (
35
+ id TEXT PRIMARY KEY,
36
+ source TEXT NOT NULL,
37
+ user_id TEXT,
38
+ session_key TEXT,
39
+ chat_id TEXT,
40
+ chat_type TEXT,
41
+ thread_id TEXT,
42
+ display_name TEXT,
43
+ origin_json TEXT,
44
+ expiry_finalized INTEGER DEFAULT 0,
45
+ model TEXT,
46
+ model_config TEXT,
47
+ system_prompt TEXT,
48
+ parent_session_id TEXT,
49
+ started_at REAL NOT NULL,
50
+ ended_at REAL,
51
+ end_reason TEXT,
52
+ message_count INTEGER DEFAULT 0,
53
+ tool_call_count INTEGER DEFAULT 0,
54
+ input_tokens INTEGER DEFAULT 0,
55
+ output_tokens INTEGER DEFAULT 0,
56
+ cache_read_tokens INTEGER DEFAULT 0,
57
+ cache_write_tokens INTEGER DEFAULT 0,
58
+ reasoning_tokens INTEGER DEFAULT 0,
59
+ cwd TEXT,
60
+ git_branch TEXT,
61
+ git_repo_root TEXT,
62
+ title TEXT,
63
+ api_call_count INTEGER DEFAULT 0,
64
+ profile_name TEXT,
65
+ rewind_count INTEGER NOT NULL DEFAULT 0,
66
+ archived INTEGER NOT NULL DEFAULT 0,
67
+ FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
68
+ );
69
+ CREATE TABLE messages (
70
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
71
+ session_id TEXT NOT NULL REFERENCES sessions(id),
72
+ role TEXT NOT NULL,
73
+ content TEXT,
74
+ tool_call_id TEXT,
75
+ tool_calls TEXT,
76
+ tool_name TEXT,
77
+ effect_disposition TEXT,
78
+ timestamp REAL NOT NULL,
79
+ token_count INTEGER,
80
+ finish_reason TEXT,
81
+ reasoning TEXT,
82
+ reasoning_content TEXT,
83
+ reasoning_details TEXT,
84
+ codex_reasoning_items TEXT,
85
+ codex_message_items TEXT,
86
+ platform_message_id TEXT,
87
+ observed INTEGER DEFAULT 0,
88
+ active INTEGER NOT NULL DEFAULT 1,
89
+ compacted INTEGER NOT NULL DEFAULT 0,
90
+ api_content TEXT
91
+ );
92
+ CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
93
+ CREATE INDEX idx_messages_session_active ON messages(session_id, active, timestamp);
94
+ CREATE VIRTUAL TABLE messages_fts USING fts5(content);
95
+ CREATE TRIGGER messages_fts_insert AFTER INSERT ON messages BEGIN
96
+ INSERT INTO messages_fts(rowid, content) VALUES (
97
+ new.id,
98
+ COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
99
+ );
100
+ END;
101
+ CREATE TRIGGER messages_fts_delete AFTER DELETE ON messages BEGIN
102
+ DELETE FROM messages_fts WHERE rowid = old.id;
103
+ END;
104
+ CREATE TRIGGER messages_fts_update AFTER UPDATE ON messages BEGIN
105
+ DELETE FROM messages_fts WHERE rowid = old.id;
106
+ INSERT INTO messages_fts(rowid, content) VALUES (
107
+ new.id,
108
+ COALESCE(new.content, '') || ' ' || COALESCE(new.tool_name, '') || ' ' || COALESCE(new.tool_calls, '')
109
+ );
110
+ END;
111
+ "#;
112
+
113
+ // Real ids and payloads from the capture.
114
+ const CLI_SESSION: &str = "20260803_080402_a54231";
115
+ const GATEWAY_SESSION: &str = "20260803_081500_b7c3d1";
116
+ const MODEL: &str = "claude-sonnet-4-6";
117
+
118
+ /// A real `tool_calls` value: OpenAI's wire shape, with `arguments` as a
119
+ /// JSON-encoded STRING rather than an object.
120
+ const TERMINAL_CALL: &str = r#"[{"id": "tooluse_8LfsNiQKh5FW5zW7gFOoVX", "call_id": "tooluse_8LfsNiQKh5FW5zW7gFOoVX", "response_item_id": "fc_tooluse_8LfsNiQKh5FW5zW7gFOoVX", "type": "function", "function": {"name": "terminal", "arguments": "{\"command\": \"ls /work\"}"}}]"#;
121
+
122
+ /// A real assistant row issuing TWO parallel calls in one row.
123
+ const PARALLEL_CALLS: &str = r#"[{"id": "tooluse_GqnMvLFT1UKNu83Un5rSIK", "call_id": "tooluse_GqnMvLFT1UKNu83Un5rSIK", "type": "function", "function": {"name": "session_search", "arguments": "{\"session_id\": \"20260803_080402_a54231\", \"around_message_id\": 1, \"window\": 20}"}}, {"id": "tooluse_NKFH2jNHpQ4ifRdaKNmsCU", "call_id": "tooluse_NKFH2jNHpQ4ifRdaKNmsCU", "type": "function", "function": {"name": "session_search", "arguments": "{\"session_id\": \"20260803_080544_ae362c\", \"around_message_id\": 1, \"window\": 20}"}}]"#;
124
+
125
+ fn tmpdir(name: &str) -> PathBuf {
126
+ let d = std::env::temp_dir().join(format!(
127
+ "fpai-hm-{}-{}-{}",
128
+ name,
129
+ std::process::id(),
130
+ SystemTime::now()
131
+ .duration_since(UNIX_EPOCH)
132
+ .unwrap()
133
+ .as_nanos()
134
+ ));
135
+ fs::create_dir_all(&d).unwrap();
136
+ d
137
+ }
138
+
139
+ /// A writable WAL database carrying the real schema.
140
+ fn make_db(dir: &Path) -> (PathBuf, Connection) {
141
+ let path = dir.join("state.db");
142
+ let conn = Connection::open(&path).unwrap();
143
+ conn.pragma_update(None, "journal_mode", "WAL").unwrap();
144
+ conn.execute_batch(SCHEMA).unwrap();
145
+ (path, conn)
146
+ }
147
+
148
+ #[allow(clippy::too_many_arguments)]
149
+ fn insert_session(
150
+ conn: &Connection,
151
+ id: &str,
152
+ source: &str,
153
+ cwd: Option<&str>,
154
+ started_at: f64,
155
+ ended_at: Option<f64>,
156
+ end_reason: Option<&str>,
157
+ chat_type: Option<&str>,
158
+ ) {
159
+ conn.execute(
160
+ "INSERT INTO sessions (id, source, chat_type, model, started_at, ended_at, end_reason, \
161
+ input_tokens, output_tokens, cwd, git_branch, git_repo_root) \
162
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 37481, 159, ?8, 'main', '/work')",
163
+ rusqlite::params![
164
+ id, source, chat_type, MODEL, started_at, ended_at, end_reason, cwd
165
+ ],
166
+ )
167
+ .unwrap();
168
+ }
169
+
170
+ /// The CLI session every test starts from: real cwd, real timestamps.
171
+ fn insert_cli_session(conn: &Connection, ended: bool) {
172
+ insert_session(
173
+ conn,
174
+ CLI_SESSION,
175
+ "cli",
176
+ Some("/work"),
177
+ 1_785_744_251.652_289_9,
178
+ ended.then_some(1_785_744_261.719_167_7),
179
+ ended.then_some("agent_close"),
180
+ None,
181
+ );
182
+ }
183
+
184
+ #[allow(clippy::too_many_arguments)]
185
+ fn insert_message(
186
+ conn: &Connection,
187
+ session: &str,
188
+ role: &str,
189
+ content: Option<&str>,
190
+ tool_call_id: Option<&str>,
191
+ tool_calls: Option<&str>,
192
+ tool_name: Option<&str>,
193
+ timestamp: f64,
194
+ finish_reason: Option<&str>,
195
+ ) {
196
+ conn.execute(
197
+ "INSERT INTO messages (session_id, role, content, tool_call_id, tool_calls, tool_name, \
198
+ timestamp, finish_reason) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
199
+ rusqlite::params![
200
+ session,
201
+ role,
202
+ content,
203
+ tool_call_id,
204
+ tool_calls,
205
+ tool_name,
206
+ timestamp,
207
+ finish_reason
208
+ ],
209
+ )
210
+ .unwrap();
211
+ }
212
+
213
+ fn user(conn: &Connection, session: &str, text: &str, ts: f64) {
214
+ insert_message(
215
+ conn,
216
+ session,
217
+ "user",
218
+ Some(text),
219
+ None,
220
+ None,
221
+ None,
222
+ ts,
223
+ None,
224
+ );
225
+ }
226
+
227
+ /// An assistant row that is CALLING tools: `content` is the empty string, not
228
+ /// NULL — the shape a `content IS NOT NULL` test gets wrong.
229
+ fn assistant_calls(conn: &Connection, session: &str, calls: &str, ts: f64) {
230
+ insert_message(
231
+ conn,
232
+ session,
233
+ "assistant",
234
+ Some(""),
235
+ None,
236
+ Some(calls),
237
+ None,
238
+ ts,
239
+ Some("tool_calls"),
240
+ );
241
+ }
242
+
243
+ fn assistant_text(conn: &Connection, session: &str, text: &str, ts: f64) {
244
+ insert_message(
245
+ conn,
246
+ session,
247
+ "assistant",
248
+ Some(text),
249
+ None,
250
+ None,
251
+ None,
252
+ ts,
253
+ Some("stop"),
254
+ );
255
+ }
256
+
257
+ fn tool_result(conn: &Connection, session: &str, call_id: &str, name: &str, out: &str, ts: f64) {
258
+ insert_message(
259
+ conn,
260
+ session,
261
+ "tool",
262
+ Some(out),
263
+ Some(call_id),
264
+ None,
265
+ Some(name),
266
+ ts,
267
+ None,
268
+ );
269
+ }
270
+
271
+ fn poll_at(db: &Path, watermark: i64, max_rows: u64) -> PollOutcome {
272
+ let conn = sqlitepoll::open_readonly(db).unwrap();
273
+ let req = PollRequest {
274
+ watermark,
275
+ max_rows,
276
+ environment: "local".into(),
277
+ agent_id: hermes::DEFAULT_AGENT_ID.into(),
278
+ };
279
+ (hermes::FORMAT.poll)(&conn, &req).unwrap()
280
+ }
281
+
282
+ fn poll_all(db: &Path) -> Vec<Value> {
283
+ poll_at(db, 0, 1000).events
284
+ }
285
+
286
+ fn of_type<'a>(events: &'a [Value], kind: &str) -> Vec<&'a Value> {
287
+ events.iter().filter(|e| e["type"] == kind).collect()
288
+ }
289
+
290
+ // ── message shapes ───────────────────────────────────────────────────────
291
+
292
+ #[test]
293
+ fn a_user_row_becomes_a_model_request_carrying_the_sessions_model() {
294
+ // The model lives on `sessions`, never on a message. The server builds this
295
+ // row's summary from the model alone, so without lifting it across every
296
+ // prompt renders as an empty row.
297
+ let dir = tmpdir("user");
298
+ let (db, conn) = make_db(&dir);
299
+ insert_cli_session(&conn, false);
300
+ user(&conn, CLI_SESSION, "say OK", 1_785_744_251.686_618);
301
+
302
+ let ev = poll_all(&db);
303
+ let reqs = of_type(&ev, "model_request");
304
+ assert_eq!(reqs.len(), 1);
305
+ assert_eq!(reqs[0]["messages"][0]["role"], "user");
306
+ assert_eq!(reqs[0]["messages"][0]["content"], "say OK");
307
+ assert_eq!(reqs[0]["model"], MODEL);
308
+ assert_eq!(reqs[0]["session_id"], CLI_SESSION);
309
+
310
+ fs::remove_dir_all(&dir).ok();
311
+ }
312
+
313
+ #[test]
314
+ fn an_assistant_text_row_becomes_a_model_response_and_a_calling_row_does_not() {
315
+ // A tool-calling assistant row has `content = ''` — an empty STRING, not
316
+ // NULL. Testing `content IS NOT NULL` emits a blank model_response for
317
+ // every tool turn in every session.
318
+ let dir = tmpdir("assistant");
319
+ let (db, conn) = make_db(&dir);
320
+ insert_cli_session(&conn, false);
321
+ assistant_calls(&conn, CLI_SESSION, TERMINAL_CALL, 1_785_744_254.923_234_2);
322
+ assistant_text(&conn, CLI_SESSION, "Demo Project", 1_785_744_261.708_625);
323
+
324
+ let ev = poll_all(&db);
325
+ let responses = of_type(&ev, "model_response");
326
+ assert_eq!(
327
+ responses.len(),
328
+ 1,
329
+ "the empty-content calling row must not produce a blank response: {ev:#?}"
330
+ );
331
+ assert_eq!(responses[0]["content"], "Demo Project");
332
+ assert_eq!(responses[0]["role"], "assistant");
333
+ assert_eq!(responses[0]["hermes_finish_reason"], "stop");
334
+
335
+ fs::remove_dir_all(&dir).ok();
336
+ }
337
+
338
+ #[test]
339
+ fn an_openai_shape_tool_calls_column_yields_one_tool_use_per_entry() {
340
+ // `tool_calls` is an ARRAY and the capture really does carry two parallel
341
+ // calls in one row. A transform reading `[0]` drops half the tool traffic
342
+ // on every parallel turn, and nothing downstream can tell.
343
+ let dir = tmpdir("parallel");
344
+ let (db, conn) = make_db(&dir);
345
+ insert_cli_session(&conn, false);
346
+ assistant_calls(&conn, CLI_SESSION, PARALLEL_CALLS, 1_785_744_413.993_990_2);
347
+
348
+ let ev = poll_all(&db);
349
+ let calls = of_type(&ev, "tool_use");
350
+ assert_eq!(calls.len(), 2, "both parallel calls must ship: {ev:#?}");
351
+ assert_eq!(calls[0]["tool_call_id"], "tooluse_GqnMvLFT1UKNu83Un5rSIK");
352
+ assert_eq!(calls[1]["tool_call_id"], "tooluse_NKFH2jNHpQ4ifRdaKNmsCU");
353
+ assert_eq!(calls[0]["tool_name"], "session_search");
354
+
355
+ // Two events from ONE row need distinct sub-second offsets, or the server's
356
+ // `(ts, random id)` ordering hands them back shuffled.
357
+ assert_ne!(calls[0]["timestamp"], calls[1]["timestamp"]);
358
+ assert_eq!(calls[1]["hermes_block_index"], 2);
359
+
360
+ fs::remove_dir_all(&dir).ok();
361
+ }
362
+
363
+ #[test]
364
+ fn a_json_string_arguments_field_is_parsed_into_structured_input() {
365
+ // Hermes stores OpenAI's wire shape verbatim: `arguments` is a STRING
366
+ // containing JSON. Shipped unparsed, the product gets one escaped blob per
367
+ // tool call instead of fields a query can reach.
368
+ let dir = tmpdir("args");
369
+ let (db, conn) = make_db(&dir);
370
+ insert_cli_session(&conn, false);
371
+ assistant_calls(&conn, CLI_SESSION, TERMINAL_CALL, 1_785_744_254.923_234_2);
372
+
373
+ let ev = poll_all(&db);
374
+ let calls = of_type(&ev, "tool_use");
375
+ assert_eq!(calls[0]["input"]["command"], "ls /work");
376
+ assert!(
377
+ calls[0]["input"].get("arguments").is_none(),
378
+ "a parsed payload must not also keep the raw string"
379
+ );
380
+
381
+ fs::remove_dir_all(&dir).ok();
382
+ }
383
+
384
+ #[test]
385
+ fn an_unparseable_arguments_string_is_kept_verbatim_rather_than_dropped() {
386
+ // A truncated argument list is still the best evidence of what the agent
387
+ // tried to run; dropping it loses the only record of a call that may have
388
+ // been the interesting one.
389
+ let dir = tmpdir("badargs");
390
+ let (db, conn) = make_db(&dir);
391
+ insert_cli_session(&conn, false);
392
+ let truncated = r#"[{"id":"c1","type":"function","function":{"name":"terminal","arguments":"{\"command\": \"rm -r"}}]"#;
393
+ assistant_calls(&conn, CLI_SESSION, truncated, 1_785_744_254.923_234_2);
394
+
395
+ let ev = poll_all(&db);
396
+ let calls = of_type(&ev, "tool_use");
397
+ assert_eq!(calls.len(), 1);
398
+ assert_eq!(calls[0]["input"]["arguments"], "{\"command\": \"rm -r");
399
+
400
+ fs::remove_dir_all(&dir).ok();
401
+ }
402
+
403
+ #[test]
404
+ fn a_malformed_tool_calls_column_costs_that_row_not_the_whole_poll() {
405
+ // One corrupt column must not stop the other sessions in the batch from
406
+ // being collected — a poll that returns Err ships nothing at all.
407
+ let dir = tmpdir("malformed");
408
+ let (db, conn) = make_db(&dir);
409
+ insert_cli_session(&conn, false);
410
+ assistant_calls(
411
+ &conn,
412
+ CLI_SESSION,
413
+ "not json at all",
414
+ 1_785_744_254.923_234_2,
415
+ );
416
+ assistant_text(&conn, CLI_SESSION, "recovered", 1_785_744_261.708_625);
417
+
418
+ let ev = poll_all(&db);
419
+ assert!(of_type(&ev, "tool_use").is_empty());
420
+ assert_eq!(of_type(&ev, "model_response").len(), 1);
421
+
422
+ fs::remove_dir_all(&dir).ok();
423
+ }
424
+
425
+ // ── call → result pairing ────────────────────────────────────────────────
426
+
427
+ #[test]
428
+ fn a_tool_result_is_paired_to_its_call_by_tool_call_id() {
429
+ let dir = tmpdir("pair");
430
+ let (db, conn) = make_db(&dir);
431
+ insert_cli_session(&conn, false);
432
+ assistant_calls(&conn, CLI_SESSION, TERMINAL_CALL, 1_785_744_254.923_234_2);
433
+ tool_result(
434
+ &conn,
435
+ CLI_SESSION,
436
+ "tooluse_8LfsNiQKh5FW5zW7gFOoVX",
437
+ "terminal",
438
+ r#"{"output": "README.md\na.txt", "exit_code": 0, "error": null}"#,
439
+ 1_785_744_254.967_635_4,
440
+ );
441
+
442
+ let ev = poll_all(&db);
443
+ let results = of_type(&ev, "tool_result");
444
+ assert_eq!(results.len(), 1);
445
+ assert_eq!(results[0]["tool_call_id"], "tooluse_8LfsNiQKh5FW5zW7gFOoVX");
446
+ assert_eq!(
447
+ results[0]["tool_name"], "terminal",
448
+ "a result with no name is a blank row in the product"
449
+ );
450
+ assert!(
451
+ results[0]["output"].as_str().unwrap().contains("README.md"),
452
+ "the tool's output must survive"
453
+ );
454
+ // A clean run must not be flagged: `exit_code: 0` with `error: null`.
455
+ assert!(results[0].get("error_type").is_none());
456
+
457
+ fs::remove_dir_all(&dir).ok();
458
+ }
459
+
460
+ #[test]
461
+ fn a_result_split_from_its_call_by_the_row_limit_still_names_its_tool() {
462
+ // When a batch is cut between the call and its result, the in-poll pairing
463
+ // map is empty on the next pass. Hermes stores `tool_name` on the result
464
+ // row too, and that fallback is the only thing standing between this and a
465
+ // permanently blank row every time a backlog drains.
466
+ let dir = tmpdir("split");
467
+ let (db, conn) = make_db(&dir);
468
+ insert_cli_session(&conn, false);
469
+ assistant_calls(&conn, CLI_SESSION, TERMINAL_CALL, 1_785_744_254.923_234_2);
470
+ tool_result(
471
+ &conn,
472
+ CLI_SESSION,
473
+ "tooluse_8LfsNiQKh5FW5zW7gFOoVX",
474
+ "terminal",
475
+ r#"{"output": "README.md", "exit_code": 0, "error": null}"#,
476
+ 1_785_744_254.967_635_4,
477
+ );
478
+
479
+ let first = poll_at(&db, 0, 1);
480
+ assert!(first.more, "a cut batch must ask the engine to poll again");
481
+ let second = poll_at(&db, first.watermark, 1);
482
+ let results = of_type(&second.events, "tool_result");
483
+ assert_eq!(results.len(), 1);
484
+ assert_eq!(results[0]["tool_name"], "terminal");
485
+
486
+ fs::remove_dir_all(&dir).ok();
487
+ }
488
+
489
+ #[test]
490
+ fn a_failed_tool_result_carries_a_non_empty_error() {
491
+ // Verbatim from the capture: a `session_search` that could not resolve its
492
+ // argument. Without this the failure is indistinguishable from a success.
493
+ let dir = tmpdir("toolerr");
494
+ let (db, conn) = make_db(&dir);
495
+ insert_cli_session(&conn, false);
496
+ tool_result(
497
+ &conn,
498
+ CLI_SESSION,
499
+ "tooluse_GqnMvLFT1UKNu83Un5rSIK",
500
+ "session_search",
501
+ r#"{"error": "around_message_id 1 not in session_id 20260803_080402_a54231", "success": false}"#,
502
+ 1_785_744_414.006_979,
503
+ );
504
+
505
+ let ev = poll_all(&db);
506
+ let results = of_type(&ev, "tool_result");
507
+ assert_eq!(results[0]["error_type"], "hermes_tool_error");
508
+ assert!(
509
+ results[0]["error"]
510
+ .as_str()
511
+ .unwrap()
512
+ .contains("not in session_id")
513
+ );
514
+
515
+ fs::remove_dir_all(&dir).ok();
516
+ }
517
+
518
+ #[test]
519
+ fn a_non_zero_exit_code_is_not_treated_as_a_tool_error() {
520
+ // `grep` finding nothing exits 1. Flagging that would fill the product with
521
+ // false alarms indistinguishable from the real ones above.
522
+ let dir = tmpdir("exitcode");
523
+ let (db, conn) = make_db(&dir);
524
+ insert_cli_session(&conn, false);
525
+ tool_result(
526
+ &conn,
527
+ CLI_SESSION,
528
+ "c1",
529
+ "terminal",
530
+ r#"{"output": "", "exit_code": 1, "error": null}"#,
531
+ 1_785_744_254.967_635_4,
532
+ );
533
+
534
+ let ev = poll_all(&db);
535
+ assert!(of_type(&ev, "tool_result")[0].get("error_type").is_none());
536
+
537
+ fs::remove_dir_all(&dir).ok();
538
+ }
539
+
540
+ // ── agent id: both session shapes ────────────────────────────────────────
541
+
542
+ #[test]
543
+ fn a_cli_session_is_grouped_by_its_working_directory() {
544
+ // `cwd` IS populated for CLI sessions (5/5 on the probe), so they group by
545
+ // project exactly like the hook source does — which is what makes hook
546
+ // events and session events for one run land under a single agent.
547
+ let dir = tmpdir("cwd");
548
+ let (db, conn) = make_db(&dir);
549
+ insert_session(
550
+ &conn,
551
+ CLI_SESSION,
552
+ "cli",
553
+ Some("/home/u/src/openclaw-local"),
554
+ 1_785_744_251.652_289_9,
555
+ None,
556
+ None,
557
+ None,
558
+ );
559
+ user(&conn, CLI_SESSION, "hi", 1_785_744_251.686_618);
560
+
561
+ let ev = poll_all(&db);
562
+ assert!(!ev.is_empty());
563
+ for e in &ev {
564
+ assert_eq!(e["agent_id"], "hermes-openclaw-local");
565
+ assert_eq!(e["hermes_cwd"], "/home/u/src/openclaw-local");
566
+ }
567
+
568
+ fs::remove_dir_all(&dir).ok();
569
+ }
570
+
571
+ #[test]
572
+ fn a_gateway_session_with_no_cwd_falls_back_to_its_source() {
573
+ // Slack/Telegram sessions genuinely have NULL cwd. Falling all the way back
574
+ // to a bare `hermes` would merge every transport into one agent nobody can
575
+ // act on, so the transport is the grouping axis instead.
576
+ let dir = tmpdir("gateway");
577
+ let (db, conn) = make_db(&dir);
578
+ insert_session(
579
+ &conn,
580
+ GATEWAY_SESSION,
581
+ "slack",
582
+ None,
583
+ 1_785_744_900.0,
584
+ None,
585
+ None,
586
+ Some("channel"),
587
+ );
588
+ user(&conn, GATEWAY_SESSION, "deploy please", 1_785_744_901.5);
589
+
590
+ let ev = poll_all(&db);
591
+ assert!(!ev.is_empty());
592
+ for e in &ev {
593
+ assert_eq!(e["agent_id"], "hermes-slack");
594
+ assert_eq!(e["hermes_source"], "slack");
595
+ assert!(
596
+ e.get("hermes_cwd").is_none(),
597
+ "a NULL cwd must not become an empty string"
598
+ );
599
+ }
600
+ assert_eq!(
601
+ of_type(&ev, "agent_start")[0]["hermes_chat_type"],
602
+ "channel"
603
+ );
604
+
605
+ fs::remove_dir_all(&dir).ok();
606
+ }
607
+
608
+ #[test]
609
+ fn a_message_whose_session_row_is_gone_falls_back_to_the_configured_agent_id() {
610
+ // A partially-applied rewind or a hand-edited database leaves orphans. One
611
+ // orphan must not error the poll and cost every other session in the batch.
612
+ let dir = tmpdir("orphan");
613
+ let (db, conn) = make_db(&dir);
614
+ conn.execute("PRAGMA foreign_keys = OFF", []).unwrap();
615
+ user(
616
+ &conn,
617
+ "20260803_999999_ffffff",
618
+ "orphan",
619
+ 1_785_744_251.686_618,
620
+ );
621
+
622
+ let ev = poll_all(&db);
623
+ assert_eq!(ev.len(), 1, "the orphan's own turn still ships");
624
+ assert_eq!(ev[0]["agent_id"], hermes::DEFAULT_AGENT_ID);
625
+ assert!(
626
+ of_type(&ev, "agent_start").is_empty(),
627
+ "no session row means nothing to start from"
628
+ );
629
+
630
+ fs::remove_dir_all(&dir).ok();
631
+ }
632
+
633
+ // ── timestamps ───────────────────────────────────────────────────────────
634
+
635
+ #[test]
636
+ fn real_float_epoch_seconds_become_rfc3339_with_six_subsecond_digits() {
637
+ // `timestamp` is a REAL epoch SECONDS float (1785744251.686618), not
638
+ // milliseconds and not ISO. Read as millis it lands in 1970; read as ISO it
639
+ // fails to parse and the row is dropped. Six digits are forced so this
640
+ // stream sorts against the hook stream on a shared timeline.
641
+ let dir = tmpdir("ts");
642
+ let (db, conn) = make_db(&dir);
643
+ insert_cli_session(&conn, false);
644
+ user(&conn, CLI_SESSION, "hi", 1_785_744_251.686_618);
645
+
646
+ let ev = poll_all(&db);
647
+ let req = of_type(&ev, "model_request")[0];
648
+ assert_eq!(req["timestamp"], "2026-08-03T08:04:11.686000Z");
649
+
650
+ fs::remove_dir_all(&dir).ok();
651
+ }
652
+
653
+ #[test]
654
+ fn a_row_with_an_implausible_timestamp_is_dropped_rather_than_dated_to_1970() {
655
+ // A 1970 event parks itself at the head of every timeline it lands on,
656
+ // silently reordering a real session. The watermark still advances past the
657
+ // row, so it costs one dropped message rather than a permanent retry.
658
+ let dir = tmpdir("badts");
659
+ let (db, conn) = make_db(&dir);
660
+ insert_cli_session(&conn, false);
661
+ user(&conn, CLI_SESSION, "from a broken clock", 42.0);
662
+ user(&conn, CLI_SESSION, "sane", 1_785_744_251.686_618);
663
+
664
+ let out = poll_at(&db, 0, 1000);
665
+ let reqs = of_type(&out.events, "model_request");
666
+ assert_eq!(reqs.len(), 1);
667
+ assert_eq!(reqs[0]["messages"][0]["content"], "sane");
668
+ assert_eq!(out.rows_seen, 2, "the bad row was read, just not shipped");
669
+ assert_eq!(out.watermark, 2, "and the watermark moved past it");
670
+
671
+ fs::remove_dir_all(&dir).ok();
672
+ }
673
+
674
+ // ── session boundaries ───────────────────────────────────────────────────
675
+
676
+ #[test]
677
+ fn a_finished_session_is_bracketed_by_a_start_and_an_end() {
678
+ let dir = tmpdir("bracket");
679
+ let (db, conn) = make_db(&dir);
680
+ insert_cli_session(&conn, true);
681
+ user(
682
+ &conn,
683
+ CLI_SESSION,
684
+ "List the files in /work using the terminal tool.",
685
+ 1_785_744_251.686_618,
686
+ );
687
+ assistant_calls(&conn, CLI_SESSION, TERMINAL_CALL, 1_785_744_254.923_234_2);
688
+ tool_result(
689
+ &conn,
690
+ CLI_SESSION,
691
+ "tooluse_8LfsNiQKh5FW5zW7gFOoVX",
692
+ "terminal",
693
+ r#"{"output": "README.md", "exit_code": 0, "error": null}"#,
694
+ 1_785_744_254.967_635_4,
695
+ );
696
+ assistant_text(&conn, CLI_SESSION, "Demo Project", 1_785_744_261.708_625);
697
+
698
+ let ev = poll_all(&db);
699
+ let types: Vec<&str> = ev.iter().filter_map(|e| e["type"].as_str()).collect();
700
+ assert_eq!(
701
+ types,
702
+ vec![
703
+ "agent_start",
704
+ "model_request",
705
+ "tool_use",
706
+ "tool_result",
707
+ "model_response",
708
+ "agent_end",
709
+ ],
710
+ "got {types:?}"
711
+ );
712
+
713
+ let start = of_type(&ev, "agent_start")[0];
714
+ // `started_at` is 34 ms before the first prompt; using the message time
715
+ // would report every session as starting late.
716
+ assert_eq!(start["timestamp"], "2026-08-03T08:04:11.652000Z");
717
+ assert!(
718
+ start["goal"]
719
+ .as_str()
720
+ .unwrap()
721
+ .starts_with("List the files"),
722
+ "the opening prompt is the session's goal"
723
+ );
724
+ assert_eq!(start["model"], MODEL);
725
+
726
+ let end = of_type(&ev, "agent_end")[0];
727
+ assert_eq!(end["hermes_end_reason"], "agent_close");
728
+ assert_eq!(end["input_tokens"], 37481);
729
+ // 999 µs past `ended_at`, so it cannot sort before a turn sharing its
730
+ // millisecond — the server's tie-break is a random id.
731
+ assert_eq!(end["timestamp"], "2026-08-03T08:04:21.719999Z");
732
+
733
+ fs::remove_dir_all(&dir).ok();
734
+ }
735
+
736
+ #[test]
737
+ fn a_live_session_gets_a_start_but_no_end() {
738
+ // `ended_at` is NULL until Hermes closes the session. Ending it anyway
739
+ // would emit an `agent_end` on every poll that touched the newest row.
740
+ let dir = tmpdir("live");
741
+ let (db, conn) = make_db(&dir);
742
+ insert_cli_session(&conn, false);
743
+ user(&conn, CLI_SESSION, "still going", 1_785_744_251.686_618);
744
+
745
+ let ev = poll_all(&db);
746
+ assert_eq!(of_type(&ev, "agent_start").len(), 1);
747
+ assert!(of_type(&ev, "agent_end").is_empty());
748
+
749
+ fs::remove_dir_all(&dir).ok();
750
+ }
751
+
752
+ #[test]
753
+ fn a_start_fires_once_even_when_the_session_is_re_read_from_zero() {
754
+ // The trigger is "this row is the session's lowest surviving id", a pure
755
+ // function of the database — not "the first time this poller saw the
756
+ // session", which would ship a fresh start after every daemon restart.
757
+ let dir = tmpdir("startonce");
758
+ let (db, conn) = make_db(&dir);
759
+ insert_cli_session(&conn, true);
760
+ user(&conn, CLI_SESSION, "one", 1_785_744_251.686_618);
761
+ assistant_text(&conn, CLI_SESSION, "two", 1_785_744_261.708_625);
762
+
763
+ let a = poll_all(&db);
764
+ let b = poll_all(&db);
765
+ assert_eq!(of_type(&a, "agent_start").len(), 1);
766
+ assert_eq!(a, b, "a re-read must produce byte-identical events");
767
+
768
+ fs::remove_dir_all(&dir).ok();
769
+ }
770
+
771
+ // ── watermark, limits, dedup ─────────────────────────────────────────────
772
+
773
+ #[test]
774
+ fn the_watermark_advances_and_a_second_poll_returns_nothing() {
775
+ let dir = tmpdir("watermark");
776
+ let (db, conn) = make_db(&dir);
777
+ insert_cli_session(&conn, true);
778
+ user(&conn, CLI_SESSION, "hi", 1_785_744_251.686_618);
779
+ assistant_text(&conn, CLI_SESSION, "hello", 1_785_744_261.708_625);
780
+
781
+ let first = poll_at(&db, 0, 1000);
782
+ assert!(!first.events.is_empty());
783
+ assert_eq!(first.watermark, 2);
784
+ assert!(!first.more);
785
+
786
+ let second = poll_at(&db, first.watermark, 1000);
787
+ assert!(second.events.is_empty(), "got {:#?}", second.events);
788
+ assert_eq!(second.rows_seen, 0);
789
+ assert_eq!(second.watermark, first.watermark);
790
+ assert!(!second.more);
791
+
792
+ fs::remove_dir_all(&dir).ok();
793
+ }
794
+
795
+ #[test]
796
+ fn hitting_the_row_limit_reports_more_so_a_backlog_drains() {
797
+ // The engine polls again immediately when `more` is set. Without it a
798
+ // restart after downtime trickles one batch per poll interval.
799
+ let dir = tmpdir("limit");
800
+ let (db, conn) = make_db(&dir);
801
+ insert_cli_session(&conn, true);
802
+ for i in 0..5 {
803
+ user(&conn, CLI_SESSION, "x", 1_785_744_251.686_618 + i as f64);
804
+ }
805
+
806
+ let out = poll_at(&db, 0, 2);
807
+ assert!(out.more);
808
+ assert_eq!(out.rows_seen, 2);
809
+ assert_eq!(out.watermark, 2);
810
+
811
+ let rest = poll_at(&db, out.watermark, 100);
812
+ assert!(!rest.more);
813
+ assert_eq!(rest.rows_seen, 3);
814
+
815
+ fs::remove_dir_all(&dir).ok();
816
+ }
817
+
818
+ #[test]
819
+ fn two_identical_messages_are_discriminated_by_their_row_id() {
820
+ // The same prompt asked twice in one session produces byte-identical
821
+ // events but for `hermes_row_id`. Without that discriminator the server's
822
+ // content hash collapses a genuinely repeated turn into one row.
823
+ let dir = tmpdir("dedup");
824
+ let (db, conn) = make_db(&dir);
825
+ insert_cli_session(&conn, false);
826
+ user(&conn, CLI_SESSION, "same", 1_785_744_251.686_618);
827
+ user(&conn, CLI_SESSION, "same", 1_785_744_251.686_618);
828
+
829
+ let ev = poll_all(&db);
830
+ let reqs = of_type(&ev, "model_request");
831
+ assert_eq!(reqs.len(), 2);
832
+ assert_eq!(reqs[0]["hermes_row_id"], 1);
833
+ assert_eq!(reqs[1]["hermes_row_id"], 2);
834
+ assert_ne!(reqs[0], reqs[1]);
835
+
836
+ fs::remove_dir_all(&dir).ok();
837
+ }
838
+
839
+ #[test]
840
+ fn a_rewound_row_is_still_shipped_and_marked_inactive() {
841
+ // Hermes flips `active` to 0 on rewind and compaction rather than deleting.
842
+ // Filtering on `active = 1` would mean a row inserted while inactive never
843
+ // ships at all, and the marker is what explains why a message the user can
844
+ // see was not in the prompt that followed it.
845
+ let dir = tmpdir("inactive");
846
+ let (db, conn) = make_db(&dir);
847
+ insert_cli_session(&conn, false);
848
+ user(&conn, CLI_SESSION, "rewound", 1_785_744_251.686_618);
849
+ conn.execute("UPDATE messages SET active = 0 WHERE id = 1", [])
850
+ .unwrap();
851
+
852
+ let ev = poll_all(&db);
853
+ assert_eq!(of_type(&ev, "model_request").len(), 1);
854
+ assert_eq!(ev[0]["hermes_active"], false);
855
+
856
+ fs::remove_dir_all(&dir).ok();
857
+ }
858
+
859
+ #[test]
860
+ fn the_fts_shadow_tables_are_populated_but_never_read() {
861
+ // `messages_fts*` mirrors every message under a rowid that means nothing
862
+ // here. A poller that widened its FROM would ship the whole conversation
863
+ // twice; this pins that the triggers fire and we still read `messages` only.
864
+ let dir = tmpdir("fts");
865
+ let (db, conn) = make_db(&dir);
866
+ insert_cli_session(&conn, false);
867
+ user(&conn, CLI_SESSION, "indexed by fts", 1_785_744_251.686_618);
868
+ assistant_text(&conn, CLI_SESSION, "so is this", 1_785_744_261.708_625);
869
+
870
+ let mirrored: i64 = conn
871
+ .query_row("SELECT count(*) FROM messages_fts", [], |r| r.get(0))
872
+ .unwrap();
873
+ assert_eq!(mirrored, 2, "the real triggers must have fired");
874
+
875
+ let out = poll_at(&db, 0, 1000);
876
+ assert_eq!(out.rows_seen, 2, "only the `messages` rows are read");
877
+ assert_eq!(of_type(&out.events, "model_request").len(), 1);
878
+ assert_eq!(of_type(&out.events, "model_response").len(), 1);
879
+
880
+ fs::remove_dir_all(&dir).ok();
881
+ }
882
+
883
+ #[test]
884
+ fn several_sessions_in_one_batch_keep_their_own_agent_ids() {
885
+ // One database holds every session on the machine, CLI and gateway alike.
886
+ // A poll that resolved the session once and reused it would file a Slack
887
+ // turn under the last CLI project it happened to see.
888
+ let dir = tmpdir("mixed");
889
+ let (db, conn) = make_db(&dir);
890
+ insert_session(
891
+ &conn,
892
+ CLI_SESSION,
893
+ "cli",
894
+ Some("/work"),
895
+ 1_785_744_251.6,
896
+ None,
897
+ None,
898
+ None,
899
+ );
900
+ insert_session(
901
+ &conn,
902
+ GATEWAY_SESSION,
903
+ "telegram",
904
+ None,
905
+ 1_785_744_900.0,
906
+ None,
907
+ None,
908
+ Some("dm"),
909
+ );
910
+ user(&conn, CLI_SESSION, "cli turn", 1_785_744_251.686_618);
911
+ user(&conn, GATEWAY_SESSION, "gateway turn", 1_785_744_901.5);
912
+
913
+ let ev = poll_all(&db);
914
+ let reqs = of_type(&ev, "model_request");
915
+ assert_eq!(reqs[0]["agent_id"], "hermes-work");
916
+ assert_eq!(reqs[1]["agent_id"], "hermes-telegram");
917
+
918
+ fs::remove_dir_all(&dir).ok();
919
+ }
920
+
921
+ // ── format table and discovery ───────────────────────────────────────────
922
+
923
+ #[test]
924
+ fn hermes_declares_a_rowid_watermark() {
925
+ // `messages` has no last-modified column, so `UpdatedAt` is not available
926
+ // as an alternative — the module docs record what that costs.
927
+ assert_eq!(hermes::FORMAT.watermark, Watermark::RowId);
928
+ assert_eq!(hermes::FORMAT.kind, "hermes");
929
+ }
930
+
931
+ #[test]
932
+ fn every_profile_is_a_separate_database_and_all_of_them_are_found() {
933
+ // A Hermes profile is a whole separate home with its own state.db. Reading
934
+ // only `~/.hermes/state.db` makes every non-default profile invisible —
935
+ // the exact bug the audit pillar shipped with before profile support.
936
+ let root = tmpdir("profiles");
937
+ fs::create_dir_all(root.join("profiles/work")).unwrap();
938
+ fs::create_dir_all(root.join("profiles/alpha")).unwrap();
939
+ fs::create_dir_all(root.join("profiles/.hidden")).unwrap();
940
+ fs::write(root.join("profiles/notadir"), b"x").unwrap();
941
+
942
+ let paths = hermes::db_paths_under(&root);
943
+ assert_eq!(
944
+ paths,
945
+ vec![
946
+ root.join("state.db"),
947
+ // Sorted, so the set of sources a daemon starts is stable across
948
+ // restarts; directory order is not.
949
+ root.join("profiles/alpha/state.db"),
950
+ root.join("profiles/work/state.db"),
951
+ ]
952
+ );
953
+
954
+ fs::remove_dir_all(&root).ok();
955
+ }
956
+
957
+ #[test]
958
+ fn a_missing_profiles_directory_still_yields_the_default_database() {
959
+ // The single-profile install everyone starts with.
960
+ let root = tmpdir("noprofiles");
961
+ assert_eq!(
962
+ hermes::db_paths_under(&root),
963
+ vec![root.join("state.db")],
964
+ "discovery must fail open"
965
+ );
966
+ fs::remove_dir_all(&root).ok();
967
+ }
968
+
969
+ #[test]
970
+ fn a_hermes_home_pointing_at_a_profile_climbs_back_to_the_root() {
971
+ // The generated per-profile alias wrapper exports
972
+ // HERMES_HOME=<root>/profiles/<name>. Taking that literally would collect
973
+ // from one profile and silently ignore every sibling.
974
+ assert_eq!(
975
+ hermes::root_from_home(Path::new("/home/u/.hermes/profiles/work")),
976
+ PathBuf::from("/home/u/.hermes")
977
+ );
978
+ assert_eq!(
979
+ hermes::root_from_home(Path::new("/home/u/.hermes")),
980
+ PathBuf::from("/home/u/.hermes")
981
+ );
982
+ }
983
+
984
+ // ── engine ───────────────────────────────────────────────────────────────
985
+
986
+ fn spec(db: PathBuf, spool: PathBuf, state: PathBuf) -> sqlitepoll::Spec {
987
+ sqlitepoll::Spec {
988
+ format: hermes::FORMAT,
989
+ db_path: db,
990
+ spool_dir: spool,
991
+ state_dir: state,
992
+ poll_interval: Duration::from_millis(150),
993
+ params: sqlitepoll::Params {
994
+ agent_id: hermes::DEFAULT_AGENT_ID.into(),
995
+ environment: "local".into(),
996
+ max_rows_per_poll: 500,
997
+ max_batch_bytes: 8 * 1024 * 1024,
998
+ max_drain_passes: 8,
999
+ },
1000
+ }
1001
+ }
1002
+
1003
+ fn spooled(dir: &Path) -> Vec<Value> {
1004
+ let mut out = Vec::new();
1005
+ let Ok(rd) = fs::read_dir(dir) else {
1006
+ return out;
1007
+ };
1008
+ for e in rd.filter_map(|e| e.ok()) {
1009
+ let p = e.path();
1010
+ if p.extension().and_then(|x| x.to_str()) != Some("jsonl") {
1011
+ continue;
1012
+ }
1013
+ for l in fs::read_to_string(&p).unwrap().lines() {
1014
+ if !l.trim().is_empty() {
1015
+ out.push(serde_json::from_str(l).unwrap());
1016
+ }
1017
+ }
1018
+ }
1019
+ out
1020
+ }
1021
+
1022
+ async fn run_briefly(s: sqlitepoll::Spec, ms: u64) {
1023
+ let sd = Shutdown::for_test(Arc::new(AtomicBool::new(false)));
1024
+ let _ = tokio::time::timeout(Duration::from_millis(ms), sqlitepoll::run(s, sd)).await;
1025
+ }
1026
+
1027
+ #[tokio::test(flavor = "multi_thread")]
1028
+ async fn a_session_is_spooled_once_and_a_resumed_run_ships_nothing() {
1029
+ // The end-to-end property: the cursor survives the process, so a daemon
1030
+ // restart does not re-ship every session in the database.
1031
+ let dir = tmpdir("engine-db");
1032
+ let spool = tmpdir("engine-spool");
1033
+ let state = tmpdir("engine-state");
1034
+ let (db, conn) = make_db(&dir);
1035
+ insert_cli_session(&conn, true);
1036
+ user(&conn, CLI_SESSION, "list the files", 1_785_744_251.686_618);
1037
+ assistant_calls(&conn, CLI_SESSION, TERMINAL_CALL, 1_785_744_254.923_234_2);
1038
+ tool_result(
1039
+ &conn,
1040
+ CLI_SESSION,
1041
+ "tooluse_8LfsNiQKh5FW5zW7gFOoVX",
1042
+ "terminal",
1043
+ r#"{"output": "README.md", "exit_code": 0, "error": null}"#,
1044
+ 1_785_744_254.967_635_4,
1045
+ );
1046
+ assistant_text(&conn, CLI_SESSION, "Demo Project", 1_785_744_261.708_625);
1047
+ drop(conn);
1048
+
1049
+ run_briefly(spec(db.clone(), spool.clone(), state.clone()), 900).await;
1050
+ let ev = spooled(&spool);
1051
+ let types: Vec<&str> = ev.iter().filter_map(|e| e["type"].as_str()).collect();
1052
+ for want in [
1053
+ "agent_start",
1054
+ "model_request",
1055
+ "tool_use",
1056
+ "tool_result",
1057
+ "model_response",
1058
+ "agent_end",
1059
+ ] {
1060
+ assert!(types.contains(&want), "missing {want}: {types:?}");
1061
+ }
1062
+ for e in &ev {
1063
+ assert_eq!(e["session_id"], CLI_SESSION);
1064
+ assert_eq!(e["agent_id"], "hermes-work");
1065
+ assert_eq!(e["environment"], "local");
1066
+ }
1067
+
1068
+ for e in fs::read_dir(&spool).unwrap().filter_map(|e| e.ok()) {
1069
+ fs::remove_file(e.path()).ok();
1070
+ }
1071
+ run_briefly(spec(db.clone(), spool.clone(), state.clone()), 600).await;
1072
+ assert!(
1073
+ spooled(&spool).is_empty(),
1074
+ "a resumed run must ship nothing"
1075
+ );
1076
+
1077
+ fs::remove_dir_all(&dir).ok();
1078
+ fs::remove_dir_all(&spool).ok();
1079
+ fs::remove_dir_all(&state).ok();
1080
+ }
1081
+
1082
+ #[tokio::test(flavor = "multi_thread")]
1083
+ async fn new_turns_appended_after_a_pass_are_picked_up_without_re_shipping() {
1084
+ let dir = tmpdir("append-db");
1085
+ let spool = tmpdir("append-spool");
1086
+ let state = tmpdir("append-state");
1087
+ let (db, conn) = make_db(&dir);
1088
+ insert_cli_session(&conn, false);
1089
+ user(&conn, CLI_SESSION, "first", 1_785_744_251.686_618);
1090
+
1091
+ run_briefly(spec(db.clone(), spool.clone(), state.clone()), 900).await;
1092
+ assert!(!spooled(&spool).is_empty());
1093
+ for e in fs::read_dir(&spool).unwrap().filter_map(|e| e.ok()) {
1094
+ fs::remove_file(e.path()).ok();
1095
+ }
1096
+
1097
+ user(&conn, CLI_SESSION, "second", 1_785_744_300.0);
1098
+ run_briefly(spec(db.clone(), spool.clone(), state.clone()), 900).await;
1099
+
1100
+ let ev = spooled(&spool);
1101
+ assert_eq!(ev.len(), 1, "only the appended turn must ship, got {ev:#?}");
1102
+ assert_eq!(ev[0]["messages"][0]["content"], "second");
1103
+
1104
+ drop(conn);
1105
+ fs::remove_dir_all(&dir).ok();
1106
+ fs::remove_dir_all(&spool).ok();
1107
+ fs::remove_dir_all(&state).ok();
1108
+ }
1109
+
1110
+ #[tokio::test(flavor = "multi_thread")]
1111
+ async fn an_absent_hermes_install_is_not_an_error() {
1112
+ // The overwhelmingly common case: Hermes simply is not installed. The
1113
+ // source must idle quietly rather than logging a failure every poll.
1114
+ let spool = tmpdir("absent-spool");
1115
+ let state = tmpdir("absent-state");
1116
+ run_briefly(
1117
+ spec(
1118
+ PathBuf::from("/nonexistent/fpai/hermes/state.db"),
1119
+ spool.clone(),
1120
+ state.clone(),
1121
+ ),
1122
+ 500,
1123
+ )
1124
+ .await;
1125
+ assert!(spooled(&spool).is_empty());
1126
+
1127
+ fs::remove_dir_all(&spool).ok();
1128
+ fs::remove_dir_all(&state).ok();
1129
+ }