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,493 @@
|
|
|
1
|
+
//! opencode `session` and `part` rows → AgentEye events.
|
|
2
|
+
//!
|
|
3
|
+
//! Every function here is pure in its arguments. That is load-bearing rather
|
|
4
|
+
//! than stylistic: this source re-reads mutated rows *by design* (see the
|
|
5
|
+
//! module docs on [`super`]), and the server collapses a re-read only when the
|
|
6
|
+
//! bytes are identical. Nothing here may consult the clock, a counter, or
|
|
7
|
+
//! anything outside its arguments.
|
|
8
|
+
//!
|
|
9
|
+
//! Row shapes verified against a live `~/.local/share/opencode/opencode.db`
|
|
10
|
+
//! (opencode 1.18.11):
|
|
11
|
+
//!
|
|
12
|
+
//! ```text
|
|
13
|
+
//! session columns id, directory (absolute cwd), path (NOT usable — see
|
|
14
|
+
//! `super`), slug, version, parent_id, time_created,
|
|
15
|
+
//! time_updated, tokens_input/output/reasoning/cache_*
|
|
16
|
+
//! message data {role:"user", model:{providerID, modelID}, time:{created}}
|
|
17
|
+
//! {role:"assistant", modelID, providerID, tokens:{…},
|
|
18
|
+
//! time:{created, completed}, finish:"stop"|"tool-calls"|…}
|
|
19
|
+
//! part data {type:"text", text, time?:{start, end}}
|
|
20
|
+
//! {type:"tool", tool, callID,
|
|
21
|
+
//! state:{status, input, output, metadata, time:{start,end}}}
|
|
22
|
+
//! {type:"step-start"|"step-finish", …} (emit nothing)
|
|
23
|
+
//! ```
|
|
24
|
+
|
|
25
|
+
use serde_json::{Map, Value, json};
|
|
26
|
+
|
|
27
|
+
/// Longest derived id component.
|
|
28
|
+
const MAX_ID_PART: usize = 48;
|
|
29
|
+
/// Longest goal string carried on `agent_start`.
|
|
30
|
+
const MAX_GOAL: usize = 1024;
|
|
31
|
+
/// Index offset for `agent_end`.
|
|
32
|
+
///
|
|
33
|
+
/// The server sorts by `(timestamp, random id)`, so an `agent_end` sharing a
|
|
34
|
+
/// millisecond with the turn that produced it can come back *before* it. 999
|
|
35
|
+
/// puts it after every content event in that millisecond — the same trick and
|
|
36
|
+
/// the same constant the Claude source uses.
|
|
37
|
+
const END_INDEX: u32 = 999;
|
|
38
|
+
|
|
39
|
+
/// Make a derived id component safe and bounded.
|
|
40
|
+
///
|
|
41
|
+
/// Byte-for-byte the Claude source's rule, deliberately: the two run on the
|
|
42
|
+
/// same machine over the same project directories, and a project that
|
|
43
|
+
/// sanitized differently under each would show up as two unrelated agents.
|
|
44
|
+
pub fn sanitize_id_part(s: &str) -> String {
|
|
45
|
+
let cleaned: String = s
|
|
46
|
+
.chars()
|
|
47
|
+
.map(|c| {
|
|
48
|
+
if c.is_ascii_alphanumeric() || c == '.' || c == '_' {
|
|
49
|
+
c
|
|
50
|
+
} else {
|
|
51
|
+
'-'
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
.collect();
|
|
55
|
+
cleaned
|
|
56
|
+
.split('-')
|
|
57
|
+
.filter(|p| !p.is_empty())
|
|
58
|
+
.collect::<Vec<_>>()
|
|
59
|
+
.join("-")
|
|
60
|
+
.chars()
|
|
61
|
+
.take(MAX_ID_PART)
|
|
62
|
+
.collect()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// `opencode-<project>`, from `session.directory`.
|
|
66
|
+
///
|
|
67
|
+
/// `directory` is the absolute working directory and is the ONLY reliable
|
|
68
|
+
/// source of the project — see the module docs on [`super`] for why
|
|
69
|
+
/// `session.path` and `session.project_id` are not.
|
|
70
|
+
///
|
|
71
|
+
/// Returns `None` for a directory with no usable final component (`/`, or an
|
|
72
|
+
/// empty string), so the caller can fall back to the configured agent id
|
|
73
|
+
/// rather than emitting `opencode-`.
|
|
74
|
+
pub fn agent_id_from_directory(directory: &str) -> Option<String> {
|
|
75
|
+
let project = sanitize_id_part(
|
|
76
|
+
directory
|
|
77
|
+
.trim_end_matches('/')
|
|
78
|
+
.rsplit('/')
|
|
79
|
+
.find(|p| !p.is_empty())?,
|
|
80
|
+
);
|
|
81
|
+
(!project.is_empty()).then(|| format!("opencode-{project}"))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Format epoch milliseconds as the RFC3339-with-microseconds string ingest
|
|
85
|
+
/// expects, offset by `index` microseconds.
|
|
86
|
+
///
|
|
87
|
+
/// opencode stores `time_created`/`time_updated` and every JSON `time` field
|
|
88
|
+
/// as epoch MILLISECONDS (verified: 1785744553213). Microseconds are forced
|
|
89
|
+
/// rather than left to RFC3339's variable precision so every source emits one
|
|
90
|
+
/// shape and the streams sort against each other on a shared timeline.
|
|
91
|
+
pub fn to_rfc3339_micros(epoch_ms: i64, index: u32) -> Option<String> {
|
|
92
|
+
let nanos = (epoch_ms as i128) * 1_000_000 + (index as i128) * 1_000;
|
|
93
|
+
let dt = time::OffsetDateTime::from_unix_timestamp_nanos(nanos).ok()?;
|
|
94
|
+
const FMT: &[time::format_description::BorrowedFormatItem<'_>] = time::macros::format_description!(
|
|
95
|
+
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:6]Z"
|
|
96
|
+
);
|
|
97
|
+
dt.format(FMT).ok()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// What every emitted event is filed under.
|
|
101
|
+
#[derive(Debug, Clone)]
|
|
102
|
+
pub struct Ctx {
|
|
103
|
+
pub session_id: String,
|
|
104
|
+
pub agent_id: String,
|
|
105
|
+
pub environment: String,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
impl Ctx {
|
|
109
|
+
fn new(session_id: &str, directory: &str, environment: &str, fallback_agent_id: &str) -> Self {
|
|
110
|
+
Self {
|
|
111
|
+
session_id: session_id.to_string(),
|
|
112
|
+
agent_id: agent_id_from_directory(directory)
|
|
113
|
+
.unwrap_or_else(|| fallback_agent_id.to_string()),
|
|
114
|
+
environment: environment.to_string(),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// One `session` row, plus the two facts about it that need a subquery.
|
|
120
|
+
#[derive(Debug, Clone, Default)]
|
|
121
|
+
pub struct SessionRow {
|
|
122
|
+
pub id: String,
|
|
123
|
+
/// Absolute cwd. The agent id derives from this and nothing else.
|
|
124
|
+
pub directory: String,
|
|
125
|
+
pub version: Option<String>,
|
|
126
|
+
pub slug: Option<String>,
|
|
127
|
+
pub parent_id: Option<String>,
|
|
128
|
+
pub time_created: i64,
|
|
129
|
+
pub time_updated: i64,
|
|
130
|
+
pub tokens_input: i64,
|
|
131
|
+
pub tokens_output: i64,
|
|
132
|
+
pub tokens_reasoning: i64,
|
|
133
|
+
pub tokens_cache_read: i64,
|
|
134
|
+
pub tokens_cache_write: i64,
|
|
135
|
+
/// `part.data` of the session's earliest part. Its presence is the "this
|
|
136
|
+
/// session produced something" test, and its text is the session goal.
|
|
137
|
+
pub first_part: Option<Value>,
|
|
138
|
+
/// `message.data` of the session's latest message. Decides whether the
|
|
139
|
+
/// session is between turns or in the middle of one.
|
|
140
|
+
pub last_message: Option<Value>,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// One `part` row, joined to its message (for the role and model) and its
|
|
144
|
+
/// session (for the directory the agent id comes from).
|
|
145
|
+
#[derive(Debug, Clone, Default)]
|
|
146
|
+
pub struct PartRow {
|
|
147
|
+
pub id: String,
|
|
148
|
+
pub session_id: String,
|
|
149
|
+
pub message_id: String,
|
|
150
|
+
pub directory: String,
|
|
151
|
+
pub time_created: i64,
|
|
152
|
+
pub time_updated: i64,
|
|
153
|
+
pub data: Value,
|
|
154
|
+
pub message: Value,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/// The envelope every emitted event carries.
|
|
158
|
+
///
|
|
159
|
+
/// `opencode_part_id` is the dedup discriminator: two events that are
|
|
160
|
+
/// otherwise identical — the same tool invoked twice with the same input, say
|
|
161
|
+
/// — must hash differently or the server collapses them into one. opencode
|
|
162
|
+
/// prefixes ids per table (`prt_…`, `ses_…`), so this one field can carry the
|
|
163
|
+
/// originating row of a part-derived event and a session-derived event alike
|
|
164
|
+
/// with no chance of collision.
|
|
165
|
+
fn base(ctx: &Ctx, kind: &str, ts_ms: i64, index: u32, row_id: &str) -> Option<Map<String, Value>> {
|
|
166
|
+
let mut m = Map::new();
|
|
167
|
+
m.insert("timestamp".into(), json!(to_rfc3339_micros(ts_ms, index)?));
|
|
168
|
+
m.insert("session_id".into(), json!(ctx.session_id));
|
|
169
|
+
m.insert("agent_id".into(), json!(ctx.agent_id));
|
|
170
|
+
m.insert("type".into(), json!(kind));
|
|
171
|
+
m.insert("environment".into(), json!(ctx.environment));
|
|
172
|
+
m.insert("opencode_part_id".into(), json!(row_id));
|
|
173
|
+
if index > 0 {
|
|
174
|
+
m.insert("opencode_block_index".into(), json!(index));
|
|
175
|
+
}
|
|
176
|
+
Some(m)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/// A `session` row's `agent_start` and — when the session is between turns —
|
|
180
|
+
/// its `agent_end`.
|
|
181
|
+
///
|
|
182
|
+
/// # Why a session row can produce these many times over
|
|
183
|
+
///
|
|
184
|
+
/// The poller re-reads a row whenever its `time_updated` moves, and a session
|
|
185
|
+
/// row's moves on every turn. `agent_start` is therefore built ONLY from
|
|
186
|
+
/// columns opencode never rewrites (`id`, `directory`, `version`, `slug`,
|
|
187
|
+
/// `parent_id`, `time_created`) plus the first prompt, which is fixed once
|
|
188
|
+
/// written — so every re-read produces the same bytes and the server collapses
|
|
189
|
+
/// them into the single event it should have been. `session.title` is
|
|
190
|
+
/// pointedly absent: opencode backfills it with an LLM-generated summary a few
|
|
191
|
+
/// seconds into the session, which would make the second read differ from the
|
|
192
|
+
/// first and leave two `agent_start`s on the timeline.
|
|
193
|
+
///
|
|
194
|
+
/// # `agent_end` without an end marker
|
|
195
|
+
///
|
|
196
|
+
/// opencode never records that a session finished — there is no end row, no
|
|
197
|
+
/// terminal status, and `time_archived` stays null. An idle timeout is not
|
|
198
|
+
/// available either: the poll must be a pure function of the database and the
|
|
199
|
+
/// request, so it cannot ask what time it is now.
|
|
200
|
+
///
|
|
201
|
+
/// So the end is inferred from the conversation instead: a session whose
|
|
202
|
+
/// latest message is an assistant turn that has *finished* and is not waiting
|
|
203
|
+
/// on tool calls is, right now, over. Every later turn moves the session row
|
|
204
|
+
/// again and emits another `agent_end` at the new `time_updated`; the newest
|
|
205
|
+
/// one is always the session's true last activity.
|
|
206
|
+
pub fn session_events(row: &SessionRow, environment: &str, fallback_agent_id: &str) -> Vec<Value> {
|
|
207
|
+
// A session row with no parts is one the user opened and never used —
|
|
208
|
+
// opencode creates it the moment a session is selected. Emitting an
|
|
209
|
+
// `agent_start` for it would put an empty session in the product, and
|
|
210
|
+
// would also risk a second `agent_start` later once the goal exists.
|
|
211
|
+
if row.first_part.is_none() {
|
|
212
|
+
return Vec::new();
|
|
213
|
+
}
|
|
214
|
+
let ctx = Ctx::new(&row.id, &row.directory, environment, fallback_agent_id);
|
|
215
|
+
let mut out = Vec::new();
|
|
216
|
+
|
|
217
|
+
if let Some(mut m) = base(&ctx, "agent_start", row.time_created, 0, &row.id) {
|
|
218
|
+
if let Some(goal) = first_prompt(row) {
|
|
219
|
+
m.insert("goal".into(), json!(goal));
|
|
220
|
+
}
|
|
221
|
+
m.insert("opencode_cwd".into(), json!(row.directory));
|
|
222
|
+
if let Some(v) = &row.version {
|
|
223
|
+
m.insert("opencode_version".into(), json!(v));
|
|
224
|
+
}
|
|
225
|
+
if let Some(s) = &row.slug {
|
|
226
|
+
m.insert("opencode_slug".into(), json!(s));
|
|
227
|
+
}
|
|
228
|
+
if let Some(p) = &row.parent_id {
|
|
229
|
+
m.insert("opencode_parent_session_id".into(), json!(p));
|
|
230
|
+
}
|
|
231
|
+
out.push(Value::Object(m));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if between_turns(row)
|
|
235
|
+
&& let Some(mut m) = base(&ctx, "agent_end", row.time_updated, END_INDEX, &row.id)
|
|
236
|
+
{
|
|
237
|
+
// Namespaced, and deliberately NOT `input_tokens`/`output_tokens`:
|
|
238
|
+
// these are the session's running TOTALS, not this event's usage, and
|
|
239
|
+
// a consumer that sums the generic fields across events would count
|
|
240
|
+
// every turn again for every `agent_end` the session emits.
|
|
241
|
+
m.insert(
|
|
242
|
+
"opencode_tokens".into(),
|
|
243
|
+
json!({
|
|
244
|
+
"input": row.tokens_input,
|
|
245
|
+
"output": row.tokens_output,
|
|
246
|
+
"reasoning": row.tokens_reasoning,
|
|
247
|
+
"cache_read": row.tokens_cache_read,
|
|
248
|
+
"cache_write": row.tokens_cache_write,
|
|
249
|
+
}),
|
|
250
|
+
);
|
|
251
|
+
out.push(Value::Object(m));
|
|
252
|
+
}
|
|
253
|
+
out
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// The session goal: the text of its first part, which is the user's opening
|
|
257
|
+
/// prompt (opencode writes the prompt's text part before anything else).
|
|
258
|
+
fn first_prompt(row: &SessionRow) -> Option<String> {
|
|
259
|
+
let data = row.first_part.as_ref()?;
|
|
260
|
+
if data.get("type").and_then(|t| t.as_str()) != Some("text") {
|
|
261
|
+
return None;
|
|
262
|
+
}
|
|
263
|
+
let text = data.get("text").and_then(|t| t.as_str())?;
|
|
264
|
+
(!text.is_empty()).then(|| text.chars().take(MAX_GOAL).collect())
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/// True when the session's latest message is a completed assistant turn.
|
|
268
|
+
///
|
|
269
|
+
/// `finish:"tool-calls"` means the assistant stopped only to run tools and
|
|
270
|
+
/// another assistant message is coming, so it is explicitly NOT an end. A user
|
|
271
|
+
/// message as the latest one means the turn has not finished either.
|
|
272
|
+
fn between_turns(row: &SessionRow) -> bool {
|
|
273
|
+
let Some(message) = row.last_message.as_ref() else {
|
|
274
|
+
return false;
|
|
275
|
+
};
|
|
276
|
+
if message.get("role").and_then(|r| r.as_str()) != Some("assistant") {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
matches!(
|
|
280
|
+
message.get("finish").and_then(|f| f.as_str()),
|
|
281
|
+
Some(finish) if finish != "tool-calls"
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/// One `part` row to the events it yields.
|
|
286
|
+
///
|
|
287
|
+
/// Returns an empty vector for a part opencode is still writing — see
|
|
288
|
+
/// `is_settled` and `tool_events` below. That is not a dropped event: the row's
|
|
289
|
+
/// `time_updated` moves again when the write completes, which brings it back
|
|
290
|
+
/// past the watermark and through here a second time.
|
|
291
|
+
pub fn part_events(row: &PartRow, environment: &str, fallback_agent_id: &str) -> Vec<Value> {
|
|
292
|
+
let ctx = Ctx::new(
|
|
293
|
+
&row.session_id,
|
|
294
|
+
&row.directory,
|
|
295
|
+
environment,
|
|
296
|
+
fallback_agent_id,
|
|
297
|
+
);
|
|
298
|
+
match row.data.get("type").and_then(|t| t.as_str()) {
|
|
299
|
+
Some("text") => text_events(row, &ctx),
|
|
300
|
+
Some("tool") => tool_events(row, &ctx),
|
|
301
|
+
// `step-start` / `step-finish` are turn structure, and `snapshot` /
|
|
302
|
+
// `patch` / `file` / `reasoning` need their own event types rather
|
|
303
|
+
// than a lossy squeeze into these. Ignoring an unknown type is also
|
|
304
|
+
// what keeps a new opencode release from breaking this source.
|
|
305
|
+
_ => Vec::new(),
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/// A `text` part is a user prompt or an assistant answer, decided by the role
|
|
310
|
+
/// on its message — the part itself carries no role.
|
|
311
|
+
fn text_events(row: &PartRow, ctx: &Ctx) -> Vec<Value> {
|
|
312
|
+
// An assistant text part is written empty and grown token by token as the
|
|
313
|
+
// model streams. Shipping it before it settles would put one row in the
|
|
314
|
+
// product per poll that happened to land mid-stream, each a longer prefix
|
|
315
|
+
// of the same answer.
|
|
316
|
+
if !is_settled(&row.data) {
|
|
317
|
+
return Vec::new();
|
|
318
|
+
}
|
|
319
|
+
let Some(text) = row.data.get("text").and_then(|t| t.as_str()) else {
|
|
320
|
+
return Vec::new();
|
|
321
|
+
};
|
|
322
|
+
if text.is_empty() {
|
|
323
|
+
return Vec::new();
|
|
324
|
+
}
|
|
325
|
+
let role = row
|
|
326
|
+
.message
|
|
327
|
+
.get("role")
|
|
328
|
+
.and_then(|r| r.as_str())
|
|
329
|
+
.unwrap_or("assistant");
|
|
330
|
+
|
|
331
|
+
let kind = if role == "user" {
|
|
332
|
+
"model_request"
|
|
333
|
+
} else {
|
|
334
|
+
"model_response"
|
|
335
|
+
};
|
|
336
|
+
// `part.time_created` rather than `data.time.start`: it is the one
|
|
337
|
+
// timestamp on the row opencode never rewrites, so a re-read of a row that
|
|
338
|
+
// changed for some other reason still produces the same bytes.
|
|
339
|
+
let Some(mut m) = base(ctx, kind, row.time_created, 0, &row.id) else {
|
|
340
|
+
return Vec::new();
|
|
341
|
+
};
|
|
342
|
+
if let Some(model) = model_id(&row.message) {
|
|
343
|
+
m.insert("model".into(), json!(model));
|
|
344
|
+
}
|
|
345
|
+
if role == "user" {
|
|
346
|
+
m.insert(
|
|
347
|
+
"messages".into(),
|
|
348
|
+
json!([{ "role": "user", "content": text }]),
|
|
349
|
+
);
|
|
350
|
+
m.insert("opencode_cwd".into(), json!(row.directory));
|
|
351
|
+
} else {
|
|
352
|
+
m.insert("role".into(), json!("assistant"));
|
|
353
|
+
m.insert("content".into(), json!(text));
|
|
354
|
+
if let Some(p) = provider_id(&row.message) {
|
|
355
|
+
m.insert("opencode_provider".into(), json!(p));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
m.insert("opencode_message_id".into(), json!(row.message_id));
|
|
359
|
+
vec![Value::Object(m)]
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/// A `tool` part is BOTH the call and its result — opencode never writes a
|
|
363
|
+
/// second row for the result, it fills this one in.
|
|
364
|
+
///
|
|
365
|
+
/// So a completed part yields two events. They are offset by index so they
|
|
366
|
+
/// sort in order even when a tool finishes inside the millisecond it started:
|
|
367
|
+
/// the server's tie-break is a random id, and a result that sorted before its
|
|
368
|
+
/// own call is visibly wrong on a timeline.
|
|
369
|
+
///
|
|
370
|
+
/// A part that is still running yields the call alone. The result is not
|
|
371
|
+
/// merely absent from our output, it does not exist yet — `state.output` is
|
|
372
|
+
/// empty and `state.time.end` is unset until opencode writes them, ~12 seconds
|
|
373
|
+
/// later on the observed capture.
|
|
374
|
+
fn tool_events(row: &PartRow, ctx: &Ctx) -> Vec<Value> {
|
|
375
|
+
let state = row.data.get("state");
|
|
376
|
+
let status = state
|
|
377
|
+
.and_then(|s| s.get("status"))
|
|
378
|
+
.and_then(|s| s.as_str())
|
|
379
|
+
.unwrap_or("");
|
|
380
|
+
// `pending` is the call before its arguments have finished streaming:
|
|
381
|
+
// there is no input to report yet. Every other status — including one this
|
|
382
|
+
// code has never heard of — reports the call, so a new opencode state name
|
|
383
|
+
// degrades to an extra event rather than a silently missing one.
|
|
384
|
+
if status == "pending" {
|
|
385
|
+
return Vec::new();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
let name = row
|
|
389
|
+
.data
|
|
390
|
+
.get("tool")
|
|
391
|
+
.and_then(|t| t.as_str())
|
|
392
|
+
.unwrap_or("tool");
|
|
393
|
+
// Falls back to the part id so two identical parallel calls cannot
|
|
394
|
+
// hash-collapse into one row.
|
|
395
|
+
let call_id = row
|
|
396
|
+
.data
|
|
397
|
+
.get("callID")
|
|
398
|
+
.and_then(|c| c.as_str())
|
|
399
|
+
.unwrap_or(&row.id);
|
|
400
|
+
|
|
401
|
+
let mut out = Vec::new();
|
|
402
|
+
if let Some(mut m) = base(ctx, "tool_use", row.time_created, 0, &row.id) {
|
|
403
|
+
m.insert("tool_name".into(), json!(name));
|
|
404
|
+
m.insert("tool_call_id".into(), json!(call_id));
|
|
405
|
+
if let Some(input) = state.and_then(|s| s.get("input")) {
|
|
406
|
+
m.insert("input".into(), input.clone());
|
|
407
|
+
}
|
|
408
|
+
if let Some(model) = model_id(&row.message) {
|
|
409
|
+
m.insert("model".into(), json!(model));
|
|
410
|
+
}
|
|
411
|
+
// Deliberately carries no status: a running part and the completed
|
|
412
|
+
// part it becomes then produce a byte-identical `tool_use`, which the
|
|
413
|
+
// server collapses. Putting the status here would leave one visible
|
|
414
|
+
// row per state the call passed through.
|
|
415
|
+
out.push(Value::Object(m));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if !matches!(status, "completed" | "error") {
|
|
419
|
+
return out;
|
|
420
|
+
}
|
|
421
|
+
let end = state
|
|
422
|
+
.and_then(|s| s.get("time"))
|
|
423
|
+
.and_then(|t| t.get("end"))
|
|
424
|
+
.and_then(serde_json::Value::as_i64)
|
|
425
|
+
// Only reachable on a shape without timing; the row's own last-write
|
|
426
|
+
// time is the closest true statement about when the result existed.
|
|
427
|
+
.unwrap_or(row.time_updated);
|
|
428
|
+
if let Some(mut m) = base(ctx, "tool_result", end, 1, &row.id) {
|
|
429
|
+
m.insert("tool_name".into(), json!(name));
|
|
430
|
+
m.insert("tool_call_id".into(), json!(call_id));
|
|
431
|
+
m.insert("opencode_tool_status".into(), json!(status));
|
|
432
|
+
let output = state.and_then(|s| s.get("output")).map(stringify);
|
|
433
|
+
let error = state.and_then(|s| s.get("error")).map(stringify);
|
|
434
|
+
if let Some(o) = output.clone().filter(|o| !o.is_empty()) {
|
|
435
|
+
m.insert("output".into(), json!(o));
|
|
436
|
+
} else if let Some(e) = error.clone().filter(|e| !e.is_empty()) {
|
|
437
|
+
// An errored tool has no output; the message is the whole result.
|
|
438
|
+
m.insert("output".into(), json!(e));
|
|
439
|
+
}
|
|
440
|
+
if status == "error" {
|
|
441
|
+
let detail = error.or(output).unwrap_or_default();
|
|
442
|
+
m.insert("error".into(), json!(detail));
|
|
443
|
+
m.insert("error_type".into(), json!("opencode_tool_error"));
|
|
444
|
+
}
|
|
445
|
+
out.push(Value::Object(m));
|
|
446
|
+
}
|
|
447
|
+
out
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/// True when opencode has finished writing this part's content.
|
|
451
|
+
///
|
|
452
|
+
/// A part that carries a `time` object is settled once that object has an
|
|
453
|
+
/// `end`; one with no `time` at all (a user prompt) was written in a single
|
|
454
|
+
/// shot and is settled on arrival.
|
|
455
|
+
fn is_settled(data: &Value) -> bool {
|
|
456
|
+
match data.get("time") {
|
|
457
|
+
Some(t) => t.get("end").is_some_and(|e| !e.is_null()),
|
|
458
|
+
None => true,
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/// The model id, from either message shape: an assistant message carries a
|
|
463
|
+
/// flat `modelID`, a user message a nested `model:{providerID, modelID}`.
|
|
464
|
+
fn model_id(message: &Value) -> Option<&str> {
|
|
465
|
+
message.get("modelID").and_then(|m| m.as_str()).or_else(|| {
|
|
466
|
+
message
|
|
467
|
+
.get("model")
|
|
468
|
+
.and_then(|m| m.get("modelID"))
|
|
469
|
+
.and_then(|m| m.as_str())
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
fn provider_id(message: &Value) -> Option<&str> {
|
|
474
|
+
message
|
|
475
|
+
.get("providerID")
|
|
476
|
+
.and_then(|p| p.as_str())
|
|
477
|
+
.or_else(|| {
|
|
478
|
+
message
|
|
479
|
+
.get("model")
|
|
480
|
+
.and_then(|m| m.get("providerID"))
|
|
481
|
+
.and_then(|p| p.as_str())
|
|
482
|
+
})
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/// Render a tool payload as text. opencode writes a plain string; anything
|
|
486
|
+
/// else is rendered rather than dropped.
|
|
487
|
+
fn stringify(v: &Value) -> String {
|
|
488
|
+
match v {
|
|
489
|
+
Value::String(s) => s.clone(),
|
|
490
|
+
Value::Null => String::new(),
|
|
491
|
+
other => other.to_string(),
|
|
492
|
+
}
|
|
493
|
+
}
|