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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +6 -6
  3. package/.next/standalone/.next/prerender-manifest.json +3 -3
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
  6. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
  14. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
  15. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
  17. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  18. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  22. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  23. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
  24. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
  25. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
  27. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +1 -1
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
  46. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
  47. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  48. package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
  49. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  50. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  51. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
  53. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
  54. package/.next/standalone/.next/server/app/policies/page.js +2 -2
  55. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
  58. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  59. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  63. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  64. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
  67. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  68. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  69. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  70. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
  71. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
  72. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
  75. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  76. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
  78. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
  80. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
  82. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
  83. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
  84. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
  85. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
  86. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
  87. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  90. package/.next/standalone/.next/server/chunks/ssr/{node_modules_html-to-image_es_index_0oy_-36.js → node_modules_html-to-image_es_index_0jfts82.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
  93. package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
  94. package/.next/standalone/.next/server/pages/404.html +1 -1
  95. package/.next/standalone/.next/server/pages/500.html +1 -1
  96. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  97. package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
  98. package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
  99. package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
  101. package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
  102. package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
  103. package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
  104. package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
  105. package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
  106. package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
  107. package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
  108. package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
  109. package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
  110. package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
  111. package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
  112. package/.next/standalone/Cargo.lock +1992 -198
  113. package/.next/standalone/Cargo.toml +1 -1
  114. package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
  115. package/.next/standalone/app/components/pause-notices.tsx +111 -0
  116. package/.next/standalone/app/policies/hooks-client.tsx +81 -6
  117. package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
  118. package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
  119. package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
  120. package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
  121. package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
  122. package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
  123. package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
  124. package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
  125. package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
  126. package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
  127. package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
  128. package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
  129. package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
  130. package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
  131. package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
  132. package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
  133. package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
  134. package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
  135. package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
  136. package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
  137. package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
  138. package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
  139. package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
  140. package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
  141. package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
  142. package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
  143. package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
  144. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
  145. package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
  146. package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
  147. package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
  148. package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
  149. package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
  150. package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
  151. package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
  152. package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
  153. package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
  154. package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
  155. package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
  156. package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
  157. package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
  158. package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
  159. package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
  160. package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
  161. package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
  162. package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
  163. package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
  164. package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
  165. package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
  166. package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
  167. package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
  168. package/.next/standalone/package.json +2 -2
  169. package/.next/standalone/server.js +1 -1
  170. package/bin/failproofai.mjs +129 -0
  171. package/dist/cli.mjs +2195 -770
  172. package/dist/worker.mjs +365 -52
  173. package/package.json +2 -2
  174. package/src/hooks/builtin-policies.ts +139 -0
  175. package/src/hooks/cloud-connection.ts +205 -0
  176. package/src/hooks/cloud-enrollment-cli.ts +227 -0
  177. package/src/hooks/cloud-enrollment.ts +173 -0
  178. package/src/hooks/cloud-managed-policies.ts +138 -0
  179. package/src/hooks/collector-config.ts +226 -0
  180. package/src/hooks/configure-wizard.ts +221 -5
  181. package/src/hooks/custom-hooks-loader.ts +69 -25
  182. package/src/hooks/daemon-client.ts +18 -0
  183. package/src/hooks/handler.ts +144 -6
  184. package/src/hooks/hook-activity-store.ts +50 -0
  185. package/src/hooks/loader-utils.ts +6 -4
  186. package/src/hooks/policy-types.ts +35 -0
  187. package/src/hooks/session-pause-cli.ts +157 -0
  188. package/src/hooks/session-pause.ts +236 -0
  189. package/src/hooks/tui.ts +115 -0
  190. package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
  191. package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
  192. package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
  193. package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
  194. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
  195. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
  196. /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_ssgManifest.js +0 -0
@@ -0,0 +1,511 @@
1
+ //! goose session capture — a [`sqlitepoll`](crate::sqlitepoll) adapter.
2
+ //!
3
+ //! goose (Block's local dev agent) keeps every session in ONE SQLite database,
4
+ //! `~/.local/share/goose/sessions/sessions.db`. There are no per-session files,
5
+ //! so there is nothing for [`filetail`](crate::filetail) to tail. Verified live
6
+ //! against goose v1.43.0, `schema_version` 15:
7
+ //!
8
+ //! ```text
9
+ //! sessions(id TEXT PK `YYYYMMDD_N`, name, description, user_set_name,
10
+ //! session_type TEXT NOT NULL DEFAULT 'user', working_dir TEXT NOT NULL,
11
+ //! created_at, updated_at, extension_data, …16 token/cost columns…,
12
+ //! schedule_id, recipe_json, provider_name, model_config_json,
13
+ //! goose_mode, archived_at, project_id, parent_session_id)
14
+ //! messages(id INTEGER PK AUTOINCREMENT, message_id TEXT, session_id TEXT NOT NULL,
15
+ //! role TEXT NOT NULL, content_json TEXT NOT NULL,
16
+ //! created_timestamp INTEGER NOT NULL, timestamp TIMESTAMP,
17
+ //! tokens INTEGER, metadata_json TEXT)
18
+ //! ```
19
+ //!
20
+ //! # `content_json` is a Claude-style typed-block ARRAY
21
+ //!
22
+ //! Not OpenAI-style like Devin. Shapes seen in the live corpus:
23
+ //!
24
+ //! ```text
25
+ //! {"type":"text","text":…}
26
+ //! {"type":"toolRequest","id":"tooluse_…",
27
+ //! "toolCall":{"status":"success","value":{"name":…,"arguments":{…}}},
28
+ //! "_meta":{"goose_extension":"developer"}}
29
+ //! {"type":"toolResponse","id":"tooluse_…",
30
+ //! "toolResult":{"status":"success","value":{"content":[{"type":"text","text":…}],
31
+ //! "structuredContent":{…},"isError":false}}}
32
+ //! ```
33
+ //!
34
+ //! Two properties of that layout drive most of the code below:
35
+ //!
36
+ //! * **A `toolResponse` arrives in a `role:'user'` row.** Branching on `role`
37
+ //! alone therefore renders every tool result as a user prompt — a session's
38
+ //! timeline turns into the model asking itself questions. Every branch here
39
+ //! is on the *block* type, with `role` used only to split plain text into a
40
+ //! request vs. a response.
41
+ //! * **Rows are NOT turn-aligned.** One assistant turn that emitted four
42
+ //! parallel `shell` calls produced *five* rows (a preamble text row plus one
43
+ //! row per call), and its four results came back as four more rows, all
44
+ //! sharing a single `created_timestamp`. Nothing here may assume one row is
45
+ //! one turn, and the sub-second ordering scheme exists because of the shared
46
+ //! timestamp — see [`transform::slot_micros`].
47
+ //!
48
+ //! # A rowid watermark is right for the happy path, and is NOT airtight
49
+ //!
50
+ //! [`Watermark::RowId`] is declared because `messages.id` is
51
+ //! `INTEGER PRIMARY KEY AUTOINCREMENT` and a normal session only ever appends:
52
+ //! a tool call's result is a *new* row, not an update to the call's row, which
53
+ //! is exactly the failure mode that forces opencode onto `UpdatedAt`.
54
+ //!
55
+ //! It is not a guarantee. The goose binary contains
56
+ //! `UPDATE messages SET content_json`, `UPDATE messages SET metadata_json` and
57
+ //! rewind's `DELETE FROM messages`. So:
58
+ //!
59
+ //! * a row **edited after** we shipped it keeps the shipped copy — the edit is
60
+ //! invisible to us, because `id` did not move;
61
+ //! * a row **deleted** behind the watermark leaves an event on the server with
62
+ //! no counterpart in goose's own history.
63
+ //!
64
+ //! There is no cheap fix: `messages` carries no `updated_at`, so `UpdatedAt`
65
+ //! has no column to order by, and re-reading the whole table every poll would
66
+ //! re-ship an entire history to be deduped on every pass. The honest position
67
+ //! is that this source is append-accurate and edit-blind, and that the two
68
+ //! writes above have not been observed firing in a normal session.
69
+ //!
70
+ //! # `session_type='hidden'` is filtered
71
+ //!
72
+ //! Those are `goose run --no-session` scratch runs, which the product should
73
+ //! not show as sessions at all. The other values (`user`, `subagent`,
74
+ //! `scheduled`, `terminal`, `acp`) are kept — filtering by an allowlist would
75
+ //! silently drop a session type the next goose release adds.
76
+ //!
77
+ //! Hidden rows still advance the watermark and still act as the *predecessor*
78
+ //! of the row after them, so filtering them can never strand a visible session
79
+ //! (see the `agent_end` rule below).
80
+ //!
81
+ //! # Where `agent_start` and `agent_end` come from
82
+ //!
83
+ //! goose writes no session-start or session-end marker, and the sqlite engine
84
+ //! exposes no idle callback the way [`filetail`](crate::filetail) does — a
85
+ //! [`SqliteFormat`] is one pure `poll`, so the wall clock is off limits
86
+ //! (consulting it would make two reads of the same rows produce different
87
+ //! bytes, which is precisely what the server's content-hash dedup relies on).
88
+ //! Both events are therefore derived from the row stream itself:
89
+ //!
90
+ //! * `agent_start` rides the session's **first** message row (`MIN(id)`), so it
91
+ //! is emitted exactly once and never depends on when we polled.
92
+ //! * `agent_end` rides the row **after** a session's last row: when row *R*
93
+ //! belongs to a different session than its immediate predecessor *P*, and *P*
94
+ //! is `MAX(id)` for its session, *P*'s session is finished and its end is
95
+ //! emitted at *P*'s timestamp. Each row is processed once, so this fires once.
96
+ //!
97
+ //! The known gap: the newest session in the database has no successor row, so
98
+ //! it stays open until goose is used again. That is deliberate. The
99
+ //! alternatives are worse — a wall-clock idle rule breaks purity, and
100
+ //! recomputing ends from the `sessions` table each poll re-ships an `agent_end`
101
+ //! per session forever, for the entire history, on an idle machine.
102
+ //!
103
+ //! A session that is *resumed* after another session wrote gets a second
104
+ //! `agent_end` (its `MAX(id)` moved). That reads correctly on a timeline: it
105
+ //! genuinely was two runs.
106
+
107
+ pub mod transform;
108
+
109
+ use std::collections::BTreeMap;
110
+ use std::path::PathBuf;
111
+
112
+ use rusqlite::{Connection, OptionalExtension, params};
113
+ use serde_json::Value;
114
+
115
+ use crate::sqlitepoll::{PollOutcome, PollRequest, SqliteFormat, Watermark};
116
+ use transform::{MessageRow, RowCtx};
117
+
118
+ /// The format table the sqlite engine drives.
119
+ pub const FORMAT: SqliteFormat = SqliteFormat {
120
+ kind: "goose",
121
+ watermark: Watermark::RowId,
122
+ poll,
123
+ };
124
+
125
+ /// The agent id used when a session has no usable `working_dir`.
126
+ pub const DEFAULT_AGENT_ID: &str = "goose";
127
+
128
+ /// `goose run --no-session` scratch runs.
129
+ const HIDDEN_SESSION_TYPE: &str = "hidden";
130
+
131
+ // ── database location ────────────────────────────────────────────────────
132
+
133
+ /// Where goose keeps its sessions database.
134
+ ///
135
+ /// `GOOSE_DB_PATH` and `GOOSE_HOME` are the same overrides goose itself and
136
+ /// `lib/goose-sessions.ts` honour, so a relocated goose home stays covered by
137
+ /// both halves of the product rather than by whichever half was tested.
138
+ pub fn db_path() -> PathBuf {
139
+ db_path_from(
140
+ var("GOOSE_DB_PATH"),
141
+ var("GOOSE_HOME"),
142
+ var("XDG_DATA_HOME"),
143
+ var("HOME"),
144
+ )
145
+ }
146
+
147
+ fn var(name: &str) -> Option<PathBuf> {
148
+ std::env::var_os(name)
149
+ .filter(|v| !v.is_empty())
150
+ .map(PathBuf::from)
151
+ }
152
+
153
+ /// The resolution order, split out so it is testable without mutating the
154
+ /// process environment (which is `unsafe` in edition 2024 and races every other
155
+ /// test in the binary).
156
+ ///
157
+ /// `GOOSE_HOME` names the *data* directory that contains `sessions/`, matching
158
+ /// goose's own meaning of the variable — pointing it at a database file is a
159
+ /// mistake `GOOSE_DB_PATH` exists to serve.
160
+ pub fn db_path_from(
161
+ db: Option<PathBuf>,
162
+ goose_home: Option<PathBuf>,
163
+ xdg_data_home: Option<PathBuf>,
164
+ home: Option<PathBuf>,
165
+ ) -> PathBuf {
166
+ if let Some(p) = db {
167
+ return p;
168
+ }
169
+ let data = goose_home.unwrap_or_else(|| {
170
+ xdg_data_home
171
+ .unwrap_or_else(|| home.unwrap_or_default().join(".local").join("share"))
172
+ .join("goose")
173
+ });
174
+ data.join("sessions").join("sessions.db")
175
+ }
176
+
177
+ // ── queries ──────────────────────────────────────────────────────────────
178
+
179
+ /// The batch. A LEFT JOIN rather than an inner one: the `session_id` foreign
180
+ /// key is declared but SQLite does not enforce it unless `PRAGMA foreign_keys`
181
+ /// is on, so an inner join would make a message with a missing session row
182
+ /// vanish silently instead of shipping with a fallback agent id.
183
+ const SELECT_ROWS: &str = "\
184
+ SELECT m.id, m.session_id, m.role, m.content_json, m.created_timestamp, m.metadata_json, \
185
+ s.session_type, s.working_dir, s.model_config_json, s.provider_name \
186
+ FROM messages m \
187
+ LEFT JOIN sessions s ON s.id = m.session_id \
188
+ WHERE m.id > ?1 \
189
+ ORDER BY m.id \
190
+ LIMIT ?2";
191
+
192
+ /// The row immediately before the batch, so the `agent_end` rule works across a
193
+ /// poll boundary too — otherwise a session whose last row ended the previous
194
+ /// batch would never be closed.
195
+ const SELECT_TAIL: &str = "\
196
+ SELECT m.id, m.session_id, m.created_timestamp, \
197
+ s.session_type, s.working_dir, s.model_config_json, s.provider_name \
198
+ FROM messages m \
199
+ LEFT JOIN sessions s ON s.id = m.session_id \
200
+ WHERE m.id <= ?1 \
201
+ ORDER BY m.id DESC \
202
+ LIMIT 1";
203
+
204
+ /// First and last message of a session, for the start/end rules. Served by
205
+ /// `idx_messages_session`, and cached per poll so a batch touching one session
206
+ /// runs it once.
207
+ const SELECT_BOUNDS: &str = "SELECT MIN(id), MAX(id) FROM messages WHERE session_id = ?1";
208
+
209
+ /// The `toolRequest` row for a result whose call landed in an earlier batch.
210
+ ///
211
+ /// `LIKE` rather than a join because the id lives inside the JSON blob. The
212
+ /// pattern's `_` characters (every goose id starts `tooluse_`) are LIKE
213
+ /// wildcards, so this can over-match; that is harmless because the block it
214
+ /// finds is then parsed and its `id` compared exactly. Scoped to the session
215
+ /// and to rows before the result, so the scan is bounded by one session's
216
+ /// history and only runs when a call and its result straddle a poll boundary.
217
+ const SELECT_TOOL_REQUEST: &str = "\
218
+ SELECT content_json FROM messages \
219
+ WHERE session_id = ?1 AND id < ?2 AND content_json LIKE ?3 \
220
+ ORDER BY id DESC LIMIT 1";
221
+
222
+ // ── row types ────────────────────────────────────────────────────────────
223
+
224
+ /// The `sessions` columns joined onto every message row.
225
+ #[derive(Debug, Clone, Default)]
226
+ struct SessionCols {
227
+ session_type: Option<String>,
228
+ working_dir: Option<String>,
229
+ model_config_json: Option<String>,
230
+ provider_name: Option<String>,
231
+ }
232
+
233
+ impl SessionCols {
234
+ fn is_hidden(&self) -> bool {
235
+ self.session_type.as_deref() == Some(HIDDEN_SESSION_TYPE)
236
+ }
237
+ }
238
+
239
+ /// One row of the batch.
240
+ struct BatchRow {
241
+ msg: MessageRow,
242
+ cols: SessionCols,
243
+ }
244
+
245
+ /// The predecessor row. Deliberately does not carry `content_json`: it exists
246
+ /// only to answer "did the previous row end its session", and that blob reached
247
+ /// 52 KB in the live corpus.
248
+ struct Tail {
249
+ id: i64,
250
+ session_id: String,
251
+ created_timestamp: i64,
252
+ cols: SessionCols,
253
+ }
254
+
255
+ // ── the poll ─────────────────────────────────────────────────────────────
256
+
257
+ /// Read rows past the watermark and turn them into events.
258
+ ///
259
+ /// Pure with respect to `(connection contents, request)`: no clock, no counter,
260
+ /// no state carried between calls. Everything order-dependent is derived from
261
+ /// `messages.id`, so re-reading the same rows produces byte-identical events
262
+ /// and the server's content-hash dedup collapses them.
263
+ fn poll(conn: &Connection, req: &PollRequest) -> rusqlite::Result<PollOutcome> {
264
+ let rows = read_rows(conn, req)?;
265
+
266
+ let mut out = PollOutcome {
267
+ watermark: req.watermark,
268
+ rows_seen: rows.len() as u64,
269
+ // A backlog should drain rather than trickle. Guarded on a non-zero
270
+ // limit so a `max_rows: 0` misconfiguration cannot spin the drain loop.
271
+ more: req.max_rows > 0 && rows.len() as u64 >= req.max_rows,
272
+ events: Vec::new(),
273
+ };
274
+ let Some(last) = rows.last() else {
275
+ return Ok(out);
276
+ };
277
+ // Advanced past every row EXAMINED, not every row emitted, so filtered
278
+ // (hidden) rows are not re-read on every poll for the life of the machine.
279
+ out.watermark = last.msg.id.max(req.watermark);
280
+
281
+ let tool_names = resolve_tool_names(conn, &rows)?;
282
+ let mut bounds: BTreeMap<String, (i64, i64)> = BTreeMap::new();
283
+ let mut ctxs: BTreeMap<String, RowCtx> = BTreeMap::new();
284
+ let mut prev = read_tail(conn, req.watermark)?;
285
+
286
+ for row in &rows {
287
+ if let Some(p) = prev.as_ref()
288
+ && p.session_id != row.msg.session_id
289
+ && !p.cols.is_hidden()
290
+ && session_bounds(conn, &mut bounds, &p.session_id)?.1 == p.id
291
+ {
292
+ let ctx = context(&mut ctxs, &p.session_id, &p.cols, req);
293
+ if let Some(ev) = transform::agent_end(&p.session_id, p.id, p.created_timestamp, ctx) {
294
+ out.events.push(ev);
295
+ }
296
+ }
297
+
298
+ if !row.cols.is_hidden() {
299
+ let is_first = session_bounds(conn, &mut bounds, &row.msg.session_id)?.0 == row.msg.id;
300
+ let ctx = context(&mut ctxs, &row.msg.session_id, &row.cols, req);
301
+ if is_first
302
+ && let Some(ev) =
303
+ transform::agent_start(&row.msg, row.cols.working_dir.as_deref(), ctx)
304
+ {
305
+ out.events.push(ev);
306
+ }
307
+ out.events
308
+ .extend(transform::content_events(&row.msg, ctx, &tool_names));
309
+ }
310
+
311
+ prev = Some(Tail {
312
+ id: row.msg.id,
313
+ session_id: row.msg.session_id.clone(),
314
+ created_timestamp: row.msg.created_timestamp,
315
+ cols: row.cols.clone(),
316
+ });
317
+ }
318
+
319
+ Ok(out)
320
+ }
321
+
322
+ fn read_rows(conn: &Connection, req: &PollRequest) -> rusqlite::Result<Vec<BatchRow>> {
323
+ // Saturating rather than `as`: a wrapped negative LIMIT means "no limit" in
324
+ // SQLite, which would quietly turn a huge max_rows into an unbounded read.
325
+ let limit = i64::try_from(req.max_rows).unwrap_or(i64::MAX);
326
+ let mut stmt = conn.prepare(SELECT_ROWS)?;
327
+ let rows = stmt.query_map(params![req.watermark, limit], |r| {
328
+ let content: Option<String> = r.get(3)?;
329
+ Ok(BatchRow {
330
+ msg: MessageRow {
331
+ id: r.get(0)?,
332
+ session_id: r.get::<_, Option<String>>(1)?.unwrap_or_default(),
333
+ role: r.get::<_, Option<String>>(2)?.unwrap_or_default(),
334
+ created_timestamp: r.get::<_, Option<i64>>(4)?.unwrap_or(0),
335
+ metadata_json: r.get(5)?,
336
+ blocks: transform::parse_blocks(content.as_deref()),
337
+ },
338
+ cols: SessionCols {
339
+ session_type: r.get(6)?,
340
+ working_dir: r.get(7)?,
341
+ model_config_json: r.get(8)?,
342
+ provider_name: r.get(9)?,
343
+ },
344
+ })
345
+ })?;
346
+ rows.collect()
347
+ }
348
+
349
+ fn read_tail(conn: &Connection, watermark: i64) -> rusqlite::Result<Option<Tail>> {
350
+ conn.query_row(SELECT_TAIL, params![watermark], |r| {
351
+ Ok(Tail {
352
+ id: r.get(0)?,
353
+ session_id: r.get::<_, Option<String>>(1)?.unwrap_or_default(),
354
+ created_timestamp: r.get::<_, Option<i64>>(2)?.unwrap_or(0),
355
+ cols: SessionCols {
356
+ session_type: r.get(3)?,
357
+ working_dir: r.get(4)?,
358
+ model_config_json: r.get(5)?,
359
+ provider_name: r.get(6)?,
360
+ },
361
+ })
362
+ })
363
+ .optional()
364
+ }
365
+
366
+ fn session_bounds(
367
+ conn: &Connection,
368
+ cache: &mut BTreeMap<String, (i64, i64)>,
369
+ session_id: &str,
370
+ ) -> rusqlite::Result<(i64, i64)> {
371
+ if let Some(b) = cache.get(session_id) {
372
+ return Ok(*b);
373
+ }
374
+ let bounds = conn.query_row(SELECT_BOUNDS, params![session_id], |r| {
375
+ Ok((
376
+ r.get::<_, Option<i64>>(0)?.unwrap_or(0),
377
+ r.get::<_, Option<i64>>(1)?.unwrap_or(0),
378
+ ))
379
+ })?;
380
+ cache.insert(session_id.to_string(), bounds);
381
+ Ok(bounds)
382
+ }
383
+
384
+ /// The per-session envelope pieces, built once per poll.
385
+ ///
386
+ /// Cached because `model_config_json` has to be parsed to find the model and
387
+ /// every row of a session repeats it — a 21-row session would otherwise parse
388
+ /// the same blob 21 times.
389
+ fn context<'a>(
390
+ cache: &'a mut BTreeMap<String, RowCtx>,
391
+ session_id: &str,
392
+ cols: &SessionCols,
393
+ req: &PollRequest,
394
+ ) -> &'a RowCtx {
395
+ cache.entry(session_id.to_string()).or_insert_with(|| {
396
+ RowCtx::new(
397
+ cols.working_dir.as_deref(),
398
+ cols.model_config_json.as_deref(),
399
+ cols.provider_name.as_deref(),
400
+ &req.environment,
401
+ &req.agent_id,
402
+ )
403
+ })
404
+ }
405
+
406
+ /// Map every tool-call id in the batch to its tool name.
407
+ ///
408
+ /// The name appears on the `toolRequest` block and on NO `toolResponse` block,
409
+ /// so without this every result is a row in the product with a blank tool —
410
+ /// the same defect the Claude source's pending-tool state prevents. The
411
+ /// difference here is that the call and the result are separate *rows*, so a
412
+ /// poll boundary can land between them; the second pass goes back to the
413
+ /// database for exactly those.
414
+ fn resolve_tool_names(
415
+ conn: &Connection,
416
+ rows: &[BatchRow],
417
+ ) -> rusqlite::Result<BTreeMap<String, String>> {
418
+ let mut names: BTreeMap<String, String> = BTreeMap::new();
419
+ for row in rows {
420
+ for block in &row.msg.blocks {
421
+ if let Some((id, name)) = transform::tool_request_name(block) {
422
+ names.insert(id, name);
423
+ }
424
+ }
425
+ }
426
+
427
+ let mut stmt = conn.prepare(SELECT_TOOL_REQUEST)?;
428
+ for row in rows {
429
+ for block in &row.msg.blocks {
430
+ if block.get("type").and_then(Value::as_str) != Some("toolResponse") {
431
+ continue;
432
+ }
433
+ let Some(id) = block.get("id").and_then(Value::as_str) else {
434
+ continue;
435
+ };
436
+ if names.contains_key(id) {
437
+ continue;
438
+ }
439
+ let found: Option<String> = stmt
440
+ .query_row(
441
+ params![row.msg.session_id, row.msg.id, format!("%\"{id}\"%")],
442
+ |r| r.get(0),
443
+ )
444
+ .optional()?;
445
+ if let Some(content) = found
446
+ && let Some(name) = transform::parse_blocks(Some(&content))
447
+ .iter()
448
+ .find_map(|b| transform::tool_request_name(b).filter(|(i, _)| i == id))
449
+ .map(|(_, n)| n)
450
+ {
451
+ names.insert(id.to_string(), name);
452
+ }
453
+ }
454
+ }
455
+ Ok(names)
456
+ }
457
+
458
+ #[cfg(test)]
459
+ mod tests {
460
+ use super::*;
461
+
462
+ #[test]
463
+ fn the_database_path_prefers_the_explicit_override() {
464
+ // GOOSE_DB_PATH names the file; GOOSE_HOME names the data DIRECTORY.
465
+ // Conflating them puts the poller on `<db>/sessions/sessions.db`, which
466
+ // does not exist, and the source then reports "not installed" forever.
467
+ let explicit = db_path_from(
468
+ Some(PathBuf::from("/srv/goose.db")),
469
+ Some(PathBuf::from("/data/goose")),
470
+ None,
471
+ Some(PathBuf::from("/home/u")),
472
+ );
473
+ assert_eq!(explicit, PathBuf::from("/srv/goose.db"));
474
+
475
+ let homed = db_path_from(
476
+ None,
477
+ Some(PathBuf::from("/data/goose")),
478
+ None,
479
+ Some(PathBuf::from("/home/u")),
480
+ );
481
+ assert_eq!(homed, PathBuf::from("/data/goose/sessions/sessions.db"));
482
+ }
483
+
484
+ #[test]
485
+ fn xdg_data_home_is_honoured_before_the_dot_local_default() {
486
+ // goose builds its data dir from XDG_DATA_HOME, so a machine that moves
487
+ // it would otherwise be polled at a path goose never writes.
488
+ assert_eq!(
489
+ db_path_from(
490
+ None,
491
+ None,
492
+ Some(PathBuf::from("/xdg")),
493
+ Some("/home/u".into())
494
+ ),
495
+ PathBuf::from("/xdg/goose/sessions/sessions.db")
496
+ );
497
+ assert_eq!(
498
+ db_path_from(None, None, None, Some("/home/u".into())),
499
+ PathBuf::from("/home/u/.local/share/goose/sessions/sessions.db")
500
+ );
501
+ }
502
+
503
+ #[test]
504
+ fn the_format_declares_a_rowid_watermark() {
505
+ // Documented as append-accurate and edit-blind. If goose ever gains an
506
+ // `updated_at` on `messages`, this is the switch — the engine already
507
+ // supports the other policy.
508
+ assert_eq!(FORMAT.watermark, Watermark::RowId);
509
+ assert_eq!(FORMAT.kind, "goose");
510
+ }
511
+ }