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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +6 -6
- package/.next/standalone/.next/prerender-manifest.json +3 -3
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/reminder/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +1 -1
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +4 -4
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +30 -18
- package/.next/standalone/.next/server/app/policies/page.js +2 -2
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +3 -3
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0id_xf7._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19120tr._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1fwl2mz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1myjm-d._.js +1 -1
- package/.next/standalone/.next/server/chunks/node_modules_next_dist_esm_build_templates_app-route_17k9e3w.js +4 -4
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02r5bgf._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0e8sjqm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0lkzqax._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ath6v_._.js → [root-of-the-server]__0ls0gsr._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qucxyj._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__170799-._.js → [root-of-the-server]__0spkm68._.js} +11 -11
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0u02miy._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1psjybo._.js → [root-of-the-server]__10s_z4l._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0114ewg._.js → [root-of-the-server]__1j4u3yd._.js} +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0nb0syg._.js → [root-of-the-server]__1ky3im9._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1zqz4v8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- 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
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1ezd2jf._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_1tnuifj._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +6 -6
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +32 -20
- package/.next/standalone/.next/static/chunks/{3s7myk_co0gbh.js → 01h6lfjz1_oie.js} +1 -1
- package/.next/standalone/.next/static/chunks/{03fmihek9n986.js → 06zgzyh3wv1ig.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0fi645si-uiiq.js → 0a7k_x4rp_97z.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2sbelanzvxvv0.js → 0cvd6bcdwchc_.js} +1 -1
- package/.next/standalone/.next/static/chunks/{18rvdw5v72364.js → 13-obgukzg_ke.js} +1 -1
- package/.next/standalone/.next/static/chunks/16p68cybetr31.js +1 -0
- package/.next/standalone/.next/static/chunks/{2j94uwkb4s3qr.js → 1h0_yaoq5f3yx.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3vs4ay36tjds_.js → 29g8ykjk6sx_8.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3hp8ulqknybil.js → 2rqm-klet_gbl.js} +1 -1
- package/.next/standalone/.next/static/chunks/{31feojxhjyjpl.js → 2uvyh8y0naofj.js} +1 -1
- package/.next/standalone/.next/static/chunks/31coz11ia5vc4.js +1 -0
- package/.next/standalone/.next/static/chunks/3p-n_j80zlzi3.js +6 -0
- package/.next/standalone/.next/static/chunks/3zdwnnthucs_1.css +2 -0
- package/.next/standalone/.next/static/chunks/{turbopack-1lsv92x7klwrn.js → turbopack-3diub4ctvn1n0.js} +1 -1
- package/.next/standalone/Cargo.lock +1992 -198
- package/.next/standalone/Cargo.toml +1 -1
- package/.next/standalone/app/actions/get-active-pauses.ts +16 -0
- package/.next/standalone/app/components/pause-notices.tsx +111 -0
- package/.next/standalone/app/policies/hooks-client.tsx +81 -6
- package/.next/standalone/crates/CLOUD_POLICIES.md +136 -0
- package/.next/standalone/crates/failproofaid/Cargo.toml +7 -0
- package/.next/standalone/crates/failproofaid/src/cloud_client.rs +484 -0
- package/.next/standalone/crates/failproofaid/src/cloud_policies.rs +921 -0
- package/.next/standalone/crates/failproofaid/src/main.rs +452 -3
- package/.next/standalone/crates/failproofaid/src/paths.rs +29 -0
- package/.next/standalone/crates/fpai-collect/Cargo.toml +43 -0
- package/.next/standalone/crates/fpai-collect/src/config.rs +412 -0
- package/.next/standalone/crates/fpai-collect/src/cursor.rs +520 -0
- package/.next/standalone/crates/fpai-collect/src/delivery.rs +472 -0
- package/.next/standalone/crates/fpai-collect/src/filetail.rs +484 -0
- package/.next/standalone/crates/fpai-collect/src/health.rs +369 -0
- package/.next/standalone/crates/fpai-collect/src/lib.rs +34 -0
- package/.next/standalone/crates/fpai-collect/src/redact.rs +516 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/mod.rs +279 -0
- package/.next/standalone/crates/fpai-collect/src/sources/claude/transform.rs +619 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/mod.rs +156 -0
- package/.next/standalone/crates/fpai-collect/src/sources/codex/transform.rs +715 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/mod.rs +158 -0
- package/.next/standalone/crates/fpai-collect/src/sources/copilot/transform.rs +512 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/mod.rs +511 -0
- package/.next/standalone/crates/fpai-collect/src/sources/goose/transform.rs +586 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/mod.rs +318 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hermes/transform.rs +579 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/mod.rs +310 -0
- package/.next/standalone/crates/fpai-collect/src/sources/hooks/transform.rs +448 -0
- package/.next/standalone/crates/fpai-collect/src/sources/mod.rs +23 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/mod.rs +176 -0
- package/.next/standalone/crates/fpai-collect/src/sources/openclaw/transform.rs +500 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/mod.rs +315 -0
- package/.next/standalone/crates/fpai-collect/src/sources/opencode/transform.rs +493 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/mod.rs +201 -0
- package/.next/standalone/crates/fpai-collect/src/sources/pi/transform.rs +533 -0
- package/.next/standalone/crates/fpai-collect/src/spool.rs +458 -0
- package/.next/standalone/crates/fpai-collect/src/sqlitepoll.rs +387 -0
- package/.next/standalone/crates/fpai-collect/src/supervisor.rs +423 -0
- package/.next/standalone/crates/fpai-collect/src/uploader.rs +600 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_source.rs +738 -0
- package/.next/standalone/crates/fpai-collect/tests/claude_subagents.rs +393 -0
- package/.next/standalone/crates/fpai-collect/tests/codex_source.rs +975 -0
- package/.next/standalone/crates/fpai-collect/tests/config.rs +286 -0
- package/.next/standalone/crates/fpai-collect/tests/copilot_source.rs +1019 -0
- package/.next/standalone/crates/fpai-collect/tests/delivery.rs +281 -0
- package/.next/standalone/crates/fpai-collect/tests/goose_source.rs +1008 -0
- package/.next/standalone/crates/fpai-collect/tests/hermes_source.rs +1129 -0
- package/.next/standalone/crates/fpai-collect/tests/hooks_source.rs +621 -0
- package/.next/standalone/crates/fpai-collect/tests/openclaw_source.rs +854 -0
- package/.next/standalone/crates/fpai-collect/tests/opencode_source.rs +1478 -0
- package/.next/standalone/crates/fpai-collect/tests/pi_source.rs +1191 -0
- package/.next/standalone/crates/fpai-collect/tests/supervisor.rs +290 -0
- package/.next/standalone/crates/fpai-collect/tests/uploader.rs +506 -0
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +129 -0
- package/dist/cli.mjs +2195 -770
- package/dist/worker.mjs +365 -52
- package/package.json +2 -2
- package/src/hooks/builtin-policies.ts +139 -0
- package/src/hooks/cloud-connection.ts +205 -0
- package/src/hooks/cloud-enrollment-cli.ts +227 -0
- package/src/hooks/cloud-enrollment.ts +173 -0
- package/src/hooks/cloud-managed-policies.ts +138 -0
- package/src/hooks/collector-config.ts +226 -0
- package/src/hooks/configure-wizard.ts +221 -5
- package/src/hooks/custom-hooks-loader.ts +69 -25
- package/src/hooks/daemon-client.ts +18 -0
- package/src/hooks/handler.ts +144 -6
- package/src/hooks/hook-activity-store.ts +50 -0
- package/src/hooks/loader-utils.ts +6 -4
- package/src/hooks/policy-types.ts +35 -0
- package/src/hooks/session-pause-cli.ts +157 -0
- package/src/hooks/session-pause.ts +236 -0
- package/src/hooks/tui.ts +115 -0
- package/.next/standalone/.next/static/chunks/0zbxssxh53n-3.js +0 -1
- package/.next/standalone/.next/static/chunks/2pujpnfx4pws6.css +0 -2
- package/.next/standalone/.next/static/chunks/33u59vf_8xpd-.js +0 -1
- package/.next/standalone/.next/static/chunks/40k1_v4os4cw6.js +0 -6
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NWAjs0tHFyzk97gRGG09r → pM1veX5Llx4YKEaiFc8Sf}/_clientMiddlewareManifest.js +0 -0
- /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
|
+
}
|