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,586 @@
1
+ //! goose `messages` row → AgentEye events.
2
+ //!
3
+ //! Every function here is pure in its arguments. Nothing consults the clock, a
4
+ //! counter, or anything outside what it is handed, which is what makes two
5
+ //! reads of the same rows byte-identical and therefore collapsible by the
6
+ //! server's content-hash dedup.
7
+ //!
8
+ //! Block shapes are verbatim from a live `sessions.db` (goose v1.43.0); see the
9
+ //! module docs of the parent for the full records.
10
+
11
+ use std::collections::BTreeMap;
12
+
13
+ use serde_json::{Map, Value, json};
14
+
15
+ use crate::sources::claude::transform::sanitize_id_part;
16
+ use crate::sqlitepoll::epoch_to_millis;
17
+
18
+ /// Longest session goal carried on `agent_start`.
19
+ const MAX_GOAL_CHARS: usize = 1024;
20
+
21
+ /// Sub-second slots reserved inside one row. See [`slot_micros`].
22
+ const SLOT_START: u32 = 0;
23
+ const SLOT_FIRST_BLOCK: u32 = 1;
24
+ const SLOT_LAST_BLOCK: u32 = 8;
25
+ const SLOT_END: u32 = 9;
26
+ const SLOTS_PER_ROW: i64 = 10;
27
+ /// Rows per second-worth of microseconds. `1_000_000 / SLOTS_PER_ROW`.
28
+ const ROW_SLOT_WRAP: i64 = 100_000;
29
+
30
+ /// One `messages` row with its `content_json` already parsed.
31
+ #[derive(Debug, Clone, Default)]
32
+ pub struct MessageRow {
33
+ pub id: i64,
34
+ pub session_id: String,
35
+ /// `user` or `assistant`. NOT sufficient to tell a prompt from a tool
36
+ /// result — goose files `toolResponse` blocks under `role:'user'`.
37
+ pub role: String,
38
+ /// Epoch SECONDS, verified live (`created_timestamp` 1785743817 for a row
39
+ /// whose sibling `timestamp` column read `2026-08-03 07:56:57`).
40
+ pub created_timestamp: i64,
41
+ pub metadata_json: Option<String>,
42
+ pub blocks: Vec<Value>,
43
+ }
44
+
45
+ /// The per-session pieces of the envelope.
46
+ #[derive(Debug, Clone, Default)]
47
+ pub struct RowCtx {
48
+ pub environment: String,
49
+ pub agent_id: String,
50
+ pub model: Option<String>,
51
+ pub provider: Option<String>,
52
+ }
53
+
54
+ impl RowCtx {
55
+ /// Build the envelope pieces for one session.
56
+ ///
57
+ /// The model is read off the SESSION row because `messages` carries none —
58
+ /// there is no per-message model anywhere in the schema. The consequence is
59
+ /// worth naming: a user who switches model mid-session has the *current*
60
+ /// model attributed to that session's older rows, and a re-read after a
61
+ /// cursor loss would attribute a different one. Accepted because the server
62
+ /// builds a `model_request` row's whole summary from the model, so the
63
+ /// alternative is a session of blank rows.
64
+ pub fn new(
65
+ working_dir: Option<&str>,
66
+ model_config_json: Option<&str>,
67
+ provider: Option<&str>,
68
+ environment: &str,
69
+ fallback_agent_id: &str,
70
+ ) -> Self {
71
+ let model = model_config_json
72
+ .and_then(|raw| serde_json::from_str::<Value>(raw).ok())
73
+ .and_then(|v| {
74
+ v.get("model_name")
75
+ .and_then(Value::as_str)
76
+ .map(str::to_string)
77
+ })
78
+ .filter(|m| !m.is_empty());
79
+ Self {
80
+ environment: environment.to_string(),
81
+ agent_id: agent_id(working_dir, fallback_agent_id),
82
+ model,
83
+ provider: provider.filter(|p| !p.is_empty()).map(str::to_string),
84
+ }
85
+ }
86
+ }
87
+
88
+ /// `goose-<project>` from the session's `working_dir`.
89
+ ///
90
+ /// `working_dir` is `NOT NULL` and was populated for every live session, so
91
+ /// goose sessions group by project the way Claude's do rather than collapsing
92
+ /// into one bucket the way a cwd-less gateway has to.
93
+ ///
94
+ /// Sanitised with the Claude source's own function rather than a copy of it:
95
+ /// the hook source derives `goose-<project>` for the same run from the hook
96
+ /// row's `cwd`, and if the two spellings ever drifted apart, one run's hook
97
+ /// events and session events would file under two agents that look unrelated.
98
+ pub fn agent_id(working_dir: Option<&str>, fallback: &str) -> String {
99
+ let project = working_dir
100
+ .and_then(|w| w.trim_end_matches('/').rsplit('/').find(|p| !p.is_empty()))
101
+ .map(sanitize_id_part)
102
+ .filter(|p| !p.is_empty());
103
+ match project {
104
+ Some(p) => format!("goose-{p}"),
105
+ None => fallback.to_string(),
106
+ }
107
+ }
108
+
109
+ /// Parse a `content_json` blob into its block array.
110
+ ///
111
+ /// Anything that is not an array yields no blocks rather than an error: a row
112
+ /// we cannot read must still advance the watermark, or the poller stalls on it
113
+ /// forever.
114
+ pub fn parse_blocks(content: Option<&str>) -> Vec<Value> {
115
+ match content.map(serde_json::from_str::<Value>) {
116
+ Some(Ok(Value::Array(blocks))) => blocks,
117
+ _ => Vec::new(),
118
+ }
119
+ }
120
+
121
+ /// The microsecond offset for one event of one row.
122
+ ///
123
+ /// The server orders events by `(timestamp, random id)`, and goose stores
124
+ /// `created_timestamp` in whole SECONDS — a live capture had EIGHT rows (four
125
+ /// parallel `shell` calls and their four results) sharing one value. Without a
126
+ /// per-row offset those eight events come back shuffled, so a result renders
127
+ /// above the call that produced it.
128
+ ///
129
+ /// The offset is `row_id`-derived rather than batch-position-derived so it does
130
+ /// not depend on where a poll boundary fell: the same row yields the same
131
+ /// microsecond whatever the watermark was, which is what keeps a re-read
132
+ /// byte-identical. Two rows collide only if their ids differ by a multiple of
133
+ /// 100,000 *and* they share a second — 100,000 messages written inside one
134
+ /// second, which a chat log cannot reach.
135
+ ///
136
+ /// Slot 0 is the session start, 1..=8 are content blocks, 9 is the session end,
137
+ /// so a row's own events never tie with its start or end.
138
+ pub fn slot_micros(row_id: i64, slot: u32) -> u32 {
139
+ let base = (row_id.rem_euclid(ROW_SLOT_WRAP) * SLOTS_PER_ROW) as u32;
140
+ base + slot.min(SLOT_END)
141
+ }
142
+
143
+ /// Format epoch milliseconds as the RFC3339-with-microseconds string ingest
144
+ /// requires.
145
+ ///
146
+ /// Microseconds are forced rather than left to RFC3339's variable precision, so
147
+ /// every source emits one shape and the hook stream and this one sort against
148
+ /// each other on a shared session timeline.
149
+ pub fn to_rfc3339_micros(epoch_ms: i64, micros: u32) -> Option<String> {
150
+ let nanos = (epoch_ms as i128) * 1_000_000 + (micros as i128) * 1_000;
151
+ let dt = time::OffsetDateTime::from_unix_timestamp_nanos(nanos).ok()?;
152
+ const FMT: &[time::format_description::BorrowedFormatItem<'_>] = time::macros::format_description!(
153
+ "[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6]Z"
154
+ );
155
+ dt.format(FMT).ok()
156
+ }
157
+
158
+ /// A row's timestamp for one slot, or `None` when the row's time is unusable.
159
+ ///
160
+ /// [`epoch_to_millis`] reports an implausible value as zero, and emitting it
161
+ /// anyway would date the event 1970-01-01 — which does not merely look wrong,
162
+ /// it parks the event at the very start of every timeline it appears on.
163
+ pub fn timestamp_for(created_timestamp: i64, row_id: i64, slot: u32) -> Option<String> {
164
+ let ms = epoch_to_millis(created_timestamp as f64);
165
+ if ms == 0 {
166
+ return None;
167
+ }
168
+ to_rfc3339_micros(ms, slot_micros(row_id, slot))
169
+ }
170
+
171
+ /// The envelope every emitted event carries.
172
+ ///
173
+ /// `goose_row_id` is the dedup discriminator: two identical turns from
174
+ /// different rows must hash differently, or the server collapses them into one.
175
+ fn base(
176
+ kind: &str,
177
+ session_id: &str,
178
+ row_id: i64,
179
+ created_timestamp: i64,
180
+ slot: u32,
181
+ ctx: &RowCtx,
182
+ ) -> Option<Map<String, Value>> {
183
+ let mut m = Map::new();
184
+ m.insert(
185
+ "timestamp".into(),
186
+ json!(timestamp_for(created_timestamp, row_id, slot)?),
187
+ );
188
+ m.insert("session_id".into(), json!(session_id));
189
+ m.insert("agent_id".into(), json!(ctx.agent_id));
190
+ m.insert("type".into(), json!(kind));
191
+ m.insert("environment".into(), json!(ctx.environment));
192
+ m.insert("goose_row_id".into(), json!(row_id));
193
+ Some(m)
194
+ }
195
+
196
+ /// The session's `agent_start`, ridden on its first message row.
197
+ pub fn agent_start(row: &MessageRow, working_dir: Option<&str>, ctx: &RowCtx) -> Option<Value> {
198
+ let mut m = base(
199
+ "agent_start",
200
+ &row.session_id,
201
+ row.id,
202
+ row.created_timestamp,
203
+ SLOT_START,
204
+ ctx,
205
+ )?;
206
+ // The goal is the session's opening human prompt. Taken only from a `user`
207
+ // row's text block: a session that opens with something else gets no goal
208
+ // rather than a guessed one, and a tool result's 52 KB of stdout is exactly
209
+ // the sort of thing a guess would put in the session title.
210
+ if row.role == "user"
211
+ && let Some(text) = first_text(&row.blocks)
212
+ {
213
+ m.insert(
214
+ "goal".into(),
215
+ json!(text.chars().take(MAX_GOAL_CHARS).collect::<String>()),
216
+ );
217
+ }
218
+ if let Some(wd) = working_dir.filter(|w| !w.is_empty()) {
219
+ m.insert("goose_working_dir".into(), json!(wd));
220
+ }
221
+ if let Some(model) = &ctx.model {
222
+ m.insert("model".into(), json!(model));
223
+ }
224
+ if let Some(provider) = &ctx.provider {
225
+ m.insert("goose_provider".into(), json!(provider));
226
+ }
227
+ Some(Value::Object(m))
228
+ }
229
+
230
+ /// The session's `agent_end`, ridden on the session's LAST message row.
231
+ ///
232
+ /// Carries nothing beyond the envelope on purpose. The obvious additions —
233
+ /// `sessions.total_tokens`, `updated_at`, the session name — are all mutated
234
+ /// after the fact by goose, so including one would make a re-read of the same
235
+ /// row produce different bytes and leave a second end event on the timeline.
236
+ pub fn agent_end(
237
+ session_id: &str,
238
+ row_id: i64,
239
+ created_timestamp: i64,
240
+ ctx: &RowCtx,
241
+ ) -> Option<Value> {
242
+ base(
243
+ "agent_end",
244
+ session_id,
245
+ row_id,
246
+ created_timestamp,
247
+ SLOT_END,
248
+ ctx,
249
+ )
250
+ .map(Value::Object)
251
+ }
252
+
253
+ /// One row's content blocks to events.
254
+ ///
255
+ /// `tool_names` maps a tool-call id to the name from its `toolRequest`, because
256
+ /// a `toolResponse` block names no tool and a result naming no tool renders as
257
+ /// a blank row.
258
+ pub fn content_events(
259
+ row: &MessageRow,
260
+ ctx: &RowCtx,
261
+ tool_names: &BTreeMap<String, String>,
262
+ ) -> Vec<Value> {
263
+ let mut out: Vec<Value> = Vec::new();
264
+ for (i, block) in row.blocks.iter().enumerate() {
265
+ let index = u32::try_from(i).unwrap_or(u32::MAX);
266
+ let slot = SLOT_FIRST_BLOCK.saturating_add(index).min(SLOT_LAST_BLOCK);
267
+ let Some(mut m) = block_event(row, block, slot, ctx, tool_names) else {
268
+ continue;
269
+ };
270
+ // The block index distinguishes two identical blocks in ONE row, which
271
+ // the row id alone cannot: they would otherwise hash to the same event.
272
+ if i > 0 {
273
+ m.insert("goose_block_index".into(), json!(i));
274
+ }
275
+ out.push(Value::Object(m));
276
+ }
277
+ attach_usage(row, &mut out);
278
+ out
279
+ }
280
+
281
+ fn block_event(
282
+ row: &MessageRow,
283
+ block: &Value,
284
+ slot: u32,
285
+ ctx: &RowCtx,
286
+ tool_names: &BTreeMap<String, String>,
287
+ ) -> Option<Map<String, Value>> {
288
+ // Branch on the BLOCK type, never on `role` alone: goose files tool results
289
+ // in `role:'user'` rows, so a role-first branch renders every tool result
290
+ // as a user prompt.
291
+ match block.get("type").and_then(Value::as_str) {
292
+ Some("text") => text_event(row, block.get("text")?.as_str()?, slot, ctx, false),
293
+ // Not seen in the live corpus, but `thinking` is a variant of goose's
294
+ // MessageContent enum, and an unhandled variant drops the turn silently.
295
+ Some("thinking") => text_event(row, block.get("thinking")?.as_str()?, slot, ctx, true),
296
+ Some("toolRequest") => tool_use_event(row, block, slot, ctx),
297
+ Some("toolResponse") => tool_result_event(row, block, slot, ctx, tool_names),
298
+ // `image`, `redactedThinking`, `toolConfirmationRequest`,
299
+ // `contextLengthExceeded`, … carry no text worth a timeline row.
300
+ _ => None,
301
+ }
302
+ }
303
+
304
+ /// A text block: the user's prompt, or the model's reply.
305
+ fn text_event(
306
+ row: &MessageRow,
307
+ text: &str,
308
+ slot: u32,
309
+ ctx: &RowCtx,
310
+ thinking: bool,
311
+ ) -> Option<Map<String, Value>> {
312
+ if text.is_empty() {
313
+ return None;
314
+ }
315
+ let assistant = row.role == "assistant";
316
+ let kind = if assistant {
317
+ "model_response"
318
+ } else {
319
+ "model_request"
320
+ };
321
+ let mut m = base(
322
+ kind,
323
+ &row.session_id,
324
+ row.id,
325
+ row.created_timestamp,
326
+ slot,
327
+ ctx,
328
+ )?;
329
+ if assistant {
330
+ m.insert("role".into(), json!("assistant"));
331
+ m.insert("content".into(), json!(text));
332
+ } else {
333
+ m.insert(
334
+ "messages".into(),
335
+ json!([{ "role": "user", "content": text }]),
336
+ );
337
+ }
338
+ if thinking {
339
+ m.insert("goose_thinking".into(), json!(true));
340
+ }
341
+ if let Some(model) = &ctx.model {
342
+ m.insert("model".into(), json!(model));
343
+ }
344
+ Some(m)
345
+ }
346
+
347
+ fn tool_use_event(
348
+ row: &MessageRow,
349
+ block: &Value,
350
+ slot: u32,
351
+ ctx: &RowCtx,
352
+ ) -> Option<Map<String, Value>> {
353
+ let mut m = base(
354
+ "tool_use",
355
+ &row.session_id,
356
+ row.id,
357
+ row.created_timestamp,
358
+ slot,
359
+ ctx,
360
+ )?;
361
+ let call = block.get("toolCall");
362
+ let value = call.and_then(|c| c.get("value"));
363
+ let name = value
364
+ .and_then(|v| v.get("name"))
365
+ .and_then(Value::as_str)
366
+ .unwrap_or("tool");
367
+ // Fall back to a row-derived id so two identical calls in one row cannot
368
+ // hash-collapse into a single event.
369
+ let id = block
370
+ .get("id")
371
+ .and_then(Value::as_str)
372
+ .filter(|s| !s.is_empty())
373
+ .map(str::to_string)
374
+ .unwrap_or_else(|| format!("goose-{}-{slot}", row.id));
375
+ m.insert("tool_name".into(), json!(name));
376
+ m.insert("tool_call_id".into(), json!(id));
377
+ if let Some(args) = value.and_then(|v| v.get("arguments")) {
378
+ m.insert("input".into(), args.clone());
379
+ }
380
+ if let Some(model) = &ctx.model {
381
+ m.insert("model".into(), json!(model));
382
+ }
383
+ // `toolCall` is a serialized Result: a non-success status means the call
384
+ // never reached the tool, and its `value` is absent.
385
+ if let Some(status) = call
386
+ .and_then(|c| c.get("status"))
387
+ .and_then(Value::as_str)
388
+ .filter(|s| *s != "success")
389
+ {
390
+ m.insert("goose_tool_call_status".into(), json!(status));
391
+ m.insert(
392
+ "error".into(),
393
+ json!(stringify(
394
+ call.and_then(|c| c.get("error")).unwrap_or(&Value::Null)
395
+ )),
396
+ );
397
+ m.insert("error_type".into(), json!("goose_tool_error"));
398
+ }
399
+ Some(m)
400
+ }
401
+
402
+ fn tool_result_event(
403
+ row: &MessageRow,
404
+ block: &Value,
405
+ slot: u32,
406
+ ctx: &RowCtx,
407
+ tool_names: &BTreeMap<String, String>,
408
+ ) -> Option<Map<String, Value>> {
409
+ let mut m = base(
410
+ "tool_result",
411
+ &row.session_id,
412
+ row.id,
413
+ row.created_timestamp,
414
+ slot,
415
+ ctx,
416
+ )?;
417
+ let result = block.get("toolResult");
418
+ let value = result.and_then(|r| r.get("value"));
419
+ if let Some(id) = block.get("id").and_then(Value::as_str) {
420
+ m.insert("tool_call_id".into(), json!(id));
421
+ if let Some(name) = tool_names.get(id) {
422
+ m.insert("tool_name".into(), json!(name));
423
+ }
424
+ }
425
+ let output = result_text(value);
426
+ m.insert("output".into(), json!(output));
427
+
428
+ let failed_status = result
429
+ .and_then(|r| r.get("status"))
430
+ .and_then(Value::as_str)
431
+ .is_some_and(|s| s != "success");
432
+ let flagged = value
433
+ .and_then(|v| v.get("isError"))
434
+ .and_then(Value::as_bool)
435
+ .unwrap_or(false);
436
+ if failed_status || flagged {
437
+ // A failed Result carries its message in `error`, not in `value` — a
438
+ // blind read of `value` would report an empty error and the product
439
+ // would show a failure with no reason.
440
+ let detail = result
441
+ .and_then(|r| r.get("error"))
442
+ .map(stringify)
443
+ .filter(|s| !s.is_empty())
444
+ .unwrap_or(output);
445
+ m.insert("error".into(), json!(detail));
446
+ m.insert("error_type".into(), json!("goose_tool_error"));
447
+ }
448
+ Some(m)
449
+ }
450
+
451
+ /// Bill a row's token usage to its FIRST event.
452
+ ///
453
+ /// Usage lives in `metadata_json.usage` with camelCase keys; the `tokens`
454
+ /// column exists but was NULL for every row of the live corpus, so reading it
455
+ /// would report zero tokens for every session.
456
+ fn attach_usage(row: &MessageRow, events: &mut [Value]) {
457
+ let Some(raw) = row.metadata_json.as_deref() else {
458
+ return;
459
+ };
460
+ let Ok(meta) = serde_json::from_str::<Value>(raw) else {
461
+ return;
462
+ };
463
+ let Some(usage) = meta.get("usage") else {
464
+ return;
465
+ };
466
+ let Some(first) = events.first_mut().and_then(Value::as_object_mut) else {
467
+ return;
468
+ };
469
+ if let Some(n) = usage.get("inputTokens").and_then(Value::as_u64) {
470
+ first.insert("input_tokens".into(), json!(n));
471
+ }
472
+ if let Some(n) = usage.get("outputTokens").and_then(Value::as_u64) {
473
+ first.insert("output_tokens".into(), json!(n));
474
+ }
475
+ first.insert("goose_usage".into(), usage.clone());
476
+ }
477
+
478
+ /// `(id, name)` when `block` is a `toolRequest` that names its tool.
479
+ pub fn tool_request_name(block: &Value) -> Option<(String, String)> {
480
+ if block.get("type").and_then(Value::as_str) != Some("toolRequest") {
481
+ return None;
482
+ }
483
+ let id = block.get("id").and_then(Value::as_str)?;
484
+ let name = block
485
+ .pointer("/toolCall/value/name")
486
+ .and_then(Value::as_str)?;
487
+ Some((id.to_string(), name.to_string()))
488
+ }
489
+
490
+ /// The first non-empty text block of a row.
491
+ fn first_text(blocks: &[Value]) -> Option<&str> {
492
+ blocks.iter().find_map(|b| {
493
+ (b.get("type").and_then(Value::as_str) == Some("text"))
494
+ .then(|| b.get("text").and_then(Value::as_str))
495
+ .flatten()
496
+ .filter(|t| !t.is_empty())
497
+ })
498
+ }
499
+
500
+ /// Render a `toolResult.value` as text.
501
+ ///
502
+ /// The live shape is `{content:[{type:"text",text}], structuredContent, isError}`.
503
+ /// `structuredContent` duplicates the same bytes under `stdout`, so only
504
+ /// `content` is read — taking both would double every tool output.
505
+ fn result_text(value: Option<&Value>) -> String {
506
+ match value {
507
+ Some(Value::String(s)) => s.clone(),
508
+ Some(Value::Array(items)) => join_text(items),
509
+ Some(Value::Object(obj)) => match obj.get("content") {
510
+ Some(Value::Array(items)) => join_text(items),
511
+ Some(Value::String(s)) => s.clone(),
512
+ _ => String::new(),
513
+ },
514
+ _ => String::new(),
515
+ }
516
+ }
517
+
518
+ fn join_text(items: &[Value]) -> String {
519
+ items
520
+ .iter()
521
+ .filter_map(|b| b.get("text").and_then(Value::as_str))
522
+ .collect::<Vec<_>>()
523
+ .join("\n")
524
+ }
525
+
526
+ fn stringify(v: &Value) -> String {
527
+ match v {
528
+ Value::String(s) => s.clone(),
529
+ Value::Null => String::new(),
530
+ other => other.to_string(),
531
+ }
532
+ }
533
+
534
+ #[cfg(test)]
535
+ mod tests {
536
+ use super::*;
537
+
538
+ #[test]
539
+ fn a_whole_second_timestamp_still_orders_the_rows_inside_it() {
540
+ // goose stores whole SECONDS: eight live rows (four parallel calls and
541
+ // their results) shared one value. Without a per-row offset the server
542
+ // sorts them by a random id and a result renders above its own call.
543
+ let a = timestamp_for(1_785_744_659, 13, SLOT_FIRST_BLOCK).unwrap();
544
+ let b = timestamp_for(1_785_744_659, 14, SLOT_FIRST_BLOCK).unwrap();
545
+ assert!(a < b, "{a} must sort before {b}");
546
+ assert!(a.starts_with("2026-08-03T08:10:59."));
547
+ assert_eq!(a.len(), "2026-08-03T08:10:59.000000Z".len());
548
+ }
549
+
550
+ #[test]
551
+ fn a_rows_start_sorts_before_its_blocks_and_its_end_sorts_after() {
552
+ let start = slot_micros(7, SLOT_START);
553
+ let block = slot_micros(7, SLOT_FIRST_BLOCK);
554
+ let end = slot_micros(7, SLOT_END);
555
+ assert!(start < block && block < end);
556
+ // And an out-of-range slot saturates inside the row rather than
557
+ // spilling into the next one's range.
558
+ assert_eq!(slot_micros(7, 99), end);
559
+ }
560
+
561
+ #[test]
562
+ fn an_implausible_timestamp_yields_no_event_rather_than_1970() {
563
+ // Emitting it would park the event at the head of every timeline it
564
+ // appears on, which is far more visible than a dropped row.
565
+ assert!(timestamp_for(0, 1, SLOT_START).is_none());
566
+ assert!(timestamp_for(42, 1, SLOT_START).is_none());
567
+ }
568
+
569
+ #[test]
570
+ fn the_agent_id_falls_back_when_a_session_has_no_working_dir() {
571
+ assert_eq!(agent_id(Some("/home/u/src/repo"), "goose"), "goose-repo");
572
+ assert_eq!(agent_id(Some("/home/u/src/repo/"), "goose"), "goose-repo");
573
+ assert_eq!(agent_id(Some("/"), "goose"), "goose");
574
+ assert_eq!(agent_id(None, "goose"), "goose");
575
+ }
576
+
577
+ #[test]
578
+ fn a_content_blob_that_is_not_an_array_yields_no_blocks() {
579
+ // The row must still advance the watermark; erroring would stall the
580
+ // poller on it forever.
581
+ assert!(parse_blocks(Some("not json")).is_empty());
582
+ assert!(parse_blocks(Some(r#"{"type":"text"}"#)).is_empty());
583
+ assert!(parse_blocks(None).is_empty());
584
+ assert_eq!(parse_blocks(Some(r#"[{"type":"text"}]"#)).len(), 1);
585
+ }
586
+ }