diff --git a/.github/scripts/mock-braintrust.py b/.github/scripts/mock-braintrust.py new file mode 100644 index 0000000..593aef0 --- /dev/null +++ b/.github/scripts/mock-braintrust.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Minimal Braintrust API used by the real-session release smoke test.""" + +import gzip +import json +import os +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +port = int(os.environ.get("MOCK_COLLECTOR_PORT", "53999")) +summary_path = Path(os.environ["MOCK_COLLECTOR_OUT"]) +summary = {"logs3Requests": 0, "totalRows": 0} + + +def save_summary() -> None: + summary_path.write_text(json.dumps(summary), encoding="utf-8") + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, format: str, *args: object) -> None: + print(f"mock-braintrust: {format % args}", flush=True) + + def send_json(self, value: object) -> None: + body = json.dumps(value).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self) -> None: + if self.path == "/version": + self.send_json({"logs3_payload_max_bytes": None}) + else: + self.send_json({}) + + def do_POST(self) -> None: + length = int(self.headers.get("Content-Length", "0")) + body = self.rfile.read(length) + if self.headers.get("Content-Encoding") == "gzip": + body = gzip.decompress(body) + + if self.path == "/api/apikey/login": + base = f"http://127.0.0.1:{port}" + self.send_json( + { + "org_info": [ + { + "id": "smoke-org", + "name": "smoke", + "api_url": base, + "proxy_url": base, + } + ] + } + ) + return + if self.path == "/api/project/register": + self.send_json( + { + "project": { + "id": "00000000-0000-0000-0000-000000000000", + "name": "smoke", + } + } + ) + return + if self.path in ("/logs3", "/logs3/overflow"): + try: + rows = json.loads(body or b"{}").get("rows", []) + except (json.JSONDecodeError, AttributeError): + rows = [] + summary["logs3Requests"] += 1 + summary["totalRows"] += len(rows) + save_summary() + print( + f"mock-braintrust: received {len(rows)} row(s), " + f"{summary['totalRows']} total", + flush=True, + ) + self.send_json({}) + + +save_summary() +ThreadingHTTPServer(("127.0.0.1", port), Handler).serve_forever() diff --git a/.github/workflows/_release.yml b/.github/workflows/_release.yml index bf8665e..ec8a894 100644 --- a/.github/workflows/_release.yml +++ b/.github/workflows/_release.yml @@ -43,10 +43,7 @@ concurrency: jobs: release: - # codex builds native macOS binaries that must be ad-hoc codesigned (only - # `codesign`, i.e. macOS, can do that), so it runs on a macOS runner. claude - # has no binaries, so it stays on cheaper Linux. - runs-on: ${{ inputs.plugin == 'codex' && 'macos-14' || 'ubuntu-24.04' }} + runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - name: Checkout monorepo @@ -77,14 +74,6 @@ jobs: echo "tag=$tag" >> "$GITHUB_OUTPUT" echo "Releasing $tag -> ${{ inputs.dist_repo }} (record=${{ inputs.record }})" - - name: Set up Node (for building codex binaries) - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: 22 - - - name: Enable pnpm - run: corepack enable - - name: Bump plugin manifest versions run: python3 scripts/set-plugin-version.py "${{ inputs.plugin }}" "${{ steps.vars.outputs.version }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6411b5..b99cd73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,3 @@ -# CI: build every plugin and validate the built trees (`make test`). name: CI on: @@ -6,19 +5,67 @@ on: push: branches: [main] pull_request: - branches: [main] permissions: contents: read +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + jobs: - test: - # Pin to a specific runner version so the workflow is reproducible. + plugins: + name: Plugin packages runs-on: ubuntu-24.04 timeout-minutes: 10 steps: - - name: Checkout - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - name: Build and validate all plugins + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Build and validate plugins run: make test + + daemon: + name: Daemon (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-24.04 + agent_suffix: "" + - os: macos-latest + agent_suffix: "" + - os: windows-latest + agent_suffix: ".cmd" + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal + rustup default stable + rustup component add clippy rustfmt + - name: Install latest coding agents + run: npm install --prefix "${{ runner.temp }}/coding-agents" --no-save --no-package-lock --no-audit --no-fund --cache "${{ runner.temp }}/npm-cache" @openai/codex@latest @anthropic-ai/claude-code@latest + - name: Report coding-agent versions + run: | + npm exec --prefix "${{ runner.temp }}/coding-agents" -- codex --version + npm exec --prefix "${{ runner.temp }}/coding-agents" -- claude --version + - name: Check formatting + if: runner.os == 'Linux' + run: cargo fmt --manifest-path bt-daemon/Cargo.toml -- --check + - name: Build daemon + run: cargo build --manifest-path bt-daemon/Cargo.toml --all-features --locked + - name: Test daemon + run: cargo test --manifest-path bt-daemon/Cargo.toml --all-features --locked + - name: Test coding-agent integrations with deterministic inference + env: + BT_AGENT_INFERENCE_MODE: mock + BT_AGENT_INGEST_MODE: mock + CODEX_BIN: ${{ runner.temp }}/coding-agents/node_modules/.bin/codex${{ matrix.agent_suffix }} + CLAUDE_BIN: ${{ runner.temp }}/coding-agents/node_modules/.bin/claude${{ matrix.agent_suffix }} + run: cargo test --manifest-path bt-daemon/Cargo.toml --all-features --locked --test agent_integration -- --ignored --nocapture --test-threads=1 + - name: Lint daemon + run: cargo clippy --manifest-path bt-daemon/Cargo.toml --all-targets --all-features --locked -- -D warnings diff --git a/.github/workflows/smoke-codex.yml b/.github/workflows/smoke-codex.yml index a0a4faa..250b83c 100644 --- a/.github/workflows/smoke-codex.yml +++ b/.github/workflows/smoke-codex.yml @@ -1,18 +1,8 @@ -# End-to-end smoke test for the codex plugin against a deployed distribution repo. -# Installs Codex + the plugin from the marketplace on Linux + both macOS arches, -# runs a real `codex exec` session with tracing pointed at a local mock Braintrust -# collector, and asserts at least one trace row was reported. This catches the -# per-platform failure modes a source build can't (code signing, cross-compiled -# binaries, marketplace install). -# -# Two entry points: -# - workflow_call: run by _release.yml after a deploy, against the repo it just -# published to. -# - workflow_dispatch: run manually against any dist repo (default: the test repo). -# -# Requires the OPENAI_API_KEY secret for `codex exec`; if it is unset the smoke is -# skipped (with a warning) rather than failing. PUBLISH_TOKEN is used to clone a -# private/internal dist repo. +# Post-deploy end-to-end smoke test for the Codex plugin. It installs the +# deployed marketplace, runs a real Codex session through the daemon-capable +# `bt` CLI, and verifies that the Rust daemon sends span rows to a local mock +# Braintrust backend. Credentials and backend URLs are supplied to `bt`; the +# shared daemon config contains behavior settings only. name: Smoke (codex) @@ -39,18 +29,15 @@ permissions: contents: read env: - PLUGIN_DIR: src/plugins/codex/content/plugins/trace-codex MARKETPLACE: braintrust-codex-plugins jobs: - # Skip the (matrix) smoke cleanly when no OpenAI key is configured, instead of - # failing every release. Job-level `if` can't read secrets, so gate here. guard: runs-on: ubuntu-24.04 outputs: - has_key: ${{ steps.c.outputs.has_key }} + has_key: ${{ steps.check.outputs.has_key }} steps: - - id: c + - id: check env: KEY: ${{ secrets.OPENAI_API_KEY }} run: | @@ -82,29 +69,46 @@ jobs: with: node-version: 22 - - name: Enable pnpm - run: corepack enable - - - name: Install plugin dependencies (for the mock collector) - working-directory: ${{ env.PLUGIN_DIR }} - run: pnpm install + - name: Install Codex and bt CLIs + run: | + npm install -g @openai/codex + curl -fsSL https://bt.dev/cli/install.sh | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: Install Codex CLI - run: npm install -g @openai/codex + - name: Require daemon-capable bt + run: | + "$HOME/.local/bin/bt" daemon hook --help - - name: Install codex plugin from ${{ inputs.dist_repo }} + - name: Install plugin from ${{ inputs.dist_repo }} env: - # PUBLISH_TOKEN can read the (private/internal) dist repo; rewrite - # github.com clones to use it so `codex plugin marketplace add` (a git - # clone) authenticates. GH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} run: | git config --global url."https://x-access-token:${GH_TOKEN}@github.com/".insteadOf "https://github.com/" codex plugin marketplace add "${{ inputs.dist_repo }}" codex plugin add "trace-codex@${MARKETPLACE}" - - name: Run smoke test (${{ matrix.label }}) - working-directory: ${{ env.PLUGIN_DIR }} + - name: Run real traced Codex session (${{ matrix.label }}) env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: sh scripts/smoke-test.sh + CODEX_API_KEY: ${{ secrets.OPENAI_API_KEY }} + BRAINTRUST_API_KEY: smoke-key + BRAINTRUST_API_URL: http://127.0.0.1:53999 + BRAINTRUST_APP_URL: http://127.0.0.1:53999 + BT_DAEMON_CONFIG: ${{ runner.temp }}/bt-daemon-config.json + MOCK_COLLECTOR_OUT: ${{ runner.temp }}/mock-summary.json + run: | + printf '%s\n' '{"traceToBraintrust":true,"project":"trace-codex-smoke","flushOnTurnEnd":true}' > "$BT_DAEMON_CONFIG" + python3 .github/scripts/mock-braintrust.py > "${{ runner.temp }}/mock-collector.log" 2>&1 & + collector_pid=$! + trap 'kill "$collector_pid" 2>/dev/null || true' EXIT + for attempt in $(seq 1 50); do + curl -fsS http://127.0.0.1:53999/version >/dev/null && break + sleep 0.2 + done + curl -fsS http://127.0.0.1:53999/version >/dev/null + codex exec \ + --skip-git-repo-check \ + --dangerously-bypass-hook-trust \ + --sandbox read-only \ + "say hi" + python3 -c 'import json, os; s=json.load(open(os.environ["MOCK_COLLECTOR_OUT"])); assert s["totalRows"] >= 1, s; print(s)' diff --git a/AGENTS.md b/AGENTS.md index 18d19fd..1b0f057 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,100 +1,87 @@ # Braintrust coding-agent plugins — monorepo -This repo is the **single source of truth** for Braintrust's coding-agent plugins -(Claude Code, Codex, …). Each agent's plugin is developed here, then **built** and -**deployed** to a per-agent **distribution repo** that the agent's marketplace -installs from. Keeping one monorepo means a fix to shared behavior lands in one -place instead of N hand-maintained repos. +This repo is the single source of truth for Braintrust's coding-agent plugins. +Each plugin is developed here, built, and deployed to a per-agent distribution +repository that its marketplace installs from. ## Layout -``` +```text src/plugins// one directory per agent (claude, codex) - content/ the deployable plugin tree, verbatim (what ships) + content/ the deployable plugin tree, verbatim build.sh assemble the deployable tree into - validate.sh sanity-check a built tree (manifests, required files) + validate.sh validate manifests and required files publish.sh deploy a built tree to a distribution repo -scripts/publish.sh read the PUBLISH_TARGETS map, dispatch to each plugin's publish.sh -Makefile build / test / publish entry points -.github/workflows/ CI + release automation -bt-daemon/ shared Rust project (self-contained; placeholder for now) +scripts/publish.sh dispatch PUBLISH_TARGETS to each plugin +bt-daemon/ shared Rust tracing crate embedded by bt +Makefile build, test, and publish entry points +.github/workflows/ CI and release automation ``` -Everything an agent installs lives under `src/plugins//content/`. `build.sh` -is mostly a copy of that tree; the one exception is codex, whose `build.sh` also -compiles the `trace-codex` hook binaries into the tree at deploy time. - -## How versioning works - -Versioning is **per plugin**. Each plugin carries its own version in its manifest -JSON (`content/plugins//.-plugin/plugin.json`), and a release bumps -those version fields (never the marketplace manifest). `scripts/set-plugin-version.py` -does the bump. +Everything an agent installs lives under `src/plugins//content/`. +Plugin hooks are thin fail-open shell and Windows command launchers that invoke +`bt daemon hook`; they do not contain or compile a second tracing runtime. -## Distribution repos +## Local development -Built plugin trees are pushed to dedicated repos that marketplaces install from -(the install URLs users already use): +```bash +make build +make build-codex +make test +cargo test --manifest-path bt-daemon/Cargo.toml --all-features +``` -| Agent | Distribution repo | -|--------|----------------------------------------------| -| claude | `braintrustdata/braintrust-claude-plugin` | -| codex | `braintrustdata/braintrust-codex-plugin` | +## Versioning and distribution -A distribution repo is a **generated artifact**: each deploy clones it, replaces -its whole tracked tree with a fresh build, and pushes. `braintrustdata/test-coding-agent-dist` -is a shared sandbox used for dry runs. +Versioning is per plugin. Each plugin carries its version in its plugin +manifest, and `scripts/set-plugin-version.py` updates those manifests for a +release. Marketplace manifests are not versioned. -## Local development +| Agent | Distribution repository | +|---|---| +| claude | `braintrustdata/braintrust-claude-plugin` | +| codex | `braintrustdata/braintrust-codex-plugin` | -``` -make build # build every plugin into dist/ -make build-codex # build just one -make test # build + validate every plugin -``` +A distribution repository is a generated artifact. Each deploy clones it, +replaces the tracked tree with a fresh build, and pushes the result. +`braintrustdata/test-coding-agent-dist` is the shared release sandbox. -To deploy manually, set the `PUBLISH_TARGETS` map (`plugin:repo`, comma-separated) -and run `make publish`. It validates the map first (rejects unknown plugins, -malformed entries, or two plugins pointing at the same repo), then for each target -clones the dist repo, rebuilds, and pushes: +To deploy manually, provide a comma-separated `plugin:repo` map: -``` +```bash PUBLISH_TARGETS="codex:braintrustdata/test-coding-agent-dist" make publish -DRY_RUN=1 PUBLISH_TARGETS="..." make publish # build + commit locally, skip the push +DRY_RUN=1 PUBLISH_TARGETS="codex:braintrustdata/test-coding-agent-dist" make publish ``` -Cross-repo pushes need a token with `contents:write` on the target repo, supplied -as `GH_TOKEN` (or ambient git credentials for an ssh URL). - -## Releasing (CI) +Cross-repository pushes use `GH_TOKEN` or ambient Git credentials. -Releases are **manual** GitHub Actions (Actions tab → Run workflow): +## Releasing -- **Release plugin** (`release.yml`) — pick `version` + `plugin`. Deploys to the - **production** dist repo and does the full flow: bump the plugin's version JSON, - commit to `main`, tag `v-`, create a GitHub Release, then deploy. -- **Release plugin (test)** (`test-release.yml`) — same dropdowns, deploys to the - **test** repo, and **skips** the commit/tag/release so it leaves no trace on the - monorepo and can be re-run freely. Use it to dry-run a release end to end. +The manual `release.yml` workflow deploys a production release, records the +version bump on `main`, tags it, and creates a GitHub Release. The manual +`test-release.yml` workflow exercises the same deployment against the test +repository without committing or tagging. Both call `_release.yml`. -Both are thin callers of the reusable `_release.yml`, which holds the shared logic -(a `record` flag gates the monorepo commit/tag/release). +A Codex deployment can run `smoke-codex.yml`, which installs the deployed +plugin and runs a real Codex session through the daemon when +`OPENAI_API_KEY` is available. -After a codex deploy, a **smoke test** (`smoke-codex.yml`) installs the just-deployed -plugin from the marketplace on Linux + both macOS arches and asserts a real Codex -session traces to Braintrust. It's a post-deploy verification (not a gate) and is -skipped if no `OPENAI_API_KEY` secret is configured. +CI builds and validates both plugin packages and builds, tests, and lints the +Rust daemon on Linux, macOS, and Windows. Concurrent runs for an obsolete +branch revision are cancelled. -`ci.yml` runs `make test` on pushes and PRs to `main`. +## Secrets -## Secrets (CI) +- `PUBLISH_TOKEN` grants `contents:write` on distribution repositories. +- `OPENAI_API_KEY` enables the optional real Codex smoke test. -- `PUBLISH_TOKEN` — `contents:write` on the distribution repos; used for cross-repo - deploys and to clone private dist repos. -- `OPENAI_API_KEY` — used by the codex smoke test; optional (smoke skips without it). +Braintrust authentication is deliberately not stored in plugin or daemon +settings. The embedding `bt` CLI owns profiles, OAuth, keychain access, API +keys, token refresh, and backend URL resolution. ## bt-daemon -`bt-daemon/` is a self-contained Rust project (its own Cargo workspace) intended to -become shared plugin logic. It's a placeholder today and depends on nothing else in -the repo, so it can be lifted into its own repo later. See `bt-daemon/README.md`. +`bt-daemon/` is one self-contained Rust crate. The library is embedded by `bt`; +the feature-gated standalone binary exists for development and integration +tests. It owns event journaling, agent-specific translation, span construction, +recovery, transport, and delivery. See `bt-daemon/README.md`. diff --git a/bt-daemon/Cargo.lock b/bt-daemon/Cargo.lock index 977ecb9..dfc514e 100644 --- a/bt-daemon/Cargo.lock +++ b/bt-daemon/Cargo.lock @@ -2,6 +2,2509 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backoff" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" +dependencies = [ + "futures-core", + "getrandom 0.2.17", + "instant", + "pin-project-lite", + "rand 0.8.7", + "tokio", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bon" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "braintrust-sdk-rust" +version = "0.1.0-alpha.2" +source = "git+https://github.com/braintrustdata/braintrust-sdk-rust?rev=d33e806bf6ab9548d37355f6a5098a971ef150aa#d33e806bf6ab9548d37355f6a5098a971ef150aa" +dependencies = [ + "anyhow", + "arc-swap", + "async-trait", + "backoff", + "base64", + "bon", + "bytes", + "chrono", + "crossbeam", + "futures", + "indexmap", + "regex", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "bt-daemon" -version = "0.0.0" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "braintrust-sdk-rust", + "bytes", + "chrono", + "clap", + "regex", + "reqwest", + "serde", + "serde_json", + "sha2", + "tempfile", + "thiserror 2.0.19", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", + "wiremock", + "zstd", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/bt-daemon/Cargo.toml b/bt-daemon/Cargo.toml index 67b9971..b76b55a 100644 --- a/bt-daemon/Cargo.toml +++ b/bt-daemon/Cargo.toml @@ -1,22 +1,42 @@ -# bt-daemon — shared Rust project for Braintrust coding-agent plugins. -# -# The empty [workspace] table makes THIS directory the Cargo workspace root, so -# the crate is not absorbed by any parent manifest and the whole project can be -# lifted into its own repo by copying bt-daemon/ verbatim. Keep the workspace -# root here (not at the monorepo root) to preserve that portability. -# -# Placeholder for now; real crates/modules land here as the shared daemon and -# per-agent tracing move from TypeScript to Rust. - [package] name = "bt-daemon" -version = "0.0.0" +version = "0.1.0" edition = "2021" license = "MIT" -description = "Shared daemon for Braintrust coding-agent plugins (placeholder)." publish = false +description = "Embeddable Braintrust coding-agent tracing daemon." + +[features] +default = [] +# Standalone development/test binary. Production embeds the library in `bt`. +cli = ["dep:tracing-subscriber"] -[workspace] -resolver = "2" +[[bin]] +name = "bt-daemon" +path = "src/main.rs" +required-features = ["cli"] [dependencies] +# Keep this pinned exactly, matching the dependency policy used by `bt`. +braintrust-sdk-rust = { git = "https://github.com/braintrustdata/braintrust-sdk-rust", rev = "d33e806bf6ab9548d37355f6a5098a971ef150aa" } +anyhow = "1" +async-trait = "0.1" +chrono = "0.4" +clap = { version = "4", features = ["derive", "env"] } +regex = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time", "process", "signal", "fs"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true } +uuid = { version = "1", features = ["v4", "v5"] } + +[dev-dependencies] +axum = "0.8" +bytes = "1" +reqwest = { version = "0.12", default-features = false, features = ["json", "stream"] } +tempfile = "3" +wiremock = "0.6" +zstd = "0.13" diff --git a/bt-daemon/README.md b/bt-daemon/README.md index 5ede331..d633658 100644 --- a/bt-daemon/README.md +++ b/bt-daemon/README.md @@ -1,20 +1,101 @@ # bt-daemon -Shared Rust project for Braintrust coding-agent plugins. **Placeholder name** — -the real name is TBD. +Shared Rust project for Braintrust coding-agent tracing plugins. A local, +stateful daemon that plugin **hook shims** forward events to; it owns the +event→trace state machine and sends spans to Braintrust out-of-band. See +[`docs/protocol.md`](docs/protocol.md) for the wire contract. -Long-term, the shared logic behind the per-agent tracing plugins (currently -TypeScript, e.g. `trace-codex`) moves here to Rust. Right now it's a stub. +> **Placeholder name** — the real name is TBD. The subcommand framing +> (`serve` / `hook` / `status` / `import`) should survive a rename. -## Self-contained +## Layout -This directory is its own Cargo workspace root (see the empty `[workspace]` -table in `Cargo.toml`), so it does not depend on anything else in the monorepo -and can be moved to a standalone repo by copying `bt-daemon/` as-is. +One self-contained Cargo crate, liftable to its own repo by copying +`bt-daemon/` verbatim: -## Build / run +- `src/wire` — the wire protocol module: envelope types + JSON-RPC framing. +- `src/translate` and `src/sink` — agent state machines and Braintrust output. +- `src/lib.rs` — the embeddable library: clap `Args` + async entry points + (`run_serve`, `run_hook`, `run_status`, `run_import`). This is what `bt` + depends on. +- `src/main.rs` — the standalone **`bt-daemon` binary**, compiled only with + the `cli` feature for isolated testing/development. Env/flag static-token + auth only; not an end-user artifact. + +## Dual consumption + +The daemon core is credential-passive — it only ever *receives* a resolved +`BackendAuth` with each session's config — so two front-ends share all core +behavior. The `cli` feature only enables the standalone binary and its logging +subscriber: + +1. **Embedded in `bt`** (production): `bt` fills `BackendAuth` from its profile + / OAuth / keychain auth. +2. **Standalone binary** (testing): fills it from `BRAINTRUST_API_KEY` etc. + +## Shared plugin settings + +Codex, Claude Code, and future hook plugins read the same non-credential +settings file through the embedded daemon. Set `BT_DAEMON_CONFIG` explicitly, +or use `config.json` under `BT_DAEMON_DATA_DIR` (by default +`~/.braintrust/state/bt-daemon/config.json` on Unix and +`%LOCALAPPDATA%\Braintrust\bt-daemon\config.json` on Windows). + +See [`config.json.example`](config.json.example). Supported settings are +`traceToBraintrust`, `project`, `flushOnTurnEnd`, and +`additionalMetadata`. File values override plugin environment fallbacks. +Credentials, auth tokens, organization selection, and backend URLs are not +settings here; production resolves them through `bt`. + +## Build / test ```bash cd bt-daemon -cargo run +cargo test # library + pipeline tests +cargo test --features cli # also compile/test the CLI +cargo build --features cli --bin bt-daemon # standalone test binary +``` + +CI runs the all-feature build, test suite, and Clippy on Linux, macOS, and +Windows. The pipeline integration tests use Unix-domain sockets on Unix and +real Windows named pipes on Windows. + +## Try it (standalone, debug sink) + +```bash +export BT_DAEMON_SOCKET=/tmp/btd.sock BT_DAEMON_DATA_DIR=/tmp/btd +cargo build --features cli --bin bt-daemon +echo '{"session_id":"s1","hook_event_name":"SessionStart"}' | ./target/debug/bt-daemon hook --source debug +echo '{"session_id":"s1","hook_event_name":"Stop"}' | ./target/debug/bt-daemon hook --source debug +./target/debug/bt-daemon status +# journaled events: $BT_DAEMON_DATA_DIR/journal/s1.ndjson +# emitted span rows: $BT_DAEMON_DATA_DIR/spans/s1.ndjson ``` + +The first `hook` spawns the daemon detached; it idles out after 5 minutes. + +`import ` has a different purpose from restart +recovery. It locates the native transcript in the selected agent's standard +session store, synthesizes the lifecycle triggers that can be recovered from +that transcript, and sends them through the normal translator and sink to +create a trace for the past session. Hook-only facts absent from a native +transcript are not invented. + +## Status + +Phases 0–5 are implemented: protocol, daemon lifecycle, Braintrust sink, +Codex and Claude translators, `bt daemon` integration, and thin hook shims for +both shipped plugins. Restart recovery replays the redacted journal with +deterministic span ids, so resubmitted rows merge into the same spans instead +of creating duplicates. Claude lifecycle entries embed transcript snapshots, so +recovery does not depend on mutable external paths. Explicit turn/session-end +flushes are bounded, and sessions can target project logs or an experiment. + +Windows named-pipe transport, detached spawning, lifecycle handover, and +cross-platform pipeline tests are implemented. The remaining host follow-ups +are OpenCode and pi, which are not present in this monorepo. + +- The Braintrust sink pins `braintrust-sdk-rust` commit `d33e806`, which adds + deterministic span ids, `span_origin`/`span_attributes` passthrough, and + per-session credential isolation. This follows the same exact-revision Git + dependency policy as `bt`. diff --git a/bt-daemon/config.json.example b/bt-daemon/config.json.example new file mode 100644 index 0000000..508b630 --- /dev/null +++ b/bt-daemon/config.json.example @@ -0,0 +1,10 @@ +{ + "_comment": "Shared by every coding-agent plugin connected to bt-daemon. Authentication and backend URLs are resolved by bt and do not belong here.", + "traceToBraintrust": true, + "project": "my-coding-agents", + "flushOnTurnEnd": false, + "additionalMetadata": { + "team": "platform", + "environment": "development" + } +} diff --git a/bt-daemon/docs/protocol.md b/bt-daemon/docs/protocol.md new file mode 100644 index 0000000..a496eec --- /dev/null +++ b/bt-daemon/docs/protocol.md @@ -0,0 +1,247 @@ +# bt-daemon wire protocol (v1) + +Status: **frozen for the prototype.** This is the contract between plugin shims +(`hook` clients) and the daemon (`serve`), and between the embedded-in-`bt` +front-end and the standalone test binary. + +All hook clients share one daemon-level, non-credential settings file. Its path +is `$BT_DAEMON_CONFIG`, falling back to `/config.json` and +then the platform default daemon state directory. The hook front-end applies +`traceToBraintrust`, `project`, `flushOnTurnEnd`, and `additionalMetadata` +before constructing `SessionConfig`. Authentication and backend URLs are +resolved by `bt` and never read from this file. + +`PROTOCOL_VERSION = 1`. + +## Transport + +- **Unix domain socket (Linux/macOS).** Default path resolution (first match wins): + 1. `--socket ` flag / `BT_DAEMON_SOCKET` env (explicit override; used by + tests to sandbox a daemon per test). + 2. `$XDG_RUNTIME_DIR/braintrust/daemon.sock` if `XDG_RUNTIME_DIR` is set. + 3. `$HOME/.braintrust/run/daemon.sock`. + The containing directory is created mode `0700`. macOS caps `sockaddr_un` + paths at 104 bytes; all defaults stay well under. +- **Framing: newline-delimited JSON.** Exactly one JSON value per line, + terminated by `\n`. `serde_json` never emits a bare newline inside a value, + so `\n` is an unambiguous frame delimiter. Max line length is bounded + (default 64 MiB) to cap memory on a malformed/huge payload; over-length lines + are a protocol error and close the connection. +- **Windows named pipe.** `--socket` / `BT_DAEMON_SOCKET` may provide an + explicit full pipe name. Otherwise the daemon uses + `\\.\pipe\braintrust-bt-daemon-`, where the suffix is derived + from the Windows domain and user name so concurrent users do not share a + daemon. The pipe is byte-mode, so framing is identical to Unix. + +## RPC: JSON-RPC 2.0 + +Each frame is a JSON-RPC 2.0 Request, Response, or Notification. + +Request: +```json +{ "jsonrpc": "2.0", "id": 1, "method": "event.log", "params": { ... } } +``` +Response (success): +```json +{ "jsonrpc": "2.0", "id": 1, "result": { ... } } +``` +Response (error): +```json +{ "jsonrpc": "2.0", "id": 1, "error": { "code": -32602, "message": "...", "data": { ... } } } +``` +Notification (no `id`, no response): +```json +{ "jsonrpc": "2.0", "method": "event.log", "params": { ... } } +``` + +`id` is an integer or string. Error `code` uses the JSON-RPC reserved ranges +for protocol errors (`-32700` parse, `-32600` invalid request, `-32601` method +not found, `-32602` invalid params, `-32603` internal); application errors use +`-32000 … -32099`. + +### Ordering & delivery + +A subprocess-style shim opens a connection, does one `event.log`, and exits. +Per-session ordering is guaranteed because (a) the agent runs hooks in blocking +mode, so it does not fire the next hook until the current one returns, and (b) +`event.log` is a **request** whose success response means *the event has been +appended to that session's ordered queue* (not that it has been delivered to +Braintrust). The shim must await that response before exiting. Long-lived +in-process clients (opencode/pi, later) hold one connection and may send +`event.log` as a **notification** for the hot path, relying on the single +connection for ordering. + +## Methods + +### `initialize` (request) + +First message on every connection. + +Params: +```json +{ + "protocol_version": 1, + "client": { "source": "codex", "plugin_version": "1.2.3", "pid": 12345 } +} +``` +Result: +```json +{ + "protocol_version": 1, + "daemon_version": "0.1.0", + "capabilities": { "sources": ["codex", "claude-code", "debug"] } +} +``` +If `protocol_version` is incompatible the daemon returns an application error; +the client decides whether to drop events or (if the client is newer) trigger a +version handover (`daemon.shutdown` → respawn). + +### `event.log` (request or notification) + +The hot path. Params are the **Envelope** (see below). Request result: +```json +{ "accepted": true } +``` +`accepted: true` means enqueued to the session's ordered queue and journaled. +The daemon never fails the caller's turn for a downstream (Braintrust) error; +those are handled asynchronously and surfaced via `status.get`. + +### `session.flush` (request) + +Block until the session's spans are delivered, or `timeout_ms` elapses. + +Params: +```json +{ "session_id": "…", "timeout_ms": 10000 } +``` +Result: +```json +{ "flushed": true, "pending": 0 } +``` +`flushed: false` with `pending > 0` means the timeout was hit with work +outstanding. Used by session-end hooks and flush-on-turn-end mode. + +### `status.get` (request) + +Params: `{ "session_id": "…" }` (omit `session_id` for daemon-wide status). +Result: +```json +{ + "daemon_version": "0.1.0", + "uptime_ms": 123456, + "sessions": [ + { + "session_id": "…", + "source": "codex", + "queued": 0, + "spans_emitted": 42, + "permalink": "https://www.braintrust.dev/app/…", + "last_error": null + } + ] +} +``` +Powers a `status` CLI and pi's trace-link widget. + +### `daemon.shutdown` (request) + +Graceful: stop accepting new events, drain all session queues, flush sinks, +release the local endpoint, exit. Result `{ "ok": true }` is sent before exit. +Used for version handover and by tests. + +## Envelope (`event.log` params) + +```json +{ + "source": "codex", + "source_version": "1.2.3", + "session_id": "0f9d…", + "event": "PostToolUse", + "ts_ms": 1753639552123, + "payload": { "…raw agent-native hook payload…": true }, + "config": { + "auth": { + "token": "sk-…", + "api_url": "https://api.braintrust.dev", + "app_url": "https://www.braintrust.dev", + "org_name": "acme" + }, + "project": "codex", + "parent_span_id": null, + "root_span_id": null, + "flush_mode": "fire_and_forget", + "additional_metadata": { "…": "…" } + } +} +``` + +Field notes: + +- **`source`** selects the daemon-side translator. `debug` is a built-in + pass-through translator used by the prototype and tests. +- **`session_id`** is the per-session queue + state key. The shim extracts it + from the payload (default JSON field `session_id`, overridable with + `--session-id-field`); both Claude Code and Codex use `session_id`. +- **`event`** is the agent-native hook name (not normalized). Extracted from + the payload (default field `hook_event_name`, overridable with `--event`). +- **`ts_ms`** is stamped by the shim **at capture time** (epoch millis), + because the daemon processes later than the hook fired. Never stamped by the + daemon. +- **`payload`** is opaque to transport and to everything except the translator + for `source`. +- **`config`** carries shim-resolved credentials and trace settings. The shim + attaches it on **every** event (stateless shim); the daemon keeps the latest + per session and only re-inits the Braintrust sink when it changes. `auth` is + filled by `bt`'s `resolve_auth` when embedded, or from env/flags in the + standalone binary. `flush_mode` ∈ `fire_and_forget` | `flush_on_turn_end`. + +### Redaction + +`config.auth.token` (and any nested secret) is **never** written to the +journal or logs. The journal stores the envelope with `config.auth` reduced to +a non-secret fingerprint (`{ "api_url", "app_url", "org_name", "token_sha256_prefix" }`) +so replay can detect a credential change without persisting the secret; on +replay the live credentials must be re-supplied. + +## Daemon lifecycle + +- **Spawn-on-demand.** The shim connects; when no endpoint is available it + spawns the daemon detached (a separate process group on Unix; a detached + process group on Windows; stdio → log file) using a host-supplied argv + (`[bt, daemon, serve]` when embedded; `[bt-daemon, serve]` standalone), then + retries connect with backoff (~50 × 20 ms). `--no-spawn` turns spawning off + (tests / diagnostics) and makes a missing daemon a hard error. +- **Bind race.** Two shims may spawn simultaneously. The daemon claims the + endpoint exclusively and probes a rival with `initialize`. Unix removes an + unresponsive stale socket before rebinding. Windows uses + `FILE_FLAG_FIRST_PIPE_INSTANCE`; named-pipe names disappear with their last + handle, so it retries the exclusive claim without filesystem cleanup. +- **Idle exit.** The daemon exits after `--idle-timeout` (default 300 s) with + zero active sessions and empty queues. +- **Version handover.** `initialize` compares versions. A newer client sends + `daemon.shutdown`, waits until the endpoint no longer accepts connections, + and spawns its own daemon. In-flight session state is rebuilt from the + journal. + +## Durability & idempotence + +Journal recovery and explicit transcript import are separate operations. +Recovery consumes the daemon's auth-redacted event WAL to rebuild state and +may idempotently re-emit rows under their original deterministic ids. The +`import ` command instead locates the selected +agent's native transcript and creates a trace for that past coding-agent +session by routing synthetic lifecycle events through the regular translator. + +- **Journal (WAL).** Every accepted event is appended (auth-redacted) to + `/journal/.ndjson` before/at enqueue. `data_dir` + defaults to `$XDG_STATE_HOME/braintrust/bt-daemon` or + `$HOME/.braintrust/state/bt-daemon` on Unix, and + `%LOCALAPPDATA%\Braintrust\bt-daemon` on Windows. On restart the daemon + rebuilds a session's unfinished correlation state by applying its journal to + a fresh translator. The resulting rows may be resubmitted to repair delivery + interrupted by a crash, but their deterministic ids target the same backend + rows and must never create duplicate spans. + Journals are GC'd after 7 days. +- **Deterministic span ids.** Translators derive span ids as UUIDv5 over stable + keys (`session_id`, `turn_id`, `call_id`, …) so a replayed re-emit merges + server-side (`_is_merge`) instead of duplicating. diff --git a/bt-daemon/src/client.rs b/bt-daemon/src/client.rs new file mode 100644 index 0000000..0739d61 --- /dev/null +++ b/bt-daemon/src/client.rs @@ -0,0 +1,193 @@ +//! Client side: ensure a daemon is running (spawn detached if not) and do +//! JSON-RPC round-trips over the socket. Used by the `hook` and `status` +//! entry points, and by tests. + +use crate::wire::{Message, Request, RequestId, Response}; +use std::ffi::OsString; +use std::path::Path; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Lines, ReadHalf, WriteHalf}; + +use crate::transport::ClientStream; + +/// Host-specific bits the client needs to (re)launch the daemon: the argv that +/// runs `serve` (e.g. `[bt, daemon, serve]` embedded, `[bt-daemon, serve]` +/// standalone) and the host binary's version string. +#[derive(Debug, Clone)] +pub struct HostInfo { + pub serve_argv: Vec, + pub version: String, +} + +/// A framed JSON-RPC connection with request/response correlation. +pub struct Conn { + reader: Lines>>, + writer: WriteHalf, + next_id: i64, +} + +impl Conn { + pub fn new(stream: ClientStream) -> Self { + let (r, w) = tokio::io::split(stream); + Conn { + reader: BufReader::new(r).lines(), + writer: w, + next_id: 1, + } + } + + /// Send a request and await its matching response (ignoring any interleaved + /// notifications). Returns the `result` value or an error on `error`. + pub async fn request( + &mut self, + method: &str, + params: T, + ) -> anyhow::Result { + let id = self.next_id; + self.next_id += 1; + let req = Request::new(RequestId::Int(id), method, serde_json::to_value(params)?); + self.write(&Message::Request(req)).await?; + + loop { + let line = + self.reader.next_line().await?.ok_or_else(|| { + anyhow::anyhow!("connection closed before response to {method}") + })?; + if let Message::Response(Response { + id: rid, + result, + error, + .. + }) = Message::from_line(&line)? + { + if rid != RequestId::Int(id) { + continue; + } + if let Some(err) = error { + anyhow::bail!("rpc error {} on {method}: {}", err.code, err.message); + } + return Ok(result.unwrap_or(serde_json::Value::Null)); + } + } + } + + async fn write(&mut self, msg: &Message) -> anyhow::Result<()> { + let mut line = msg.to_line()?; + line.push('\n'); + self.writer.write_all(line.as_bytes()).await?; + self.writer.flush().await?; + Ok(()) + } +} + +pub(crate) async fn connect(socket: &Path) -> std::io::Result { + crate::transport::connect(socket).await +} + +/// Connect to the daemon, spawning it (detached) if it isn't up yet. With +/// `no_spawn`, a missing daemon is a hard error (tests / diagnostics). +pub async fn ensure_daemon( + socket: &Path, + host: &HostInfo, + no_spawn: bool, +) -> anyhow::Result { + if let Ok(s) = connect(socket).await { + return Ok(s); + } + if no_spawn { + anyhow::bail!("no daemon at {} and --no-spawn is set", socket.display()); + } + spawn_daemon(host, socket)?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + tokio::time::sleep(Duration::from_millis(20)).await; + if let Ok(s) = connect(socket).await { + return Ok(s); + } + if tokio::time::Instant::now() >= deadline { + break; + } + } + anyhow::bail!("daemon did not come up at {}", socket.display()) +} + +#[cfg(unix)] +fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> { + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + + let (exe, rest) = host + .serve_argv + .split_first() + .ok_or_else(|| anyhow::anyhow!("empty serve_argv"))?; + + let data_dir = crate::paths::data_dir(None); + let _ = crate::paths::ensure_private_dir(&data_dir); + let log = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(data_dir.join("serve.log")) + .ok(); + + let mut cmd = Command::new(exe); + cmd.args(rest); + cmd.arg("--socket").arg(socket); + cmd.stdin(Stdio::null()); + match log { + Some(f) => { + let f2 = f.try_clone()?; + cmd.stdout(Stdio::from(f)); + cmd.stderr(Stdio::from(f2)); + } + None => { + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::null()); + } + } + // Detach into our own process group so the daemon outlives the hook (and + // the agent's) process and its controlling terminal. + cmd.process_group(0); + cmd.spawn()?; + Ok(()) +} + +#[cfg(windows)] +fn spawn_daemon(host: &HostInfo, socket: &Path) -> anyhow::Result<()> { + use std::os::windows::process::CommandExt; + use std::process::{Command, Stdio}; + + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + + let (exe, rest) = host + .serve_argv + .split_first() + .ok_or_else(|| anyhow::anyhow!("empty serve_argv"))?; + + let data_dir = crate::paths::data_dir(None); + let _ = crate::paths::ensure_private_dir(&data_dir); + let log = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(data_dir.join("serve.log")) + .ok(); + + let mut cmd = Command::new(exe); + cmd.args(rest); + cmd.arg("--socket").arg(socket); + cmd.stdin(Stdio::null()); + match log { + Some(file) => { + let stderr = file.try_clone()?; + cmd.stdout(Stdio::from(file)); + cmd.stderr(Stdio::from(stderr)); + } + None => { + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::null()); + } + } + cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP); + cmd.spawn()?; + Ok(()) +} diff --git a/bt-daemon/src/dispatch.rs b/bt-daemon/src/dispatch.rs new file mode 100644 index 0000000..602738c --- /dev/null +++ b/bt-daemon/src/dispatch.rs @@ -0,0 +1,275 @@ +//! Per-session dispatch. Each session owns an ordered queue and a single actor +//! task that runs its translator + sink serially, so events for one session +//! are processed strictly in arrival order. Different sessions run +//! concurrently. +//! +//! Ack semantics: `event.log` is acked once the event is journaled and handed +//! to the session's queue (see [`Session::append_and_enqueue`]). Delivery to +//! Braintrust happens later in the actor; a downstream error never fails the +//! caller's turn. + +use crate::journal::JournalWriter; +use crate::sink::SinkFactory; +use crate::translate::{Registry, SessionCtx}; +use crate::wire::Envelope; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use tokio::sync::{mpsc, oneshot}; + +#[derive(Default)] +pub struct Counters { + pub queued: AtomicU64, + pub spans_emitted: AtomicU64, +} + +enum SessionMsg { + Event(Box), + Flush(oneshot::Sender), + Shutdown(oneshot::Sender<()>), +} + +/// Handle to one live session: its queue plus observable counters/state. +pub struct Session { + pub source: String, + tx: mpsc::UnboundedSender, + journal: tokio::sync::Mutex, + pub counters: Arc, + pub last_error: Arc>>, + pub permalink: Arc>>, +} + +impl Session { + /// Spawn a session's actor task and return its handle. + pub fn spawn( + session_id: String, + source: String, + journal: JournalWriter, + replay: Vec, + translators: Arc, + sink_factory: Arc, + ) -> Arc { + let (tx, rx) = mpsc::unbounded_channel(); + let counters = Arc::new(Counters::default()); + let last_error = Arc::new(Mutex::new(None)); + let permalink = Arc::new(Mutex::new(None)); + + let actor = SessionActor { + session_id: session_id.clone(), + source: source.clone(), + translators, + sink_factory, + counters: counters.clone(), + last_error: last_error.clone(), + permalink: permalink.clone(), + replay, + }; + tokio::spawn(actor.run(rx)); + + Arc::new(Session { + source, + tx, + journal: tokio::sync::Mutex::new(journal), + counters, + last_error, + permalink, + }) + } + + /// Journal (redacted) then enqueue. Both complete before the caller acks. + pub async fn append_and_enqueue(&self, mut env: Envelope) -> anyhow::Result<()> { + hydrate_transcript_snapshot(&mut env).await; + { + let mut j = self.journal.lock().await; + j.append(&env).await?; + } + self.counters.queued.fetch_add(1, Ordering::Relaxed); + self.tx + .send(SessionMsg::Event(Box::new(env))) + .map_err(|_| anyhow::anyhow!("session actor is gone"))?; + Ok(()) + } + + /// Ask the actor to drain and flush its sink, bounded by `timeout`. + /// Returns `(flushed, pending)`. + pub async fn flush(&self, timeout: std::time::Duration) -> (bool, u64) { + let (reply_tx, reply_rx) = oneshot::channel(); + if self.tx.send(SessionMsg::Flush(reply_tx)).is_err() { + return (false, self.counters.queued.load(Ordering::Relaxed)); + } + match tokio::time::timeout(timeout, reply_rx).await { + Ok(Ok(pending)) => (pending == 0, pending), + _ => (false, self.counters.queued.load(Ordering::Relaxed)), + } + } + + /// Drain, flush, and stop the actor (used on daemon shutdown). + pub async fn shutdown(&self) { + let (reply_tx, reply_rx) = oneshot::channel(); + if self.tx.send(SessionMsg::Shutdown(reply_tx)).is_ok() { + let _ = reply_rx.await; + } + } +} + +/// Claude transcript files are external mutable state. Capture them in the +/// journal at lifecycle boundaries so recovery/replay does not depend on a +/// path that Claude may later rewrite or delete. Fail open: a missing file is +/// handled by the translator exactly as before. +async fn hydrate_transcript_snapshot(env: &mut Envelope) { + if env.source != "claude-code" + || !matches!( + env.event.as_str(), + "UserPromptSubmit" | "Stop" | "StopFailure" | "SubagentStop" | "SessionEnd" + ) + { + return; + } + let field = if env.event == "SubagentStop" { + "agent_transcript_path" + } else { + "transcript_path" + }; + let Some(path) = env + .payload + .get(field) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + else { + return; + }; + let Ok(contents) = tokio::fs::read_to_string(&path).await else { + return; + }; + if let Some(payload) = env.payload.as_object_mut() { + payload.insert( + "_bt_transcript_snapshot".to_string(), + serde_json::json!({ "path": path, "contents": contents }), + ); + } +} + +struct SessionActor { + session_id: String, + source: String, + translators: Arc, + sink_factory: Arc, + counters: Arc, + last_error: Arc>>, + permalink: Arc>>, + replay: Vec, +} + +impl SessionActor { + async fn run(self, mut rx: mpsc::UnboundedReceiver) { + let mut translator = self.translators.create(&self.source, &self.session_id); + let mut sink = match self.sink_factory.create(&self.session_id, &self.source) { + Ok(s) => s, + Err(e) => { + self.set_error(format!("sink init failed: {e}")); + // Still drain the queue so the daemon's counters settle and + // callers waiting on flush don't hang. + while let Some(msg) = rx.recv().await { + if let SessionMsg::Event(_) = msg { + self.counters.queued.fetch_sub(1, Ordering::Relaxed); + } else if let SessionMsg::Flush(r) = msg { + let _ = r.send(0); + } else if let SessionMsg::Shutdown(r) = msg { + let _ = r.send(()); + break; + } + } + return; + } + }; + let mut ctx = SessionCtx { + session_id: self.session_id.clone(), + config: None, + }; + // Rebuild translator state before accepting the first new event. Keep + // the deterministic replay ops buffered until live credentials arrive; + // then re-emitting them repairs any rows lost by a prior crash. The + // stable span ids ensure these target existing rows rather than create + // duplicate spans. + let mut replay_ops = Vec::new(); + for env in &self.replay { + if let Some(cfg) = &env.config { + ctx.config = Some(cfg.clone()); + } + match translator.handle(env, &ctx) { + Ok(mut ops) => replay_ops.append(&mut ops), + Err(e) => self.set_error(format!("journal replay failed: {e}")), + } + } + + while let Some(msg) = rx.recv().await { + match msg { + SessionMsg::Event(env) => { + if let Some(cfg) = &env.config { + sink.configure(cfg); + ctx.config = Some(cfg.clone()); + self.refresh_permalink(sink.as_ref()); + } + if !replay_ops.is_empty() { + match sink.emit(&replay_ops).await { + Ok(n) => { + self.counters.spans_emitted.fetch_add(n, Ordering::Relaxed); + replay_ops.clear(); + } + Err(e) => self.set_error(format!("sink replay emit failed: {e}")), + } + } + match translator.handle(&env, &ctx) { + Ok(ops) => match sink.emit(&ops).await { + Ok(n) => { + self.counters.spans_emitted.fetch_add(n, Ordering::Relaxed); + } + Err(e) => self.set_error(format!("sink emit failed: {e}")), + }, + Err(e) => self.set_error(format!("translate failed: {e}")), + } + self.counters.queued.fetch_sub(1, Ordering::Relaxed); + } + SessionMsg::Flush(reply) => { + self.drain_flush(&mut translator, &mut sink, &ctx).await; + let _ = reply.send(self.counters.queued.load(Ordering::Relaxed)); + } + SessionMsg::Shutdown(reply) => { + self.drain_flush(&mut translator, &mut sink, &ctx).await; + let _ = reply.send(()); + break; + } + } + } + } + + async fn drain_flush( + &self, + translator: &mut Box, + sink: &mut Box, + ctx: &SessionCtx, + ) { + match translator.flush(ctx) { + Ok(ops) => { + if let Err(e) = sink.emit(&ops).await { + self.set_error(format!("sink emit (flush) failed: {e}")); + } + } + Err(e) => self.set_error(format!("translate flush failed: {e}")), + } + if let Err(e) = sink.flush().await { + self.set_error(format!("sink flush failed: {e}")); + } + self.refresh_permalink(sink.as_ref()); + } + + fn refresh_permalink(&self, sink: &dyn crate::sink::Sink) { + if let Some(link) = sink.permalink() { + *self.permalink.lock().unwrap() = Some(link); + } + } + + fn set_error(&self, msg: String) { + tracing::warn!(session_id = %self.session_id, "{msg}"); + *self.last_error.lock().unwrap() = Some(msg); + } +} diff --git a/bt-daemon/src/ids.rs b/bt-daemon/src/ids.rs new file mode 100644 index 0000000..6167207 --- /dev/null +++ b/bt-daemon/src/ids.rs @@ -0,0 +1,38 @@ +//! Deterministic span-id derivation. +//! +//! Translators derive span ids as UUIDv5 over stable keys so that replaying a +//! session's journal re-creates the same ids, and the re-emit merges +//! server-side (`_is_merge`) instead of duplicating. The exact string format +//! the Braintrust sink requires is reconciled in the sink layer (some SDK +//! paths want hex span ids); this module is the single place that mints them. + +use uuid::Uuid; + +/// Fixed namespace for all bt-daemon span ids ("btdaemon-span-id-ns" hashed to +/// a v4 uuid, pinned as a constant so it never changes across builds). +const NAMESPACE: Uuid = Uuid::from_u128(0x8f2b_4e11_9c7a_4d3e_b6a1_5f0c_2d84_71ae); + +const SEP: char = '\u{1f}'; // ASCII unit separator; will not appear in ids/keys. + +/// A deterministic span id for `key` within `session_id`. `key` should encode +/// the logical span identity, e.g. `turn:{turn_id}` or `tool:{call_id}`. +pub fn span_id(session_id: &str, key: &str) -> String { + let name = format!("{session_id}{SEP}{key}"); + Uuid::new_v5(&NAMESPACE, name.as_bytes()).to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stable_and_distinct() { + let a1 = span_id("s1", "turn:1"); + let a2 = span_id("s1", "turn:1"); + let b = span_id("s1", "turn:2"); + let c = span_id("s2", "turn:1"); + assert_eq!(a1, a2, "same inputs must be stable across calls"); + assert_ne!(a1, b); + assert_ne!(a1, c); + } +} diff --git a/bt-daemon/src/journal.rs b/bt-daemon/src/journal.rs new file mode 100644 index 0000000..52c7758 --- /dev/null +++ b/bt-daemon/src/journal.rs @@ -0,0 +1,144 @@ +//! Per-session write-ahead journal. Every accepted event is appended +//! (auth-redacted) before the caller is acked, so a restarted daemon can +//! rebuild session state by replaying the journal through the translator. +//! +//! Format: one [`RedactedEnvelope`] JSON value per line in +//! `/journal/.ndjson`. + +use crate::wire::{ + AuthFingerprint, BackendAuth, Envelope, RedactedConfig, RedactedEnvelope, SessionConfig, +}; +use std::path::{Path, PathBuf}; +use tokio::io::AsyncWriteExt; + +pub fn journal_dir(data_dir: &Path) -> PathBuf { + data_dir.join("journal") +} + +fn sanitize(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} + +pub fn journal_path(data_dir: &Path, session_id: &str) -> PathBuf { + journal_dir(data_dir).join(format!("{}.ndjson", sanitize(session_id))) +} + +/// Append-only journal writer for one session. +pub struct JournalWriter { + file: tokio::fs::File, +} + +impl JournalWriter { + pub async fn open(data_dir: &Path, session_id: &str) -> anyhow::Result { + let dir = journal_dir(data_dir); + tokio::fs::create_dir_all(&dir).await?; + let path = journal_path(data_dir, session_id); + let file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .await?; + Ok(Self { file }) + } + + /// Append one event in redacted form and flush to the OS. Not fsync'd per + /// event (that would dominate hook latency); an OS crash can lose the last + /// few lines, which replay tolerates. + pub async fn append(&mut self, env: &Envelope) -> anyhow::Result<()> { + let mut line = serde_json::to_vec(&env.redacted())?; + line.push(b'\n'); + self.file.write_all(&line).await?; + self.file.flush().await?; + Ok(()) + } +} + +/// Read a journal file back into redacted envelopes (for replay/rebuild). +pub async fn read_journal(path: &Path) -> anyhow::Result> { + let data = tokio::fs::read_to_string(path).await?; + let mut out = Vec::new(); + for (i, line) in data.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let env: RedactedEnvelope = serde_json::from_str(line) + .map_err(|e| anyhow::anyhow!("journal {}:{}: {e}", path.display(), i + 1))?; + out.push(env); + } + Ok(out) +} + +/// Best-effort age-based journal collection. A failed stat/remove is logged +/// and ignored; stale state must never prevent the daemon from serving hooks. +pub async fn gc_old_journals(data_dir: &Path, max_age: std::time::Duration) { + let dir = journal_dir(data_dir); + let Ok(mut entries) = tokio::fs::read_dir(&dir).await else { + return; + }; + let now = std::time::SystemTime::now(); + while let Ok(Some(entry)) = entries.next_entry().await { + let path = entry.path(); + if path.extension().and_then(|v| v.to_str()) != Some("ndjson") { + continue; + } + let old = entry + .metadata() + .await + .ok() + .and_then(|m| m.modified().ok()) + .and_then(|modified| now.duration_since(modified).ok()) + .is_some_and(|age| age > max_age); + if old { + if let Err(e) = tokio::fs::remove_file(&path).await { + tracing::warn!(path = %path.display(), "failed to remove stale journal: {e}"); + } + } + } +} + +/// Reconstruct a translator-usable [`Envelope`] from a redacted journal entry. +/// The live token is gone (redacted), so `auth.token` is empty — fine for +/// rebuilding translator state; the sink must be re-supplied live credentials +/// if replay needs to actually deliver. +pub fn envelope_from_redacted(r: RedactedEnvelope) -> Envelope { + Envelope { + source: r.source, + source_version: r.source_version, + session_id: r.session_id, + event: r.event, + ts_ms: r.ts_ms, + payload: r.payload, + config: r.config.map(config_from_redacted), + } +} + +fn config_from_redacted(c: RedactedConfig) -> SessionConfig { + let AuthFingerprint { + api_url, + app_url, + org_name, + .. + } = c.auth; + SessionConfig { + auth: BackendAuth { + token: String::new(), + api_url, + app_url, + org_name, + org_id: None, + }, + project: c.project, + parent_span_id: c.parent_span_id, + root_span_id: c.root_span_id, + flush_mode: c.flush_mode, + additional_metadata: c.additional_metadata, + } +} diff --git a/bt-daemon/src/lib.rs b/bt-daemon/src/lib.rs new file mode 100644 index 0000000..446075d --- /dev/null +++ b/bt-daemon/src/lib.rs @@ -0,0 +1,503 @@ +//! bt-daemon: the embeddable library behind the Braintrust coding-agent +//! tracing daemon. Two front-ends consume it (see `../DESIGN.md`): +//! * `bt` wires the [`clap::Args`] structs into its command tree and fills +//! [`wire::SessionConfig`] from its own auth resolution. +//! * the feature-gated standalone `bt-daemon` binary does the same with +//! env/flag token auth only, for isolated testing. +//! +//! The core is credential-passive: it only ever *receives* a resolved +//! [`wire::BackendAuth`] with the session config, so both front-ends share all +//! core behavior. The `cli` feature only gates the standalone binary and its +//! logging subscriber. + +pub mod paths; + +mod client; +mod dispatch; +mod ids; +mod journal; +mod server; +mod settings; +mod sink; +mod transcript_import; +mod translate; +mod transport; + +pub mod wire; +pub use client::HostInfo; +pub use server::ServeOptions; +pub use sink::{BraintrustSinkConfig, BraintrustSinkFactory, DebugSinkFactory, Sink, SinkFactory}; +pub use translate::{ + AgentTranslator, Registry, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory, +}; + +use clap::{Args, ValueEnum}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use wire::{method, Envelope, SessionConfig, StatusResult, PROTOCOL_VERSION}; + +/// Arguments for `serve`. +#[derive(Debug, Clone, Args)] +pub struct ServeArgs { + /// Socket path override (default: see docs/protocol.md). + #[arg(long)] + pub socket: Option, + /// Data/journal directory override. + #[arg(long)] + pub data_dir: Option, + /// Exit after this many seconds idle (no activity, empty queues). 0 + /// disables the watchdog. + #[arg(long, default_value_t = 300)] + pub idle_timeout_secs: u64, +} + +/// Arguments for `hook`. +#[derive(Debug, Clone, Args)] +pub struct HookArgs { + /// Which translator should interpret this event's payload. + #[arg(long)] + pub source: String, + /// Optional agent version, forwarded for payload-drift handling. + #[arg(long)] + pub source_version: Option, + /// Socket path override. + #[arg(long)] + pub socket: Option, + /// JSON field in the payload holding the session id. + #[arg(long, default_value = "session_id")] + pub session_id_field: String, + /// JSON field in the payload holding the event name. + #[arg(long, default_value = "hook_event_name")] + pub event_field: String, + /// Explicit event name (overrides `--event-field` lookup). + #[arg(long)] + pub event: Option, + /// Fail instead of spawning a daemon if none is running. + #[arg(long)] + pub no_spawn: bool, + /// Flush the session after a turn-ending event. Intended for short-lived + /// CI hosts; SessionEnd is always flushed. + #[arg(long)] + pub flush_on_turn_end: bool, + /// Bound an explicit turn/session-end flush. + #[arg(long, default_value_t = 10_000)] + pub flush_timeout_ms: u64, + /// Attach the agent session below an existing Braintrust span. + #[arg(long)] + pub parent_span_id: Option, + /// Existing trace root when attaching below a non-root parent. + #[arg(long)] + pub root_span_id: Option, + /// JSON object merged into root-span metadata. + #[arg(long)] + pub additional_metadata: Option, + /// Route spans to an existing Braintrust experiment instead of project + /// logs. The Claude shim supplies this from CC_EXPERIMENT_ID. + #[arg(long)] + pub experiment_id: Option, +} + +/// Arguments for `status`. +#[derive(Debug, Clone, Args)] +pub struct StatusArgs { + #[arg(long)] + pub socket: Option, + /// Limit to one session. + #[arg(long)] + pub session_id: Option, +} + +/// Arguments for importing a past coding-agent session. +#[derive(Debug, Clone, Args)] +pub struct ImportArgs { + /// Agent that produced the session. + #[arg(value_enum)] + pub source: ImportSource, + /// Codex or Claude Code session id shown by the agent's resume command. + pub session_id: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum ImportSource { + Codex, + #[value(name = "claude", alias = "claude-code")] + Claude, +} + +/// Run the daemon until shutdown. +pub async fn run_serve(args: ServeArgs, opts: ServeOptions) -> anyhow::Result<()> { + server::run(args, opts).await +} + +/// Capture one hook event from `stdin` and forward it to the daemon. +/// +/// `config` is the caller-resolved session config (auth + trace settings). +/// Returns `Ok` once the daemon has acked (journaled + enqueued). Callers that +/// must never fail the agent's turn should treat any `Err` as non-fatal and +/// exit 0. +pub async fn run_hook( + args: HookArgs, + mut config: SessionConfig, + host: HostInfo, +) -> anyhow::Result<()> { + let settings = settings::SharedSettings::load(); + if !settings.tracing_enabled() { + return Ok(()); + } + let payload = read_stdin_json()?; + + let session_id = json_str_field(&payload, &args.session_id_field) + .ok_or_else(|| anyhow::anyhow!("no `{}` field in hook payload", args.session_id_field))?; + let event = args + .event + .clone() + .or_else(|| json_str_field(&payload, &args.event_field)) + .unwrap_or_default(); + + if let Some(project) = settings.project.filter(|project| !project.is_empty()) { + config.project = Some(project); + } + match settings.flush_on_turn_end { + Some(true) => config.flush_mode = wire::FlushMode::FlushOnTurnEnd, + Some(false) => config.flush_mode = wire::FlushMode::FireAndForget, + None if args.flush_on_turn_end => config.flush_mode = wire::FlushMode::FlushOnTurnEnd, + None => {} + } + if args.parent_span_id.is_some() { + config.parent_span_id = args.parent_span_id.clone(); + } + if args.root_span_id.is_some() { + config.root_span_id = args.root_span_id.clone(); + } + match (config.parent_span_id.clone(), config.root_span_id.clone()) { + (Some(parent), None) => config.root_span_id = Some(parent), + (None, Some(root)) => config.parent_span_id = Some(root), + _ => {} + } + if let Some(metadata) = settings.additional_metadata { + config.additional_metadata = Some(serde_json::Value::Object(metadata)); + } else if let Some(metadata) = &args.additional_metadata { + let value: serde_json::Value = serde_json::from_str(metadata) + .map_err(|e| anyhow::anyhow!("invalid --additional-metadata JSON: {e}"))?; + if !value.is_object() { + anyhow::bail!("--additional-metadata must be a JSON object"); + } + config.additional_metadata = Some(value); + } + if let Some(experiment_id) = &args.experiment_id { + let mut metadata = config + .additional_metadata + .take() + .and_then(|value| value.as_object().cloned()) + .unwrap_or_default(); + metadata.insert( + "_bt_experiment_id".to_string(), + serde_json::Value::String(experiment_id.clone()), + ); + config.additional_metadata = Some(serde_json::Value::Object(metadata)); + } + let env = Envelope { + source: args.source.clone(), + source_version: args.source_version.clone(), + session_id, + event, + ts_ms: now_ms(), + payload, + config: Some(config), + }; + + let socket = paths::socket_path(args.socket.as_deref()); + forward_envelope(&env, &socket, &host, args.no_spawn).await?; + let should_flush = env.event == "SessionEnd" + || (matches!( + env.config.as_ref().map(|c| c.flush_mode), + Some(wire::FlushMode::FlushOnTurnEnd) + ) && matches!(env.event.as_str(), "Stop" | "SubagentStop")); + if should_flush { + flush_session(&env.session_id, &socket, args.flush_timeout_ms).await?; + } + Ok(()) +} + +/// Ensure a daemon is up and forward one already-built [`Envelope`] to it +/// (`initialize` handshake + `event.log`). Also the seam in-process clients and +/// tests use to send events without going through stdin. +pub async fn forward_envelope( + env: &Envelope, + socket: &std::path::Path, + host: &HostInfo, + no_spawn: bool, +) -> anyhow::Result<()> { + let stream = client::ensure_daemon(socket, host, no_spawn).await?; + let mut conn = client::Conn::new(stream); + let initialized = conn + .request( + method::INITIALIZE, + serde_json::json!({ + "protocol_version": PROTOCOL_VERSION, + "client": { + "source": env.source, + "plugin_version": env.source_version, + "pid": std::process::id() + } + }), + ) + .await?; + let initialized: wire::InitializeResult = serde_json::from_value(initialized)?; + if initialized.daemon_version != host.version { + if no_spawn { + anyhow::bail!( + "daemon version {} does not match client {} and --no-spawn is set", + initialized.daemon_version, + host.version + ); + } + conn.request(method::DAEMON_SHUTDOWN, serde_json::json!({})) + .await?; + drop(conn); + for _ in 0..100 { + if client::connect(socket).await.is_err() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + let stream = client::ensure_daemon(socket, host, false).await?; + conn = client::Conn::new(stream); + conn.request( + method::INITIALIZE, + serde_json::json!({ + "protocol_version": PROTOCOL_VERSION, + "client": { + "source": env.source, + "plugin_version": env.source_version, + "pid": std::process::id() + } + }), + ) + .await?; + } + conn.request(method::EVENT_LOG, env).await?; + Ok(()) +} + +/// Ask the daemon to flush a session, bounded by `timeout_ms`. A reliable +/// barrier: on return, every event enqueued before the call has been processed +/// and its spans emitted to the sink. +pub async fn flush_session( + session_id: &str, + socket: &std::path::Path, + timeout_ms: u64, +) -> anyhow::Result { + let stream = client::connect(socket).await?; + let mut conn = client::Conn::new(stream); + conn.request( + method::INITIALIZE, + serde_json::json!({ "protocol_version": PROTOCOL_VERSION, "client": { "source": "flush" } }), + ) + .await?; + let params = wire::FlushParams { + session_id: session_id.to_string(), + timeout_ms, + }; + let value = conn.request(method::SESSION_FLUSH, params).await?; + Ok(serde_json::from_value(value)?) +} + +/// Query daemon status. `Ok(None)` means no daemon is running. +pub async fn run_status(args: StatusArgs) -> anyhow::Result> { + let socket = paths::socket_path(args.socket.as_deref()); + let stream = match client::connect(&socket).await { + Ok(s) => s, + Err(_) => return Ok(None), + }; + let mut conn = client::Conn::new(stream); + conn.request( + method::INITIALIZE, + serde_json::json!({ + "protocol_version": PROTOCOL_VERSION, + "client": { "source": "status" } + }), + ) + .await?; + let params = wire::StatusParams { + session_id: args.session_id.clone(), + }; + let value = conn.request(method::STATUS_GET, params).await?; + Ok(Some(serde_json::from_value(value)?)) +} + +/// Request a graceful daemon shutdown. Primarily useful for lifecycle +/// management and transport integration tests. +pub async fn shutdown_daemon(socket: &std::path::Path) -> anyhow::Result<()> { + let stream = client::connect(socket).await?; + let mut conn = client::Conn::new(stream); + conn.request(method::DAEMON_SHUTDOWN, serde_json::json!({})) + .await?; + Ok(()) +} + +/// Import a native coding-agent transcript through the normal translators and +/// sink. This is separate from daemon journal recovery: import creates traces +/// for a past session, while recovery rebuilds live correlation state. +pub async fn run_import( + args: ImportArgs, + opts: ServeOptions, + config: Option, +) -> anyhow::Result<()> { + let file = transcript_import::resolve_transcript(&args.session_id, args.source)?; + import_transcript(&file, args.source, opts, config).await +} + +/// Import a native transcript from a known path. Front-ends should normally +/// expose [`run_import`] so users only need the agent's session id; this lower- +/// level entry point is useful for embedding and isolated tests. +pub async fn import_transcript( + file: &std::path::Path, + source: ImportSource, + opts: ServeOptions, + config: Option, +) -> anyhow::Result<()> { + use std::collections::HashMap; + let entries = transcript_import::transcript_envelopes(file, source)?; + + struct Live { + translator: Box, + sink: Box, + ctx: SessionCtx, + pending_ops: usize, + } + let mut sessions: HashMap = HashMap::new(); + + for mut env in entries { + env.config = config.clone(); + let sid = env.session_id.clone(); + let live = match sessions.get_mut(&sid) { + Some(l) => l, + None => { + let translator = opts.translators.create(&env.source, &sid); + let sink = opts.sink_factory.create(&sid, &env.source)?; + sessions.insert( + sid.clone(), + Live { + translator, + sink, + ctx: SessionCtx { + session_id: sid.clone(), + config: None, + }, + pending_ops: 0, + }, + ); + sessions.get_mut(&sid).unwrap() + } + }; + if let Some(cfg) = &env.config { + live.sink.configure(cfg); + live.ctx.config = Some(cfg.clone()); + } + let ops = live.translator.handle(&env, &live.ctx)?; + // Imports can contain tens of thousands of SDK log commands. Bound the + // number queued between drains without serializing one network flush + // for every native turn boundary. + const FLUSH_OPS: usize = 500; + for chunk in ops.chunks(FLUSH_OPS) { + live.sink.emit(chunk).await?; + live.pending_ops += chunk.len(); + if live.pending_ops >= FLUSH_OPS { + live.sink.flush().await?; + live.pending_ops = 0; + } + } + } + + for (_sid, mut live) in sessions { + let ops = live.translator.flush(&live.ctx)?; + live.sink.emit(&ops).await?; + live.sink.flush().await?; + } + Ok(()) +} + +/// Build a Phase-1 debug [`ServeOptions`]: debug translator registry + a debug +/// sink writing NDJSON under `/spans/`. +pub fn debug_serve_options(version: impl Into, data_dir: &std::path::Path) -> ServeOptions { + ServeOptions { + version: version.into(), + translators: Arc::new(Registry::debug_only()), + sink_factory: Arc::new(DebugSinkFactory { + dir: data_dir.join("spans"), + }), + } +} + +/// Build [`ServeOptions`] with the Braintrust sink. `translators` lets the +/// caller choose the translator registry (debug-only until Phase 3 adds the +/// Codex/Claude translators). Clients are built lazily per session URL, so this +/// is cheap and infallible. +pub fn braintrust_serve_options( + version: impl Into, + sink_config: BraintrustSinkConfig, + translators: Arc, +) -> ServeOptions { + ServeOptions { + version: version.into(), + translators, + sink_factory: Arc::new(BraintrustSinkFactory::new(sink_config)), + } +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn read_stdin_json() -> anyhow::Result { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + if buf.trim().is_empty() { + anyhow::bail!("empty stdin (expected a JSON hook payload)"); + } + Ok(serde_json::from_str(&buf)?) +} + +/// Read a string-ish field (`session_id` / event name) from the payload, +/// coercing a JSON number to its string form. +fn json_str_field(payload: &serde_json::Value, field: &str) -> Option { + match payload.get(field) { + Some(serde_json::Value::String(s)) => Some(s.clone()), + Some(serde_json::Value::Number(n)) => Some(n.to_string()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_string_fields_accept_strings_and_numbers_only() { + let payload = serde_json::json!({ + "string": "session", + "number": 42, + "boolean": true, + "null": null + }); + assert_eq!( + json_str_field(&payload, "string").as_deref(), + Some("session") + ); + assert_eq!(json_str_field(&payload, "number").as_deref(), Some("42")); + assert_eq!(json_str_field(&payload, "boolean"), None); + assert_eq!(json_str_field(&payload, "null"), None); + assert_eq!(json_str_field(&payload, "missing"), None); + } + + #[test] + fn clock_returns_a_positive_epoch_timestamp() { + assert!(now_ms() > 0); + } +} diff --git a/bt-daemon/src/main.rs b/bt-daemon/src/main.rs index 422ff81..8fbd8b0 100644 --- a/bt-daemon/src/main.rs +++ b/bt-daemon/src/main.rs @@ -1,8 +1,183 @@ -// bt-daemon — placeholder entrypoint. -// -// This will grow into the shared daemon / event server that the coding-agent -// plugins talk to. For now it just proves the crate builds. +//! Standalone `bt-daemon` binary — the isolated-testing front-end over the +//! `bt-daemon` library. Built only with the `cli` feature. Auth is env/flag +//! static-token only; there are no +//! profiles, OAuth, or keychain here (that lives in `bt`). See +//! the crate README's "Dual consumption" section. -fn main() { - println!("bt-daemon: placeholder"); +use bt_daemon::wire::{BackendAuth, FlushMode, SessionConfig}; +use bt_daemon::{ + braintrust_serve_options, paths, run_hook, run_import, run_serve, run_status, + BraintrustSinkConfig, DebugSinkFactory, HookArgs, HostInfo, ImportArgs, Registry, ServeArgs, + ServeOptions, StatusArgs, +}; +use clap::{Args, Parser, Subcommand}; +use std::ffi::OsString; +use std::sync::Arc; + +/// A debug-sink [`ServeOptions`] with all real agent translators registered +/// (Braintrust delivery off — NDJSON to `/spans/`). +fn debug_serve_options(version: &str, data_dir: &std::path::Path) -> ServeOptions { + ServeOptions { + version: version.to_string(), + translators: Arc::new(Registry::default_agents()), + sink_factory: Arc::new(DebugSinkFactory { + dir: data_dir.join("spans"), + }), + } +} + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Parser)] +#[command( + name = "bt-daemon", + version, + about = "Braintrust coding-agent tracing daemon (standalone test binary)" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +// Clap parse structs; sizes are irrelevant for a one-shot CLI dispatch. +#[allow(clippy::large_enum_variant)] +enum Command { + /// Run the daemon (foreground). + Serve { + #[command(flatten)] + args: ServeArgs, + /// Use the debug sink (NDJSON to disk) instead of sending to Braintrust. + /// For offline isolated testing. + #[arg(long)] + debug_sink: bool, + /// Braintrust API URL for the sink (default: SDK default). + #[arg(long, env = "BRAINTRUST_API_URL")] + api_url: Option, + /// Braintrust app URL for the sink (default: SDK default). + #[arg(long, env = "BRAINTRUST_APP_URL")] + app_url: Option, + }, + /// Forward one hook event (read from stdin) to the daemon. + Hook { + #[command(flatten)] + args: HookArgs, + #[command(flatten)] + auth: AuthArgs, + }, + /// Print daemon/session status. + Status(StatusArgs), + /// Import a past Codex or Claude Code session by its resume id. + Import(ImportArgs), +} + +/// Static-token backend auth from env/flags (no profile resolution). +#[derive(Args)] +struct AuthArgs { + #[arg(long, env = "BRAINTRUST_API_KEY")] + api_key: Option, + #[arg(long, env = "BRAINTRUST_API_URL")] + api_url: Option, + #[arg(long, env = "BRAINTRUST_APP_URL")] + app_url: Option, + #[arg(long = "org", env = "BRAINTRUST_ORG_NAME")] + org_name: Option, + #[arg(long = "org-id", env = "BRAINTRUST_ORG_ID")] + org_id: Option, + #[arg(long, env = "BRAINTRUST_PROJECT")] + project: Option, +} + +impl AuthArgs { + fn into_config(self) -> SessionConfig { + SessionConfig { + auth: BackendAuth { + token: self.api_key.unwrap_or_default(), + api_url: self.api_url, + app_url: self.app_url, + org_name: self.org_name, + org_id: self.org_id, + }, + project: self.project, + parent_span_id: None, + root_span_id: None, + flush_mode: FlushMode::FireAndForget, + additional_metadata: None, + } + } +} + +fn host_info() -> HostInfo { + let exe = std::env::current_exe() + .map(OsString::from) + .unwrap_or_else(|_| OsString::from("bt-daemon")); + HostInfo { + serve_argv: vec![exe, OsString::from("serve")], + version: VERSION.to_string(), + } +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + match cli.command { + Command::Serve { + args, + debug_sink, + api_url, + app_url, + } => { + let data_dir = paths::data_dir(args.data_dir.as_deref()); + let opts = if debug_sink { + debug_serve_options(VERSION, &data_dir) + } else { + let cfg = BraintrustSinkConfig { + api_url, + app_url, + version: VERSION.to_string(), + }; + braintrust_serve_options(VERSION, cfg, Arc::new(Registry::default_agents())) + }; + if let Err(e) = run_serve(args, opts).await { + eprintln!("bt-daemon serve: {e}"); + std::process::exit(1); + } + } + Command::Hook { args, auth } => { + // A hook must NEVER fail the agent's turn: log and exit 0 on error. + let config = auth.into_config(); + if let Err(e) = run_hook(args, config, host_info()).await { + eprintln!("bt-daemon hook (non-fatal): {e}"); + } + std::process::exit(0); + } + Command::Status(args) => match run_status(args).await { + Ok(Some(status)) => { + println!("{}", serde_json::to_string_pretty(&status).unwrap()); + } + Ok(None) => { + println!("bt-daemon is not running"); + } + Err(e) => { + eprintln!("bt-daemon status: {e}"); + std::process::exit(1); + } + }, + Command::Import(args) => { + let data_dir = paths::data_dir(None); + let opts = debug_serve_options(VERSION, &data_dir); + if let Err(e) = run_import(args, opts, None).await { + eprintln!("bt-daemon import: {e}"); + std::process::exit(1); + } + } + } } diff --git a/bt-daemon/src/paths.rs b/bt-daemon/src/paths.rs new file mode 100644 index 0000000..686e2b6 --- /dev/null +++ b/bt-daemon/src/paths.rs @@ -0,0 +1,135 @@ +//! Socket and data-directory resolution. Both `serve` and `hook` must agree on +//! the defaults, so the logic lives here. See `docs/protocol.md`. + +use std::path::{Path, PathBuf}; + +/// Env override for the socket path (also settable via `--socket`). +pub const SOCKET_ENV: &str = "BT_DAEMON_SOCKET"; +/// Env override for the data/journal directory. +pub const DATA_DIR_ENV: &str = "BT_DAEMON_DATA_DIR"; +/// Env override for the shared non-credential daemon settings file. +pub const SETTINGS_ENV: &str = "BT_DAEMON_CONFIG"; + +fn home() -> PathBuf { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// Resolve the socket path: explicit `override` → `$BT_DAEMON_SOCKET` → +/// `$XDG_RUNTIME_DIR/braintrust/daemon.sock` → `~/.braintrust/run/daemon.sock`. +pub fn socket_path(explicit: Option<&Path>) -> PathBuf { + if let Some(p) = explicit { + return p.to_path_buf(); + } + if let Some(p) = std::env::var_os(SOCKET_ENV) { + return PathBuf::from(p); + } + #[cfg(windows)] + { + use sha2::{Digest, Sha256}; + let identity = format!( + "{}\\{}", + std::env::var("USERDOMAIN").unwrap_or_default(), + std::env::var("USERNAME").unwrap_or_default() + ); + let digest = Sha256::digest(identity.as_bytes()); + let suffix: String = digest[..8] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + PathBuf::from(format!(r"\\.\pipe\braintrust-bt-daemon-{suffix}")) + } + #[cfg(unix)] + if let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") { + if !rt.is_empty() { + return PathBuf::from(rt).join("braintrust").join("daemon.sock"); + } + } + #[cfg(unix)] + { + home().join(".braintrust").join("run").join("daemon.sock") + } +} + +/// Resolve the data dir: explicit `override` → `$BT_DAEMON_DATA_DIR` → +/// `$XDG_STATE_HOME/braintrust/bt-daemon` → `~/.braintrust/state/bt-daemon`. +pub fn data_dir(explicit: Option<&Path>) -> PathBuf { + if let Some(p) = explicit { + return p.to_path_buf(); + } + if let Some(p) = std::env::var_os(DATA_DIR_ENV) { + return PathBuf::from(p); + } + #[cfg(windows)] + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + if !local.is_empty() { + return PathBuf::from(local).join("Braintrust").join("bt-daemon"); + } + } + #[cfg(unix)] + if let Some(s) = std::env::var_os("XDG_STATE_HOME") { + if !s.is_empty() { + return PathBuf::from(s).join("braintrust").join("bt-daemon"); + } + } + home().join(".braintrust").join("state").join("bt-daemon") +} + +/// Resolve the shared settings file: explicit override → `$BT_DAEMON_CONFIG` +/// → `/config.json`. +pub fn settings_path(explicit: Option<&Path>) -> PathBuf { + if let Some(path) = explicit { + return path.to_path_buf(); + } + std::env::var_os(SETTINGS_ENV) + .map(PathBuf::from) + .unwrap_or_else(|| data_dir(None).join("config.json")) +} + +/// Create `dir` (and parents) mode 0700 on unix. +pub fn ensure_private_dir(dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o700); + std::fs::set_permissions(dir, perms)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_paths_take_precedence() { + let socket = Path::new("custom-endpoint"); + let data = Path::new("custom-data"); + assert_eq!(socket_path(Some(socket)), socket); + assert_eq!(data_dir(Some(data)), data); + assert_eq!( + settings_path(Some(Path::new("config.json"))), + Path::new("config.json") + ); + } + + #[test] + fn private_directory_is_created() { + let temp = tempfile::tempdir().unwrap(); + let nested = temp.path().join("one/two"); + ensure_private_dir(&nested).unwrap(); + assert!(nested.is_dir()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&nested).unwrap().permissions().mode() & 0o777, + 0o700 + ); + } + } +} diff --git a/bt-daemon/src/server.rs b/bt-daemon/src/server.rs new file mode 100644 index 0000000..d15ab88 --- /dev/null +++ b/bt-daemon/src/server.rs @@ -0,0 +1,453 @@ +//! The daemon: owns the session map + shared deps, binds the UDS listener, +//! serves JSON-RPC connections, and shuts down gracefully (idle timeout, +//! `daemon.shutdown`, or SIGINT/SIGTERM). + +use crate::dispatch::Session; +use crate::journal::{self, JournalWriter}; +use crate::sink::SinkFactory; +use crate::translate::Registry; +use crate::transport::{self, Listener, ServerStream}; +use crate::wire::{ + error_code, method, Capabilities, Envelope, EventLogResult, FlushParams, FlushResult, + InitializeParams, InitializeResult, Message, Request, Response, RpcError, SessionStatus, + ShutdownResult, StatusParams, StatusResult, PROTOCOL_VERSION, +}; +use crate::{paths, ServeArgs}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::sync::Notify; + +/// Injected dependencies for `serve`, so `bt` / tests can supply a sink +/// factory (Braintrust in production, debug in tests) and a version string. +pub struct ServeOptions { + pub version: String, + pub translators: Arc, + pub sink_factory: Arc, +} + +pub struct Daemon { + version: String, + data_dir: PathBuf, + translators: Arc, + sink_factory: Arc, + sessions: Mutex>>, + started: Instant, + last_activity: Mutex, + shutting_down: AtomicBool, + shutdown: Notify, +} + +impl Daemon { + fn new(opts: ServeOptions, data_dir: PathBuf) -> Arc { + Arc::new(Daemon { + version: opts.version, + data_dir, + translators: opts.translators, + sink_factory: opts.sink_factory, + sessions: Mutex::new(HashMap::new()), + started: Instant::now(), + last_activity: Mutex::new(Instant::now()), + shutting_down: AtomicBool::new(false), + shutdown: Notify::new(), + }) + } + + fn touch(&self) { + *self.last_activity.lock().unwrap() = Instant::now(); + } + + async fn session_for(&self, env: &Envelope) -> anyhow::Result> { + { + let map = self.sessions.lock().unwrap(); + if let Some(s) = map.get(&env.session_id) { + return Ok(s.clone()); + } + } + // Open the journal outside the lock (async I/O), then insert under it, + // resolving a race where two connections create the same session. + let replay = + match journal::read_journal(&journal::journal_path(&self.data_dir, &env.session_id)) + .await + { + Ok(entries) => entries + .into_iter() + .map(journal::envelope_from_redacted) + .collect(), + Err(e) + if e.downcast_ref::() + .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound) => + { + Vec::new() + } + Err(e) => { + tracing::warn!(session_id = %env.session_id, "journal replay skipped: {e}"); + Vec::new() + } + }; + let journal = JournalWriter::open(&self.data_dir, &env.session_id).await?; + let mut map = self.sessions.lock().unwrap(); + if let Some(s) = map.get(&env.session_id) { + return Ok(s.clone()); + } + let session = Session::spawn( + env.session_id.clone(), + env.source.clone(), + journal, + replay, + self.translators.clone(), + self.sink_factory.clone(), + ); + map.insert(env.session_id.clone(), session.clone()); + Ok(session) + } + + fn total_queued(&self) -> u64 { + self.sessions + .lock() + .unwrap() + .values() + .map(|s| s.counters.queued.load(Ordering::Relaxed)) + .sum() + } + + fn trigger_shutdown(&self) { + self.shutting_down.store(true, Ordering::SeqCst); + self.shutdown.notify_waiters(); + } +} + +/// Bind the socket (handling a stale/rival socket), serve until shutdown, then +/// drain sessions and remove the socket. +pub async fn run(args: ServeArgs, opts: ServeOptions) -> anyhow::Result<()> { + let socket = paths::socket_path(args.socket.as_deref()); + let data_dir = paths::data_dir(args.data_dir.as_deref()); + paths::ensure_private_dir(&data_dir)?; + #[cfg(unix)] + if let Some(parent) = socket.parent() { + paths::ensure_private_dir(parent)?; + } + + let listener = match transport::claim(&socket, || probe_alive(&socket)).await? { + Some(l) => l, + None => { + tracing::info!( + "another daemon is already serving {}; exiting", + socket.display() + ); + return Ok(()); + } + }; + tracing::info!(socket = %socket.display(), "bt-daemon listening"); + + let daemon = Daemon::new(opts, data_dir); + journal::gc_old_journals(&daemon.data_dir, Duration::from_secs(7 * 24 * 60 * 60)).await; + let idle_timeout = Duration::from_secs(args.idle_timeout_secs); + spawn_idle_watchdog(daemon.clone(), idle_timeout); + + let accept_result = accept_loop(daemon.clone(), listener).await; + + // Graceful drain regardless of why we stopped. + drain_all(&daemon).await; + transport::cleanup(&socket); + accept_result +} + +async fn accept_loop(daemon: Arc, mut listener: Listener) -> anyhow::Result<()> { + loop { + tokio::select! { + _ = daemon.shutdown.notified() => { + tracing::info!("shutdown requested"); + return Ok(()); + } + _ = tokio::signal::ctrl_c() => { + tracing::info!("interrupt received"); + return Ok(()); + } + accepted = listener.accept() => { + match accepted { + Ok(stream) => { + let d = daemon.clone(); + tokio::spawn(async move { + if let Err(e) = serve_connection(d, stream).await { + tracing::debug!("connection ended: {e}"); + } + }); + } + Err(e) => { + tracing::warn!("accept error: {e}"); + } + } + } + } + } +} + +async fn serve_connection(daemon: Arc, stream: ServerStream) -> anyhow::Result<()> { + let (read_half, mut write_half) = tokio::io::split(stream); + let mut lines = BufReader::new(read_half).lines(); + + while let Some(line) = lines.next_line().await? { + if line.trim().is_empty() { + continue; + } + let response = match Message::from_line(&line) { + Ok(Message::Request(req)) => { + let request_id = req.id.clone(); + let method = req.method.clone(); + tracing::info!( + request_id = ?request_id, + method, + "request received" + ); + let response = handle_request(&daemon, req).await; + if let Some(error) = &response.error { + tracing::warn!( + request_id = ?request_id, + method, + error_code = error.code, + error = %error.message, + "request failed" + ); + } else { + tracing::info!( + request_id = ?request_id, + method, + "request completed" + ); + } + Some(response) + } + Ok(Message::Notification(note)) => { + tracing::info!(method = %note.method, "notification received"); + // Hot-path notifications (in-process clients): process, no reply. + if note.method == method::EVENT_LOG { + if let Some(params) = note.params { + match serde_json::from_value::(params) { + Ok(env) => { + let _ = accept_event(&daemon, env).await; + } + Err(error) => tracing::warn!( + method = %note.method, + error = %error, + "notification parameters rejected" + ), + } + } + } + None + } + Ok(Message::Response(_)) => None, // clients don't send us responses + Err(e) => { + tracing::warn!(error = %e, "request parse failed"); + Some(Response::err( + crate::wire::RequestId::Int(0), + RpcError::new(error_code::PARSE, format!("parse error: {e}")), + )) + } + }; + + if let Some(resp) = response { + let mut buf = Message::Response(resp).to_line()?; + buf.push('\n'); + write_half.write_all(buf.as_bytes()).await?; + write_half.flush().await?; + } + } + Ok(()) +} + +async fn accept_event(daemon: &Arc, env: Envelope) -> Result<(), String> { + let source = env.source.clone(); + let event = env.event.clone(); + let session_id = env.session_id.clone(); + tracing::info!(source, event, session_id, "event received"); + daemon.touch(); + + let result = async { + let session = daemon + .session_for(&env) + .await + .map_err(|error| format!("session init failed: {error}"))?; + session + .append_and_enqueue(env) + .await + .map_err(|error| format!("enqueue failed: {error}")) + } + .await; + + match &result { + Ok(()) => tracing::info!(source, event, session_id, "event accepted"), + Err(error) => tracing::warn!(source, event, session_id, error, "event rejected"), + } + result +} + +async fn handle_request(daemon: &Arc, req: Request) -> Response { + let id = req.id.clone(); + let params = req.params.unwrap_or(serde_json::Value::Null); + + macro_rules! parse { + ($t:ty) => { + match serde_json::from_value::<$t>(params) { + Ok(v) => v, + Err(e) => { + return Response::err( + id, + RpcError::new(error_code::INVALID_PARAMS, format!("invalid params: {e}")), + ) + } + } + }; + } + + match req.method.as_str() { + method::INITIALIZE => { + let p = parse!(InitializeParams); + if p.protocol_version != PROTOCOL_VERSION { + return Response::err( + id, + RpcError::new( + error_code::APP, + format!( + "protocol version mismatch: client {} daemon {}", + p.protocol_version, PROTOCOL_VERSION + ), + ), + ); + } + let result = InitializeResult { + protocol_version: PROTOCOL_VERSION, + daemon_version: daemon.version.clone(), + capabilities: Capabilities { + sources: daemon.translators.sources(), + }, + }; + Response::ok(id, serde_json::to_value(result).unwrap()) + } + method::EVENT_LOG => { + let env = parse!(Envelope); + match accept_event(daemon, env).await { + Ok(()) => Response::ok( + id, + serde_json::to_value(EventLogResult { accepted: true }).unwrap(), + ), + Err(error) => Response::err(id, RpcError::new(error_code::INTERNAL, error)), + } + } + method::SESSION_FLUSH => { + let p = parse!(FlushParams); + let session = { daemon.sessions.lock().unwrap().get(&p.session_id).cloned() }; + let (flushed, pending) = match session { + Some(s) => s.flush(Duration::from_millis(p.timeout_ms)).await, + None => (true, 0), + }; + Response::ok( + id, + serde_json::to_value(FlushResult { flushed, pending }).unwrap(), + ) + } + method::STATUS_GET => { + let p = parse!(StatusParams); + Response::ok(id, serde_json::to_value(daemon.status(p)).unwrap()) + } + method::DAEMON_SHUTDOWN => { + let resp = Response::ok( + id, + serde_json::to_value(ShutdownResult { ok: true }).unwrap(), + ); + daemon.trigger_shutdown(); + resp + } + other => Response::err( + id, + RpcError::new( + error_code::METHOD_NOT_FOUND, + format!("unknown method: {other}"), + ), + ), + } +} + +impl Daemon { + fn status(&self, p: StatusParams) -> StatusResult { + let map = self.sessions.lock().unwrap(); + let sessions = map + .iter() + .filter(|(sid, _)| p.session_id.as_ref().is_none_or(|want| *want == **sid)) + .map(|(sid, s)| SessionStatus { + session_id: sid.clone(), + source: s.source.clone(), + queued: s.counters.queued.load(Ordering::Relaxed), + spans_emitted: s.counters.spans_emitted.load(Ordering::Relaxed), + permalink: s.permalink.lock().unwrap().clone(), + last_error: s.last_error.lock().unwrap().clone(), + }) + .collect(); + StatusResult { + daemon_version: self.version.clone(), + uptime_ms: self.started.elapsed().as_millis() as u64, + sessions, + } + } +} + +fn spawn_idle_watchdog(daemon: Arc, idle_timeout: Duration) { + if idle_timeout.is_zero() { + return; // 0 disables the watchdog (useful in tests) + } + tokio::spawn(async move { + let tick = (idle_timeout / 4).max(Duration::from_secs(1)); + loop { + tokio::select! { + _ = daemon.shutdown.notified() => return, + _ = tokio::time::sleep(tick) => {} + } + let idle_for = daemon.last_activity.lock().unwrap().elapsed(); + if idle_for >= idle_timeout && daemon.total_queued() == 0 { + tracing::info!("idle for {:?}; shutting down", idle_for); + daemon.trigger_shutdown(); + return; + } + } + }); +} + +async fn drain_all(daemon: &Arc) { + let sessions: Vec> = daemon.sessions.lock().unwrap().values().cloned().collect(); + for s in sessions { + s.shutdown().await; + } +} + +/// Is a live daemon answering at the endpoint? Connect and expect any line +/// back from a well-formed `initialize`. +async fn probe_alive(endpoint: &std::path::Path) -> bool { + let Ok(stream) = transport::connect(endpoint).await else { + return false; + }; + let (read_half, mut write_half) = tokio::io::split(stream); + let init = Request::new( + crate::wire::RequestId::Int(0), + method::INITIALIZE, + serde_json::json!({ + "protocol_version": PROTOCOL_VERSION, + "client": { "source": "probe" } + }), + ); + let Ok(mut line) = Message::Request(init).to_line() else { + return false; + }; + line.push('\n'); + if write_half.write_all(line.as_bytes()).await.is_err() { + return false; + } + let mut lines = BufReader::new(read_half).lines(); + matches!( + tokio::time::timeout(Duration::from_secs(1), lines.next_line()).await, + Ok(Ok(Some(_))) + ) +} diff --git a/bt-daemon/src/settings.rs b/bt-daemon/src/settings.rs new file mode 100644 index 0000000..711cd87 --- /dev/null +++ b/bt-daemon/src/settings.rs @@ -0,0 +1,136 @@ +//! Shared, non-credential settings for every agent connected to the daemon. +//! +//! Authentication and backend URLs deliberately stay with the embedding `bt` +//! CLI. This file only controls tracing behavior that should be consistent +//! across Codex, Claude Code, and future agent plugins. + +use crate::paths; +use serde::Deserialize; +use serde_json::{Map, Value}; +use std::path::Path; + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SharedSettings { + pub trace_to_braintrust: Option, + pub project: Option, + pub flush_on_turn_end: Option, + pub additional_metadata: Option>, +} + +impl SharedSettings { + pub(crate) fn load() -> Self { + Self::load_from(&paths::settings_path(None)) + } + + fn load_from(path: &Path) -> Self { + let raw = match std::fs::read_to_string(path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Self::default(), + Err(error) => { + tracing::warn!(path = %path.display(), "shared daemon settings ignored: {error}"); + return Self::default(); + } + }; + match serde_json::from_str(&raw) { + Ok(settings) => settings, + Err(error) => { + tracing::warn!(path = %path.display(), "shared daemon settings ignored: {error}"); + Self::default() + } + } + } + + pub(crate) fn tracing_enabled(&self) -> bool { + self.tracing_enabled_with(env_bool("TRACE_TO_BRAINTRUST")) + } + + fn tracing_enabled_with(&self, environment: Option) -> bool { + self.trace_to_braintrust.or(environment).unwrap_or(false) + } +} + +pub(crate) fn env_bool(name: &str) -> Option { + let value = std::env::var(name).ok()?; + parse_bool(&value) +} + +fn parse_bool(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" => Some(true), + "0" | "false" | "no" | "off" => Some(false), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shared_file_contains_behavior_only() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.json"); + std::fs::write( + &path, + r#"{ + "traceToBraintrust": true, + "project": "agents", + "flushOnTurnEnd": false, + "additionalMetadata": {"team": "platform"}, + "apiKey": "ignored", + "apiUrl": "https://ignored.example" + }"#, + ) + .unwrap(); + + let settings = SharedSettings::load_from(&path); + assert_eq!(settings.trace_to_braintrust, Some(true)); + assert_eq!(settings.project.as_deref(), Some("agents")); + assert_eq!(settings.flush_on_turn_end, Some(false)); + assert_eq!( + settings.additional_metadata.unwrap()["team"], + Value::String("platform".into()) + ); + } + + #[test] + fn malformed_or_missing_settings_are_fail_open() { + let temp = tempfile::tempdir().unwrap(); + assert!(SharedSettings::load_from(&temp.path().join("missing.json")) + .project + .is_none()); + let malformed = temp.path().join("malformed.json"); + std::fs::write(&malformed, "{").unwrap(); + assert!(SharedSettings::load_from(&malformed).project.is_none()); + } + + #[test] + fn file_enablement_overrides_environment_fallback() { + let enabled = SharedSettings { + trace_to_braintrust: Some(true), + ..Default::default() + }; + assert!(enabled.tracing_enabled_with(Some(false))); + + let disabled = SharedSettings { + trace_to_braintrust: Some(false), + ..Default::default() + }; + assert!(!disabled.tracing_enabled_with(Some(true))); + + assert!(SharedSettings::default().tracing_enabled_with(Some(true))); + assert!(!SharedSettings::default().tracing_enabled_with(None)); + } + + #[test] + fn boolean_environment_values_match_launcher_contract() { + for value in ["1", "true", "TRUE", "yes", "on"] { + assert_eq!(parse_bool(value), Some(true)); + } + for value in ["0", "false", "FALSE", "no", "off"] { + assert_eq!(parse_bool(value), Some(false)); + } + assert_eq!(parse_bool("sometimes"), None); + } +} diff --git a/bt-daemon/src/sink/braintrust.rs b/bt-daemon/src/sink/braintrust.rs new file mode 100644 index 0000000..da8cd6c --- /dev/null +++ b/bt-daemon/src/sink/braintrust.rs @@ -0,0 +1,376 @@ +//! The Braintrust sink: maps sink-neutral [`SpanOp`]s onto `braintrust-sdk-rust`. +//! +//! Multi-profile: a session's backend URLs come from its own config (bt +//! resolves them per profile), so clients are built lazily and cached by +//! `(api_url, app_url)` — sessions on the same instance share a client, +//! sessions on different instances get their own. Within a client, each +//! session's token/org travel per span (`span_builder_with_credentials`) and +//! never leak across sessions. Span ids are the translator's deterministic +//! UUIDv5 strings, reused as the SDK `row_id` merge key so journal replay +//! re-emits idempotently. + +use super::{Sink, SinkFactory}; +use crate::translate::{SpanOp, SpanRow, SpanType}; +use crate::wire::SessionConfig; +use braintrust_sdk_rust::{ + BraintrustClient, ParentSpanInfo, SpanHandle, SpanLog, SpanObjectType, SpanOrigin, + SpanType as SdkSpanType, DEFAULT_API_URL, DEFAULT_APP_URL, +}; +use serde_json::{Map, Value}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex as AsyncMutex; + +/// Daemon-level Braintrust settings. `api_url`/`app_url` are *fallbacks* used +/// when a session's config doesn't carry its own (e.g. the standalone binary's +/// env); `bt` supplies per-session URLs, so these are usually `None`. +#[derive(Debug, Clone, Default)] +pub struct BraintrustSinkConfig { + pub api_url: Option, + pub app_url: Option, + pub version: String, +} + +/// Lazily-built, shared-by-URL client pool. +struct ClientCache { + clients: AsyncMutex>>, + version: String, +} + +impl ClientCache { + fn new(version: String) -> Self { + Self { + clients: AsyncMutex::new(HashMap::new()), + version, + } + } + + async fn get_or_build( + &self, + api_url: &str, + app_url: &str, + ) -> anyhow::Result> { + let key = (api_url.to_string(), app_url.to_string()); + // Hold the lock across build so two sessions on a new URL don't build + // duplicate clients. Build is cheap (skip_login: no network). + let mut map = self.clients.lock().await; + if let Some(c) = map.get(&key) { + return Ok(c.clone()); + } + let client = BraintrustClient::builder() + .skip_login(true) + .span_origin(SpanOrigin::new().version(self.version.clone())) + .api_url(api_url.to_string()) + .app_url(app_url.to_string()) + .build() + .await + .map_err(|e| anyhow::anyhow!("braintrust client build failed: {e}"))?; + let arc = Arc::new(client); + map.insert(key, arc.clone()); + Ok(arc) + } +} + +/// Hands out a per-session sink over the shared client pool. +pub struct BraintrustSinkFactory { + cache: Arc, + default_api_url: Option, + default_app_url: Option, + version: String, +} + +impl BraintrustSinkFactory { + pub fn new(cfg: BraintrustSinkConfig) -> Self { + Self { + cache: Arc::new(ClientCache::new(cfg.version.clone())), + default_api_url: cfg.api_url, + default_app_url: cfg.app_url, + version: cfg.version, + } + } +} + +impl SinkFactory for BraintrustSinkFactory { + fn create(&self, _session_id: &str, source: &str) -> anyhow::Result> { + Ok(Box::new(BraintrustSink { + cache: self.cache.clone(), + default_api_url: self.default_api_url.clone(), + default_app_url: self.default_app_url.clone(), + version: self.version.clone(), + source: source.to_string(), + creds: None, + urls: None, + client: None, + open: HashMap::new(), + })) + } +} + +/// Per-session resolved credentials + trace-attach settings. +struct Creds { + token: String, + org_id: String, + org_name: Option, + project: Option, + experiment_id: Option, + parent_span_id: Option, + root_span_id: Option, +} + +struct BraintrustSink { + cache: Arc, + default_api_url: Option, + default_app_url: Option, + version: String, + source: String, + creds: Option, + /// Resolved `(api_url, app_url)` for this session, from its config. + urls: Option<(String, String)>, + /// The client for `urls`, obtained from the cache on first emit. + client: Option>, + /// Live span handles keyed by deterministic span id, so a later op (e.g. + /// setting `end`) merges onto the same row the SDK already knows. + open: HashMap>, +} + +impl BraintrustSink { + fn project(&self, creds: &Creds) -> String { + creds.project.clone().unwrap_or_else(|| self.source.clone()) + } + + fn parent_info(&self, row: &SpanRow, creds: &Creds, project: &str) -> ParentSpanInfo { + if row.parent_span_ids.is_empty() { + // Session root: attach under an external trace if the shim supplied + // one, else land it directly in the project's logs. + match (&creds.parent_span_id, &creds.root_span_id) { + (Some(p), Some(r)) => full_span(creds, project, p.clone(), r.clone()), + _ if creds.experiment_id.is_some() => ParentSpanInfo::Experiment { + object_id: creds.experiment_id.clone().unwrap(), + }, + _ => ParentSpanInfo::ProjectName { + project_name: project.to_string(), + }, + } + } else { + full_span( + creds, + project, + row.parent_span_ids[0].clone(), + creds + .root_span_id + .clone() + .unwrap_or_else(|| row.root_span_id.clone()), + ) + } + } + + async fn ensure_client(&mut self) -> anyhow::Result> { + if let Some(c) = &self.client { + return Ok(c.clone()); + } + let urls = self + .urls + .as_ref() + .ok_or_else(|| anyhow::anyhow!("session has no config/URLs yet"))?; + let client = self.cache.get_or_build(&urls.0, &urls.1).await?; + self.client = Some(client.clone()); + Ok(client) + } + + fn ensure_handle(&mut self, client: &BraintrustClient, row: &SpanRow) -> anyhow::Result<()> { + if self.open.contains_key(&row.span_id) { + return Ok(()); + } + let creds = self + .creds + .as_ref() + .ok_or_else(|| anyhow::anyhow!("session has no credentials/config yet"))?; + let project = self.project(creds); + let parent = self.parent_info(row, creds, &project); + + let mut builder = client + .span_builder_with_credentials(creds.token.clone(), creds.org_id.clone()) + .span_type(map_span_type(row.span_type)) + .span_id(row.span_id.clone()) + .row_id(row.span_id.clone()) + .project_name(project) + .parent_info(parent) + .span_origin( + SpanOrigin::new() + .name(format!("braintrust.plugin.{}", self.source)) + .version(self.version.clone()) + .instrumentation("braintrust-plugin"), + ); + if let Some(org_name) = &creds.org_name { + builder = builder.org_name(org_name.clone()); + } + if let Some(start) = row.start_ms { + builder = builder.start_time(ms_to_secs(start)); + } + self.open.insert(row.span_id.clone(), builder.build()); + Ok(()) + } + + fn upsert(&mut self, client: &BraintrustClient, row: &SpanRow) -> anyhow::Result<()> { + self.ensure_handle(client, row)?; + let handle = self.open.get(&row.span_id).expect("just inserted"); + handle.log(build_log(row)?); + if let Some(end) = row.end_ms { + handle.end_with_time(ms_to_secs(end)); + } + Ok(()) + } +} + +#[async_trait::async_trait] +impl Sink for BraintrustSink { + fn configure(&mut self, config: &SessionConfig) { + let api = config + .auth + .api_url + .clone() + .or_else(|| self.default_api_url.clone()) + .unwrap_or_else(|| DEFAULT_API_URL.to_string()); + let app = config + .auth + .app_url + .clone() + .or_else(|| self.default_app_url.clone()) + .unwrap_or_else(|| DEFAULT_APP_URL.to_string()); + let new_urls = (api, app); + if self.urls.as_ref() != Some(&new_urls) { + // A session shouldn't change backend URLs mid-flight; if it does, + // rebind the client on the next emit. Pre-change open handles stay + // bound to the old client (pathological; just noted). + if self.client.is_some() { + tracing::warn!( + source = %self.source, + "session changed backend URLs mid-session; rebinding client" + ); + } + self.urls = Some(new_urls); + self.client = None; + } + self.creds = Some(Creds { + token: config.auth.token.clone(), + org_id: config.auth.org_id.clone().unwrap_or_default(), + org_name: config.auth.org_name.clone(), + project: config.project.clone(), + experiment_id: config + .additional_metadata + .as_ref() + .and_then(|v| v.get("_bt_experiment_id")) + .and_then(Value::as_str) + .map(ToOwned::to_owned), + parent_span_id: config.parent_span_id.clone(), + root_span_id: config.root_span_id.clone(), + }); + } + + async fn emit(&mut self, ops: &[SpanOp]) -> anyhow::Result { + let client = self.ensure_client().await?; + let mut n = 0u64; + for op in ops { + let row = match op { + SpanOp::Insert(r) | SpanOp::Merge(r) => r, + }; + self.upsert(&client, row)?; + n += 1; + } + Ok(n) + } + + async fn flush(&mut self) -> anyhow::Result<()> { + match &self.client { + Some(client) => client + .flush() + .await + .map_err(|e| anyhow::anyhow!("braintrust flush failed: {e}")), + None => Ok(()), + } + } +} + +fn full_span( + creds: &Creds, + project: &str, + span_id: String, + root_span_id: String, +) -> ParentSpanInfo { + if let Some(experiment_id) = &creds.experiment_id { + return ParentSpanInfo::FullSpan { + object_type: SpanObjectType::Experiment, + object_id: Some(experiment_id.clone()), + compute_object_metadata_args: None, + span_id, + root_span_id, + span_parents: None, + propagated_event: None, + }; + } + let mut cma = Map::new(); + cma.insert( + "project_name".to_string(), + Value::String(project.to_string()), + ); + ParentSpanInfo::FullSpan { + object_type: SpanObjectType::ProjectLogs, + object_id: None, + compute_object_metadata_args: Some(cma), + span_id, + root_span_id, + span_parents: None, + propagated_event: None, + } +} + +fn map_span_type(t: SpanType) -> SdkSpanType { + match t { + SpanType::Task => SdkSpanType::Task, + SpanType::Llm => SdkSpanType::Llm, + SpanType::Tool => SdkSpanType::Tool, + } +} + +fn ms_to_secs(ms: i64) -> f64 { + ms as f64 / 1000.0 +} + +fn build_log(row: &SpanRow) -> anyhow::Result { + // The span's display name is carried on the log event, not the builder. + // An empty name means "unchanged" (many merge ops use `..Default::default()` + // and don't rename the span) — omitting `.name()` avoids overwriting the + // already-set name with an empty string on merge. + let mut lb = SpanLog::builder(); + if !row.name.is_empty() { + lb = lb.name(row.name.clone()); + } + if let Some(input) = &row.input { + lb = lb.input(input.clone()); + } + if let Some(output) = &row.output { + lb = lb.output(output.clone()); + } + if let Some(Value::Object(md)) = &row.metadata { + lb = lb.metadata(md.clone()); + } + if let Some(Value::Object(metrics)) = &row.metrics { + let hm: HashMap = metrics + .iter() + .filter_map(|(k, v)| v.as_f64().map(|f| (k.clone(), f))) + .collect(); + if !hm.is_empty() { + lb = lb.metrics(hm); + } + } + if let Some(err) = &row.error { + lb = lb.error(Value::String(err.clone())); + } + if let Some(tags) = &row.tags { + if !tags.is_empty() { + lb = lb.tags(tags.clone()); + } + } + lb.build() + .map_err(|e| anyhow::anyhow!("span log build failed: {e}")) +} diff --git a/bt-daemon/src/sink/debug.rs b/bt-daemon/src/sink/debug.rs new file mode 100644 index 0000000..f61c534 --- /dev/null +++ b/bt-daemon/src/sink/debug.rs @@ -0,0 +1,62 @@ +//! Debug sink: appends each emitted [`SpanOp`] as one NDJSON line to +//! `/spans/.ndjson`. Lets tests assert on exactly what +//! the pipeline produced without touching Braintrust. + +use super::{Sink, SinkFactory}; +use crate::translate::SpanOp; +use std::fs::{File, OpenOptions}; +use std::io::{BufWriter, Write}; +use std::path::PathBuf; + +pub struct DebugSinkFactory { + pub dir: PathBuf, +} + +impl SinkFactory for DebugSinkFactory { + fn create(&self, session_id: &str, _source: &str) -> anyhow::Result> { + std::fs::create_dir_all(&self.dir)?; + let path = self.dir.join(format!("{}.ndjson", sanitize(session_id))); + let file = OpenOptions::new().create(true).append(true).open(&path)?; + Ok(Box::new(DebugSink { + writer: BufWriter::new(file), + written: 0, + })) + } +} + +struct DebugSink { + writer: BufWriter, + written: u64, +} + +#[async_trait::async_trait] +impl Sink for DebugSink { + async fn emit(&mut self, ops: &[SpanOp]) -> anyhow::Result { + for op in ops { + serde_json::to_writer(&mut self.writer, op)?; + self.writer.write_all(b"\n")?; + self.written += 1; + } + // Flush per batch so a reader (test) sees rows promptly. + self.writer.flush()?; + Ok(ops.len() as u64) + } + + async fn flush(&mut self) -> anyhow::Result<()> { + self.writer.flush()?; + Ok(()) + } +} + +/// Keep session ids filesystem-safe for the per-session file name. +fn sanitize(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect() +} diff --git a/bt-daemon/src/sink/mod.rs b/bt-daemon/src/sink/mod.rs new file mode 100644 index 0000000..f6b9a32 --- /dev/null +++ b/bt-daemon/src/sink/mod.rs @@ -0,0 +1,43 @@ +//! Sinks consume [`SpanOp`]s. Phase 1 shipped the debug sink (dumps ops to +//! NDJSON); Phase 2 adds the Braintrust sink over `braintrust-sdk-rust`. +//! +//! The trait is async so the Braintrust sink can drive the SDK's async +//! `flush`. `emit` is called on the per-session hot path; the SDK's `log`/`end` +//! are synchronous fire-and-forget (queue-backed), so `emit` rarely awaits. + +mod braintrust; +mod debug; + +pub use braintrust::{BraintrustSinkConfig, BraintrustSinkFactory}; +pub use debug::DebugSinkFactory; + +use crate::translate::SpanOp; +use crate::wire::SessionConfig; + +/// A per-session sink. Created once per session; `configure` supplies the +/// resolved credentials/project/trace-attach settings (and may be re-called if +/// they change). +#[async_trait::async_trait] +pub trait Sink: Send { + /// Called when the session's config is (re)resolved. + fn configure(&mut self, config: &SessionConfig) { + let _ = config; + } + + /// Emit span ops. Returns the number of rows written, for status counters. + async fn emit(&mut self, ops: &[SpanOp]) -> anyhow::Result; + + /// Deliver everything buffered (bounded by the caller's flush timeout). + async fn flush(&mut self) -> anyhow::Result<()>; + + /// A user-facing trace permalink, once known. + fn permalink(&self) -> Option { + None + } +} + +/// Builds a sink per session. `source` is the agent id (e.g. `codex`), used by +/// the Braintrust sink to stamp `context.span_origin`. +pub trait SinkFactory: Send + Sync { + fn create(&self, session_id: &str, source: &str) -> anyhow::Result>; +} diff --git a/bt-daemon/src/transcript_import.rs b/bt-daemon/src/transcript_import.rs new file mode 100644 index 0000000..cbee887 --- /dev/null +++ b/bt-daemon/src/transcript_import.rs @@ -0,0 +1,574 @@ +use crate::wire::Envelope; +use crate::ImportSource; +use anyhow::{bail, Context}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; + +pub(crate) fn resolve_transcript( + session_id: &str, + source: ImportSource, +) -> anyhow::Result { + validate_session_id(session_id)?; + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let roots = match source { + ImportSource::Codex => { + let codex_home = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".codex")); + vec![ + codex_home.join("sessions"), + codex_home.join("archived_sessions"), + ] + } + ImportSource::Claude => { + let claude_home = std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| home.join(".claude")); + vec![claude_home.join("projects")] + } + }; + resolve_transcript_in(session_id, source, &roots) +} + +fn resolve_transcript_in( + session_id: &str, + source: ImportSource, + roots: &[PathBuf], +) -> anyhow::Result { + validate_session_id(session_id)?; + let expected = match source { + ImportSource::Codex => format!("{session_id}.jsonl"), + ImportSource::Claude => format!("{session_id}.jsonl"), + }; + let mut matches = Vec::new(); + for root in roots { + find_matching_files(root, &expected, source, &mut matches); + } + matches.sort(); + matches.dedup(); + match matches.as_slice() { + [path] => Ok(path.clone()), + [] => { + let locations = roots + .iter() + .map(|root| root.display().to_string()) + .collect::>() + .join(", "); + bail!( + "no {} transcript found for session {session_id}; searched {locations}", + source_name(source) + ) + } + paths => { + let locations = paths + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", "); + bail!( + "multiple {} transcripts found for session {session_id}: {locations}", + source_name(source) + ) + } + } +} + +fn find_matching_files( + directory: &Path, + expected_suffix: &str, + source: ImportSource, + matches: &mut Vec, +) { + let Ok(entries) = std::fs::read_dir(directory) else { + return; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() { + continue; + } + let path = entry.path(); + if file_type.is_dir() { + find_matching_files(&path, expected_suffix, source, matches); + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let is_match = match source { + // Codex prefixes rollout files with their timestamp. Claude names + // the main transcript exactly after the session id. + ImportSource::Codex => name.ends_with(expected_suffix), + ImportSource::Claude => name == expected_suffix, + }; + if is_match { + matches.push(path); + } + } +} + +fn validate_session_id(session_id: &str) -> anyhow::Result<()> { + if session_id.is_empty() + || !session_id + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + bail!("invalid session id {session_id:?}"); + } + Ok(()) +} + +fn source_name(source: ImportSource) -> &'static str { + match source { + ImportSource::Codex => "Codex", + ImportSource::Claude => "Claude Code", + } +} + +pub(crate) fn transcript_envelopes( + path: &Path, + source: ImportSource, +) -> anyhow::Result> { + let contents = std::fs::read_to_string(path) + .with_context(|| format!("read transcript {}", path.display()))?; + let mut records = Vec::new(); + let mut record_end_offsets = Vec::new(); + let mut offset = 0_u64; + for (index, line) in contents.split_inclusive('\n').enumerate() { + offset += line.len() as u64; + if line.trim().is_empty() { + continue; + } + records.push( + serde_json::from_str(line).with_context(|| { + format!("parse transcript {} line {}", path.display(), index + 1) + })?, + ); + record_end_offsets.push(offset); + } + if records.is_empty() { + bail!("transcript {} is empty", path.display()); + } + match source { + ImportSource::Codex => codex_envelopes(path, &records), + ImportSource::Claude => { + claude_envelopes(path, &records, &record_end_offsets, contents.len() as u64) + } + } +} + +fn codex_envelopes(path: &Path, records: &[Value]) -> anyhow::Result> { + let meta = records + .iter() + .find(|record| record.get("type").and_then(Value::as_str) == Some("session_meta")); + let session_id = meta + .and_then(|record| record.pointer("/payload/id")) + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| file_session_id(path)); + let source_version = meta + .and_then(|record| record.pointer("/payload/cli_version")) + .and_then(Value::as_str) + .map(str::to_owned); + let (start_ms, end_ms) = timestamp_bounds(records); + let transcript_path = path.to_string_lossy(); + let last_message = records.iter().rev().find_map(|record| { + (record.pointer("/payload/type").and_then(Value::as_str) == Some("task_complete")) + .then(|| record.pointer("/payload/last_agent_message").cloned()) + .flatten() + }); + let mut events = vec![envelope( + "codex", + source_version.clone(), + &session_id, + "SessionStart", + start_ms, + json!({ + "session_id": session_id, + "hook_event_name": "SessionStart", + "transcript_path": transcript_path, + "source": "import", + "_bt_import_through_ms": start_ms + }), + )]; + let mut checkpoints = records + .iter() + .filter(|record| { + matches!( + record.pointer("/payload/type").and_then(Value::as_str), + Some("task_started" | "task_complete" | "turn_aborted") + ) + }) + .filter_map(timestamp_ms) + .collect::>(); + checkpoints.sort_unstable(); + checkpoints.dedup(); + for checkpoint_ms in checkpoints { + if checkpoint_ms <= start_ms || checkpoint_ms >= end_ms { + continue; + } + events.push(envelope( + "codex", + source_version.clone(), + &session_id, + "ImportCheckpoint", + checkpoint_ms, + json!({ + "session_id": session_id, + "hook_event_name": "ImportCheckpoint", + "transcript_path": transcript_path, + "_bt_import_through_ms": checkpoint_ms + }), + )); + } + events.push(envelope( + "codex", + source_version, + &session_id, + "Stop", + end_ms, + json!({ + "session_id": session_id, + "hook_event_name": "Stop", + "transcript_path": transcript_path, + "last_agent_message": last_message, + "_bt_import_through_ms": end_ms + }), + )); + Ok(events) +} + +fn claude_envelopes( + path: &Path, + records: &[Value], + record_end_offsets: &[u64], + transcript_len: u64, +) -> anyhow::Result> { + if records.len() != record_end_offsets.len() { + bail!("Claude transcript record offsets do not match parsed records"); + } + let session_id = records + .iter() + .find_map(|record| string_at(record, "/sessionId")) + .unwrap_or_else(|| file_session_id(path)); + let source_version = records + .iter() + .find_map(|record| string_at(record, "/version")); + let cwd = records.iter().find_map(|record| string_at(record, "/cwd")); + let (start_ms, end_ms) = timestamp_bounds(records); + let transcript_path = path.to_string_lossy().into_owned(); + let mut events = vec![claude_import_envelope( + source_version.clone(), + &session_id, + "SessionStart", + start_ms.saturating_sub(1), + json!({ + "session_id": session_id, + "hook_event_name": "SessionStart", + "transcript_path": transcript_path, + "cwd": cwd, + "source": "import" + }), + 0, + )]; + + let user_indexes: Vec = records + .iter() + .enumerate() + .filter_map(|(index, record)| is_real_user(record).then_some(index)) + .collect(); + for (turn, &index) in user_indexes.iter().enumerate() { + let next = user_indexes.get(turn + 1).copied().unwrap_or(records.len()); + let segment = &records[index..next]; + let turn_start = timestamp_ms(&records[index]).unwrap_or(start_ms); + // Claude can append queue bookkeeping ahead of older conversation + // records. Only message rows define the native turn's duration. + let turn_end = segment + .iter() + .filter(|record| { + matches!( + record.get("type").and_then(Value::as_str), + Some("user" | "assistant") + ) + }) + .filter_map(timestamp_ms) + .max() + .unwrap_or(turn_start); + let prompt = records[index] + .pointer("/message/content") + .cloned() + .unwrap_or(Value::Null); + events.push(claude_import_envelope( + source_version.clone(), + &session_id, + "UserPromptSubmit", + turn_start, + json!({ + "session_id": session_id, + "hook_event_name": "UserPromptSubmit", + "transcript_path": transcript_path, + "cwd": cwd, + "prompt": prompt + }), + record_end_offsets[index], + )); + let error = last_assistant_error(segment); + let stop_event = if error.is_some() { + "StopFailure" + } else { + "Stop" + }; + events.push(claude_import_envelope( + source_version.clone(), + &session_id, + stop_event, + turn_end, + json!({ + "session_id": session_id, + "hook_event_name": stop_event, + "transcript_path": transcript_path, + "cwd": cwd, + "last_assistant_message": last_assistant_text(segment), + "error": error + }), + record_end_offsets[next.saturating_sub(1)], + )); + } + events.push(claude_import_envelope( + source_version, + &session_id, + "SessionEnd", + end_ms.saturating_add(1), + json!({ + "session_id": session_id, + "hook_event_name": "SessionEnd", + "transcript_path": transcript_path, + "cwd": cwd, + "reason": "transcript_import" + }), + transcript_len, + )); + Ok(events) +} + +fn claude_import_envelope( + source_version: Option, + session_id: &str, + event: &str, + ts_ms: i64, + mut payload: Value, + through_offset: u64, +) -> Envelope { + if let Some(payload) = payload.as_object_mut() { + payload.insert("_bt_import_through_offset".into(), json!(through_offset)); + } + envelope( + "claude-code", + source_version, + session_id, + event, + ts_ms, + payload, + ) +} + +fn envelope( + source: &str, + source_version: Option, + session_id: &str, + event: &str, + ts_ms: i64, + payload: Value, +) -> Envelope { + Envelope { + source: source.into(), + source_version, + session_id: session_id.into(), + event: event.into(), + ts_ms, + payload, + config: None, + } +} + +fn is_real_user(record: &Value) -> bool { + if record.get("type").and_then(Value::as_str) != Some("user") { + return false; + } + !record + .pointer("/message/content") + .and_then(Value::as_array) + .is_some_and(|blocks| { + blocks + .iter() + .any(|block| block.get("type").and_then(Value::as_str) == Some("tool_result")) + }) +} + +fn last_assistant_text(records: &[Value]) -> Option { + records.iter().rev().find_map(|record| { + if record.get("type").and_then(Value::as_str) != Some("assistant") { + return None; + } + let content = record.pointer("/message/content")?; + if let Some(text) = content.as_str() { + return Some(json!(text)); + } + let text = content + .as_array()? + .iter() + .filter_map(|block| { + (block.get("type").and_then(Value::as_str) == Some("text")) + .then(|| block.get("text").and_then(Value::as_str)) + .flatten() + }) + .collect::>() + .join("\n"); + (!text.is_empty()).then(|| json!(text)) + }) +} + +fn last_assistant_error(records: &[Value]) -> Option { + records.iter().rev().find_map(|record| { + if record.get("type").and_then(Value::as_str) != Some("assistant") + || record.get("isApiErrorMessage").and_then(Value::as_bool) != Some(true) + { + return None; + } + string_at(record, "/error") + .or_else(|| { + last_assistant_text(std::slice::from_ref(record))? + .as_str() + .map(str::to_owned) + }) + .or_else(|| Some("Claude API error".into())) + }) +} + +fn timestamp_bounds(records: &[Value]) -> (i64, i64) { + let mut timestamps = records.iter().filter_map(timestamp_ms); + let Some(first) = timestamps.next() else { + return (0, 0); + }; + timestamps.fold((first, first), |(min, max), value| { + (min.min(value), max.max(value)) + }) +} + +fn timestamp_ms(record: &Value) -> Option { + chrono::DateTime::parse_from_rfc3339(record.get("timestamp")?.as_str()?) + .ok() + .map(|timestamp| timestamp.timestamp_millis()) +} + +fn string_at(record: &Value, pointer: &str) -> Option { + record.pointer(pointer)?.as_str().map(str::to_owned) +} + +fn file_session_id(path: &Path) -> String { + path.file_stem() + .and_then(|stem| stem.to_str()) + .filter(|stem| !stem.is_empty()) + .unwrap_or("imported-session") + .to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_codex_rollout_by_session_suffix() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("sessions"); + let transcript = root + .join("2026/07/31") + .join("rollout-2026-07-31T12-00-00-session-123.jsonl"); + std::fs::create_dir_all(transcript.parent().unwrap()).unwrap(); + std::fs::write(&transcript, "{}\n").unwrap(); + + assert_eq!( + resolve_transcript_in("session-123", ImportSource::Codex, &[root]).unwrap(), + transcript + ); + } + + #[test] + fn finds_only_exact_claude_session_filename() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("projects"); + let project = root.join("-tmp-project"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write(project.join("prefix-session-123.jsonl"), "{}\n").unwrap(); + let transcript = project.join("session-123.jsonl"); + std::fs::write(&transcript, "{}\n").unwrap(); + + assert_eq!( + resolve_transcript_in("session-123", ImportSource::Claude, &[root]).unwrap(), + transcript + ); + } + + #[test] + fn rejects_unsafe_session_ids() { + let error = resolve_transcript_in( + "../session", + ImportSource::Codex, + &[PathBuf::from("unused")], + ) + .unwrap_err(); + assert!(error.to_string().contains("invalid session id")); + } + + #[test] + fn reports_missing_and_ambiguous_sessions() { + let temp = tempfile::tempdir().unwrap(); + let first = temp.path().join("first"); + let second = temp.path().join("second"); + std::fs::create_dir_all(&first).unwrap(); + std::fs::create_dir_all(&second).unwrap(); + + let missing = resolve_transcript_in( + "missing", + ImportSource::Claude, + &[first.clone(), second.clone()], + ) + .unwrap_err(); + assert!(missing.to_string().contains("no Claude Code transcript")); + + std::fs::write(first.join("duplicate.jsonl"), "{}\n").unwrap(); + std::fs::write(second.join("duplicate.jsonl"), "{}\n").unwrap(); + let ambiguous = + resolve_transcript_in("duplicate", ImportSource::Claude, &[first, second]).unwrap_err(); + assert!(ambiguous + .to_string() + .contains("multiple Claude Code transcripts")); + } + + #[test] + fn codex_import_adds_native_turn_checkpoints() { + let records = vec![ + json!({"timestamp":"2026-01-01T00:00:01Z","type":"session_meta","payload":{"id":"session-123"}}), + json!({"timestamp":"2026-01-01T00:00:02Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}), + json!({"timestamp":"2026-01-01T00:00:03Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1"}}), + json!({"timestamp":"2026-01-01T00:00:04Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-2"}}), + json!({"timestamp":"2026-01-01T00:00:05Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-2"}}), + ]; + let events = codex_envelopes(Path::new("rollout.jsonl"), &records).unwrap(); + + assert_eq!(events.first().unwrap().event, "SessionStart"); + assert_eq!(events.last().unwrap().event, "Stop"); + assert_eq!( + events + .iter() + .filter(|event| event.event == "ImportCheckpoint") + .count(), + 3 + ); + } +} diff --git a/bt-daemon/src/translate/claude.rs b/bt-daemon/src/translate/claude.rs new file mode 100644 index 0000000..08d375e --- /dev/null +++ b/bt-daemon/src/translate/claude.rs @@ -0,0 +1,1363 @@ +//! Claude Code hook/transcript translator. +//! +//! Hook events own lifecycle and timing. Transcript rows supply model calls, +//! conversation history, usage, and a recovery path for tool calls whose hook +//! event was missed. Transcript cursors advance on every hook, but never past +//! the hook timestamp; this is essential when replaying a journal against a +//! transcript that already contains the completed session. + +use super::{AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory}; +use crate::ids; +use crate::wire::Envelope; +use serde_json::{json, Map, Value}; +use std::collections::{HashMap, HashSet}; +use std::io::{BufRead, Seek, SeekFrom}; +use std::process::Command; + +pub struct ClaudeTranslatorFactory; + +impl TranslatorFactory for ClaudeTranslatorFactory { + fn source(&self) -> &str { + "claude-code" + } + + fn create(&self, session_id: &str) -> Box { + Box::new(ClaudeTranslator::new(session_id)) + } +} + +struct Turn { + id: String, + number: u32, +} + +#[derive(Default)] +struct TranscriptCursor { + offset: u64, + buffered: Vec, +} + +struct Subagent { + span_id: String, + transcript_path: Option, +} + +struct PendingTool { + span_id: String, + parent_id: String, +} + +struct ClaudeTranslator { + session_id: String, + session_span_id: String, + root_span_id: String, + root_open: bool, + root_ended: bool, + turn: Option, + last_turn_id: Option, + turn_count: u32, + tool_seq: u32, + main_transcript: Option, + transcripts: HashMap, + main_history: Vec, + emitted_requests: HashSet, + emitted_tools: HashSet, + pending_tools: HashMap, + subagents: HashMap, + pending_skills: Vec, + claude_version: Option, + claude_version_logged: bool, +} + +impl ClaudeTranslator { + fn new(session_id: &str) -> Self { + let root = ids::span_id(session_id, "root"); + Self { + session_id: session_id.to_string(), + session_span_id: root.clone(), + root_span_id: root, + root_open: false, + root_ended: false, + turn: None, + last_turn_id: None, + turn_count: 0, + tool_seq: 0, + main_transcript: None, + transcripts: HashMap::new(), + main_history: Vec::new(), + emitted_requests: HashSet::new(), + emitted_tools: HashSet::new(), + pending_tools: HashMap::new(), + subagents: HashMap::new(), + pending_skills: Vec::new(), + claude_version: None, + claude_version_logged: false, + } + } + + fn ensure_root(&mut self, event: &Envelope, ctx: &SessionCtx, ops: &mut Vec) { + if self.root_open { + return; + } + self.root_open = true; + if let Some(root) = ctx.config.as_ref().and_then(|c| c.root_span_id.as_ref()) { + self.root_span_id = root.clone(); + } + let cwd = string_field(&event.payload, "cwd").unwrap_or_default(); + let workspace = basename(&cwd); + let mut metadata = ctx + .config + .as_ref() + .and_then(|c| c.additional_metadata.clone()) + .and_then(|v| v.as_object().cloned()) + .unwrap_or_default(); + // Internal routing settings must never appear as user metadata. + metadata.retain(|key, _| !key.starts_with("_bt_")); + metadata.extend(git_metadata(&cwd)); + metadata.insert("session_id".into(), json!(self.session_id)); + metadata.insert("workspace".into(), json!(cwd)); + metadata.insert("source".into(), json!("claude-code")); + metadata.insert("hostname".into(), json!(hostname())); + metadata.insert("username".into(), json!(username())); + metadata.insert( + "os".into(), + json!(command_output("", "uname", &["-s"]) + .unwrap_or_else(|| std::env::consts::OS.to_string())), + ); + if let Some(version) = &event.source_version { + metadata.insert("trace_claude_code_version".into(), json!(version)); + } + if let Some(version) = &self.claude_version { + metadata.insert("claude_code_version".into(), json!(version)); + } + if let Some(source) = string_field(&event.payload, "source") { + metadata.insert("session_source".into(), json!(source)); + } + if let Some(model) = string_field(&event.payload, "model") { + metadata.insert("model".into(), json!(model)); + } + ops.push(SpanOp::Insert(SpanRow { + span_id: self.session_span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: ctx + .config + .as_ref() + .and_then(|c| c.parent_span_id.clone()) + .into_iter() + .collect(), + name: format!("Claude Code: {workspace}"), + span_type: SpanType::Task, + start_ms: Some(event.ts_ms), + input: Some(json!(format!("Session: {workspace}"))), + metadata: Some(Value::Object(metadata)), + ..Default::default() + })); + } + + fn tail_main(&mut self, event: &Envelope) { + if let Some(path) = string_field(&event.payload, "transcript_path") { + self.main_transcript = Some(path); + } + let Some(path) = self.main_transcript.clone() else { + return; + }; + let cursor = self.transcripts.entry(path.clone()).or_default(); + let rows = read_event_records(event, &path, &mut cursor.offset); + if self.claude_version.is_none() { + self.claude_version = rows.iter().find_map(|row| string_field(row, "version")); + } + cursor.buffered.extend(rows); + } + + fn open_turn(&mut self, event: &Envelope, ops: &mut Vec) { + if let Some(old) = self.turn.take() { + self.close_pending_tools( + &old.id, + event.ts_ms, + "Turn ended before tool completion", + ops, + ); + ops.push(SpanOp::Merge(SpanRow { + span_id: old.id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(event.ts_ms), + ..Default::default() + })); + } + self.turn_count += 1; + let id = ids::span_id(&self.session_id, &format!("turn:{}", self.turn_count)); + let skill_metadata = explicit_skill_metadata(&self.pending_skills); + self.pending_skills.clear(); + ops.push(SpanOp::Insert(SpanRow { + span_id: id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![self.session_span_id.clone()], + name: format!("Turn {}", self.turn_count), + span_type: SpanType::Task, + start_ms: Some(event.ts_ms), + input: event.payload.get("prompt").cloned(), + metadata: skill_metadata, + ..Default::default() + })); + self.turn = Some(Turn { + id, + number: self.turn_count, + }); + } + + fn record_skill(&mut self, event: &Envelope, ops: &mut Vec) { + if string_field(&event.payload, "expansion_type") + .or_else(|| string_field(&event.payload, "type")) + .is_some_and(|kind| kind != "slash_command") + { + return; + } + let direct = string_field(&event.payload, "skill_name") + .or_else(|| string_field(&event.payload, "skillName")) + .or_else(|| { + event + .payload + .pointer("/skill/name") + .and_then(Value::as_str) + .map(str::to_owned) + }) + .or_else(|| string_field(&event.payload, "skill")); + let command = string_field(&event.payload, "command_name") + .or_else(|| string_field(&event.payload, "command")) + .or_else(|| string_field(&event.payload, "slash_command")) + .or_else(|| string_field(&event.payload, "name")) + .map(|name| normalize_skill_name(&name)); + let name = direct.map(|name| normalize_skill_name(&name)).or_else(|| { + let command = command?; + let path = string_field(&event.payload, "transcript_path")?; + skill_listing_contains(&path, &command).then_some(command) + }); + let Some(name) = name.filter(|name| !name.is_empty()) else { + return; + }; + if !self.pending_skills.contains(&name) { + self.pending_skills.push(name); + } + if let Some(turn) = &self.turn { + ops.push(SpanOp::Merge(SpanRow { + span_id: turn.id.clone(), + root_span_id: self.root_span_id.clone(), + metadata: explicit_skill_metadata(&self.pending_skills), + ..Default::default() + })); + } + } + + fn parent_for(&mut self, event: &Envelope, ops: &mut Vec) -> Option { + if let Some(agent_id) = string_field(&event.payload, "agent_id") { + return Some(self.ensure_subagent(&agent_id, event, ops)); + } + self.turn.as_ref().map(|turn| turn.id.clone()) + } + + fn ensure_subagent( + &mut self, + agent_id: &str, + event: &Envelope, + ops: &mut Vec, + ) -> String { + if let Some(agent) = self.subagents.get(agent_id) { + return agent.span_id.clone(); + } + let parent_id = self + .turn + .as_ref() + .map(|turn| turn.id.clone()) + .or_else(|| self.last_turn_id.clone()) + .unwrap_or_else(|| self.session_span_id.clone()); + let agent_type = + string_field(&event.payload, "agent_type").unwrap_or_else(|| "agent".into()); + let span_id = ids::span_id(&self.session_id, &format!("subagent:{agent_id}")); + ops.push(SpanOp::Insert(SpanRow { + span_id: span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![parent_id.clone()], + name: format!("subagent: {agent_type}"), + span_type: SpanType::Task, + start_ms: Some(event.ts_ms), + metadata: Some(json!({ "agent_id": agent_id, "agent_type": agent_type })), + ..Default::default() + })); + self.subagents.insert( + agent_id.to_string(), + Subagent { + span_id: span_id.clone(), + transcript_path: None, + }, + ); + span_id + } + + fn pre_tool(&mut self, event: &Envelope, ops: &mut Vec) { + let Some(parent_id) = self.parent_for(event, ops) else { + return; + }; + let Some(tool_name) = tool_name(&event.payload) else { + return; + }; + let call_id = self.call_id(event); + if self.pending_tools.contains_key(&call_id) || self.emitted_tools.contains(&call_id) { + return; + } + let input = tool_input(&event.payload); + let span_id = ids::span_id(&self.session_id, &format!("tool:{call_id}")); + let metadata = tool_metadata(event, &tool_name, &call_id, "approved", &input); + ops.push(SpanOp::Insert(SpanRow { + span_id: span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![parent_id.clone()], + name: tool_span_name(&tool_name, &input), + span_type: SpanType::Tool, + start_ms: Some(event.ts_ms), + input: Some(input.clone()), + metadata: Some(metadata), + ..Default::default() + })); + self.pending_tools + .insert(call_id, PendingTool { span_id, parent_id }); + } + + fn finish_tool( + &mut self, + event: &Envelope, + approval: &str, + forced_error: Option, + ops: &mut Vec, + ) { + let Some(tool_name) = tool_name(&event.payload) else { + return; + }; + let call_id = self.call_id(event); + let input = tool_input(&event.payload); + let output = tool_output(&event.payload); + let error = forced_error.or_else(|| tool_error(&event.payload)); + let metadata = tool_metadata(event, &tool_name, &call_id, approval, &input); + if let Some(pending) = self.pending_tools.remove(&call_id) { + ops.push(SpanOp::Merge(SpanRow { + span_id: pending.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(event.ts_ms), + output, + metadata: Some(metadata), + error, + ..Default::default() + })); + } else if !self.emitted_tools.contains(&call_id) { + let Some(parent_id) = self.parent_for(event, ops) else { + return; + }; + let duration = event + .payload + .get("duration_ms") + .and_then(Value::as_i64) + .unwrap_or(0); + ops.push(SpanOp::Insert(SpanRow { + span_id: ids::span_id(&self.session_id, &format!("tool:{call_id}")), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![parent_id], + name: tool_span_name(&tool_name, &input), + span_type: SpanType::Tool, + start_ms: Some(event.ts_ms.saturating_sub(duration)), + end_ms: Some(event.ts_ms), + input: Some(input), + output, + metadata: Some(metadata), + error, + ..Default::default() + })); + } + self.emitted_tools.insert(call_id); + } + + fn call_id(&mut self, event: &Envelope) -> String { + string_field(&event.payload, "tool_use_id").unwrap_or_else(|| { + self.tool_seq += 1; + let turn = self.turn.as_ref().map(|t| t.number).unwrap_or(0); + format!("{turn}:{}", self.tool_seq) + }) + } + + fn stop_subagent(&mut self, event: &Envelope, ops: &mut Vec) { + let Some(agent_id) = string_field(&event.payload, "agent_id") else { + return; + }; + let parent = self.ensure_subagent(&agent_id, event, ops); + let path = string_field(&event.payload, "agent_transcript_path"); + if let Some(agent) = self.subagents.get_mut(&agent_id) { + agent.transcript_path = path.clone(); + } + if let Some(path) = path { + let cursor = self.transcripts.entry(path.clone()).or_default(); + cursor + .buffered + .extend(read_event_records(event, &path, &mut cursor.offset)); + let records = std::mem::take(&mut cursor.buffered); + self.emit_transcript(&records, &format!("subagent:{agent_id}"), &parent, ops); + } + self.close_pending_tools( + &parent, + event.ts_ms, + "Subagent ended before tool completion", + ops, + ); + ops.push(SpanOp::Merge(SpanRow { + span_id: parent, + root_span_id: self.root_span_id.clone(), + end_ms: Some(event.ts_ms), + output: event.payload.get("last_assistant_message").cloned(), + ..Default::default() + })); + } + + fn emit_main(&mut self, parent: &str, ops: &mut Vec) { + let Some(path) = self.main_transcript.clone() else { + return; + }; + let records = self + .transcripts + .get_mut(&path) + .map(|cursor| std::mem::take(&mut cursor.buffered)) + .unwrap_or_default(); + let parsed = parse_transcript(&records, std::mem::take(&mut self.main_history)); + self.main_history = parsed.history.clone(); + self.emit_parsed(parsed, "main", parent, ops); + } + + fn emit_transcript( + &mut self, + records: &[Value], + scope: &str, + parent: &str, + ops: &mut Vec, + ) { + let parsed = parse_transcript(records, Vec::new()); + self.emit_parsed(parsed, scope, parent, ops); + } + + fn emit_parsed( + &mut self, + parsed: ParsedTranscript, + scope: &str, + parent: &str, + ops: &mut Vec, + ) { + for call in parsed.calls { + let request_key = format!("{scope}:{}", call.request_id); + if self.emitted_requests.insert(request_key.clone()) { + let span_key = format!("{scope}:llm:{}", call.request_id); + ops.push(SpanOp::Insert(call.into_row( + ids::span_id(&self.session_id, &span_key), + self.root_span_id.clone(), + parent.to_string(), + ))); + } + } + for tool in parsed.tools { + if self.emitted_tools.insert(tool.call_id.clone()) { + let span_key = format!("tool:{}", tool.call_id); + ops.push(SpanOp::Insert(tool.into_row( + ids::span_id(&self.session_id, &span_key), + self.root_span_id.clone(), + parent.to_string(), + ))); + } + } + } + + fn flush_previous_turn_rows(&mut self, ops: &mut Vec) { + let (Some(path), Some(parent)) = (self.main_transcript.clone(), self.last_turn_id.clone()) + else { + return; + }; + let Some(cursor) = self.transcripts.get_mut(&path) else { + return; + }; + let split = cursor + .buffered + .iter() + .rposition(is_real_user_record) + .unwrap_or(cursor.buffered.len()); + let current_turn_rows = cursor.buffered.split_off(split); + let previous_rows = std::mem::replace(&mut cursor.buffered, current_turn_rows); + let parsed = parse_transcript(&previous_rows, std::mem::take(&mut self.main_history)); + self.main_history = parsed.history.clone(); + self.emit_parsed(parsed, "main", &parent, ops); + } + + fn stop_turn(&mut self, event: &Envelope, error: Option, ops: &mut Vec) { + let Some(turn_id) = self.turn.as_ref().map(|turn| turn.id.clone()) else { + return; + }; + self.emit_main(&turn_id, ops); + self.close_pending_tools( + &turn_id, + event.ts_ms, + "Turn ended before tool completion", + ops, + ); + ops.push(SpanOp::Merge(SpanRow { + span_id: turn_id.clone(), + root_span_id: self.root_span_id.clone(), + end_ms: Some(event.ts_ms), + output: event + .payload + .get("last_assistant_message") + .cloned() + .or_else(|| event.payload.get("output").cloned()), + error, + ..Default::default() + })); + self.last_turn_id = Some(turn_id); + self.turn = None; + self.pending_skills.clear(); + } + + fn close_pending_tools( + &mut self, + parent_id: &str, + end_ms: i64, + error: &str, + ops: &mut Vec, + ) { + let ids: Vec = self + .pending_tools + .iter() + .filter(|(_, tool)| tool.parent_id == parent_id) + .map(|(id, _)| id.clone()) + .collect(); + for id in ids { + if let Some(tool) = self.pending_tools.remove(&id) { + self.emitted_tools.insert(id); + ops.push(SpanOp::Merge(SpanRow { + span_id: tool.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(end_ms), + error: Some(error.to_string()), + ..Default::default() + })); + } + } + } + + fn end_session(&mut self, event: &Envelope, ops: &mut Vec) { + if let Some(turn_id) = self + .turn + .as_ref() + .map(|turn| turn.id.clone()) + .or_else(|| self.last_turn_id.clone()) + { + self.emit_main(&turn_id, ops); + } + if let Some(turn) = self.turn.take() { + self.close_pending_tools( + &turn.id, + event.ts_ms, + "Session ended before tool completion", + ops, + ); + ops.push(SpanOp::Merge(SpanRow { + span_id: turn.id.clone(), + root_span_id: self.root_span_id.clone(), + end_ms: Some(event.ts_ms), + ..Default::default() + })); + self.last_turn_id = Some(turn.id); + } + if self.root_open && !self.root_ended { + self.root_ended = true; + ops.push(SpanOp::Merge(SpanRow { + span_id: self.session_span_id.clone(), + root_span_id: self.root_span_id.clone(), + end_ms: Some(event.ts_ms), + ..Default::default() + })); + } + } +} + +impl AgentTranslator for ClaudeTranslator { + fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result> { + let mut ops = Vec::new(); + self.tail_main(event); + self.ensure_root(event, ctx, &mut ops); + if !self.claude_version_logged { + if let Some(version) = &self.claude_version { + self.claude_version_logged = true; + ops.push(SpanOp::Merge(SpanRow { + span_id: self.session_span_id.clone(), + root_span_id: self.root_span_id.clone(), + metadata: Some(json!({ "claude_code_version": version })), + ..Default::default() + })); + } + } + match event.event.as_str() { + "SessionStart" => {} + "UserPromptSubmit" => { + self.flush_previous_turn_rows(&mut ops); + self.open_turn(event, &mut ops); + } + "UserPromptExpansion" => self.record_skill(event, &mut ops), + "PreToolUse" => self.pre_tool(event, &mut ops), + "PostToolUse" => self.finish_tool(event, "approved", None, &mut ops), + "PostToolUseFailure" => self.finish_tool( + event, + "approved", + tool_error(&event.payload) + .or_else(|| { + event + .payload + .pointer("/tool_response/output") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + }) + .or_else(|| Some("Tool execution failed".into())), + &mut ops, + ), + "PermissionDenied" => self.finish_tool(event, "denied", None, &mut ops), + "SubagentStart" => { + if let Some(agent_id) = string_field(&event.payload, "agent_id") { + self.ensure_subagent(&agent_id, event, &mut ops); + } + } + "SubagentStop" => self.stop_subagent(event, &mut ops), + "Stop" => self.stop_turn(event, None, &mut ops), + "StopFailure" => self.stop_turn( + event, + tool_error(&event.payload).or_else(|| Some("Claude Code turn failed".into())), + &mut ops, + ), + "SessionEnd" => self.end_session(event, &mut ops), + _ => {} + } + Ok(ops) + } + + fn flush(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { + Ok(Vec::new()) + } +} + +struct ParsedTranscript { + calls: Vec, + tools: Vec, + history: Vec, +} + +fn parse_transcript(records: &[Value], mut history: Vec) -> ParsedTranscript { + let mut calls = Vec::::new(); + let mut call_indexes = HashMap::::new(); + let mut assistant_history_indexes = HashMap::::new(); + let mut tools = HashMap::::new(); + let mut tool_order = Vec::::new(); + + for record in records { + match record.get("type").and_then(Value::as_str) { + Some("assistant") => { + let request_id = record + .get("message") + .and_then(|message| string_field(message, "id")) + .or_else(|| string_field(record, "requestId")) + .or_else(|| string_field(record, "uuid")) + .unwrap_or_default(); + if request_id.is_empty() { + continue; + } + let index = *call_indexes.entry(request_id.clone()).or_insert_with(|| { + let index = calls.len(); + calls.push(LlmCall::new( + request_id.clone(), + parse_timestamp_ms(record).unwrap_or(0), + history.clone(), + )); + index + }); + calls[index].observe(record); + let output = calls[index].output_message(); + if let Some(history_index) = assistant_history_indexes.get(&request_id) { + history[*history_index] = output; + } else { + assistant_history_indexes.insert(request_id.clone(), history.len()); + history.push(output); + } + if let Some(content) = record.pointer("/message/content").and_then(Value::as_array) + { + for block in content { + if block.get("type").and_then(Value::as_str) == Some("tool_use") { + let Some(call_id) = string_field(block, "id") else { + continue; + }; + if !tools.contains_key(&call_id) { + tool_order.push(call_id.clone()); + tools.insert( + call_id.clone(), + TranscriptTool { + call_id, + tool_name: string_field(block, "name") + .unwrap_or_else(|| "Tool".into()), + input: block + .get("input") + .cloned() + .unwrap_or_else(|| json!({})), + output: None, + error: None, + start_ms: parse_timestamp_ms(record).unwrap_or(0), + end_ms: parse_timestamp_ms(record).unwrap_or(0), + }, + ); + } + } + } + } + } + Some("user") => { + let content = record + .pointer("/message/content") + .cloned() + .unwrap_or(Value::Null); + if let Some(blocks) = content.as_array() { + let mut had_tool_result = false; + for block in blocks { + if block.get("type").and_then(Value::as_str) != Some("tool_result") { + continue; + } + had_tool_result = true; + let call_id = string_field(block, "tool_use_id").unwrap_or_default(); + let result = block.get("content").cloned().unwrap_or(Value::Null); + history.push(json!({ + "role": "tool", + "tool_call_id": call_id, + "content": result + })); + if let Some(tool) = tools.get_mut(&call_id) { + tool.output = Some(result); + tool.end_ms = parse_timestamp_ms(record).unwrap_or(tool.start_ms); + if block + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false) + { + tool.error = Some("Tool execution failed".into()); + } + } + } + if !had_tool_result { + history.push(json!({ "role": "user", "content": content })); + } + } else if !content.is_null() { + history.push(json!({ "role": "user", "content": content })); + } + } + _ => {} + } + } + ParsedTranscript { + calls, + tools: tool_order + .into_iter() + .filter_map(|id| tools.remove(&id)) + .collect(), + history, + } +} + +fn is_real_user_record(record: &Value) -> bool { + if record.get("type").and_then(Value::as_str) != Some("user") { + return false; + } + !record + .pointer("/message/content") + .and_then(Value::as_array) + .is_some_and(|blocks| { + blocks + .iter() + .any(|block| block.get("type").and_then(Value::as_str) == Some("tool_result")) + }) +} + +struct LlmCall { + request_id: String, + model: String, + start_ms: i64, + end_ms: i64, + input: Vec, + text: Vec, + tool_calls: Vec, + prompt_tokens: u64, + completion_tokens: u64, + cache_creation_tokens: u64, + cache_creation_5m_tokens: u64, + cache_creation_1h_tokens: u64, + cache_read_tokens: u64, + error: Option, + api_error_status: Option, +} + +impl LlmCall { + fn new(request_id: String, start_ms: i64, input: Vec) -> Self { + Self { + request_id, + model: "claude".into(), + start_ms, + end_ms: start_ms, + input, + text: Vec::new(), + tool_calls: Vec::new(), + prompt_tokens: 0, + completion_tokens: 0, + cache_creation_tokens: 0, + cache_creation_5m_tokens: 0, + cache_creation_1h_tokens: 0, + cache_read_tokens: 0, + error: None, + api_error_status: None, + } + } + + fn observe(&mut self, record: &Value) { + self.end_ms = parse_timestamp_ms(record).unwrap_or(self.end_ms); + if record + .get("isApiErrorMessage") + .and_then(Value::as_bool) + .unwrap_or(false) + { + self.error = string_field(record, "error") + .or_else(|| { + record + .pointer("/message/content") + .and_then(Value::as_array) + .and_then(|content| { + content.iter().find_map(|block| { + (block.get("type").and_then(Value::as_str) == Some("text")) + .then(|| block.get("text").and_then(Value::as_str)) + .flatten() + }) + }) + .map(str::to_owned) + }) + .or_else(|| Some("Claude API error".into())); + self.api_error_status = record.get("apiErrorStatus").and_then(Value::as_u64); + } + let Some(message) = record.get("message") else { + return; + }; + if let Some(model) = string_field(message, "model") { + self.model = model; + } + if let Some(content) = message.get("content").and_then(Value::as_array) { + for block in content { + match block.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(text) = block.get("text").and_then(Value::as_str) { + if !self.text.iter().any(|seen| seen == text) { + self.text.push(text.to_string()); + } + } + } + Some("tool_use") => { + let arguments = + serde_json::to_string(block.get("input").unwrap_or(&Value::Null)) + .unwrap_or_else(|_| "{}".into()); + let call = json!({ + "id": block.get("id").cloned().unwrap_or(Value::Null), + "type": "function", + "function": { + "name": block.get("name").cloned().unwrap_or(Value::Null), + "arguments": arguments + } + }); + if !self.tool_calls.contains(&call) { + self.tool_calls.push(call); + } + } + _ => {} + } + } + } + if let Some(usage) = message.get("usage") { + self.prompt_tokens = self.prompt_tokens.max(u64_field(usage, "input_tokens")); + self.completion_tokens = self + .completion_tokens + .max(u64_field(usage, "output_tokens")); + self.cache_creation_tokens = self + .cache_creation_tokens + .max(u64_field(usage, "cache_creation_input_tokens")); + self.cache_read_tokens = self + .cache_read_tokens + .max(u64_field(usage, "cache_read_input_tokens")); + if let Some(cache) = usage.get("cache_creation") { + self.cache_creation_5m_tokens = self + .cache_creation_5m_tokens + .max(u64_field(cache, "ephemeral_5m_input_tokens")); + self.cache_creation_1h_tokens = self + .cache_creation_1h_tokens + .max(u64_field(cache, "ephemeral_1h_input_tokens")); + } + } + } + + fn output_message(&self) -> Value { + let content = self.text.join("\n"); + if self.tool_calls.is_empty() { + json!({ "role": "assistant", "content": content }) + } else { + json!({ "role": "assistant", "content": content, "tool_calls": self.tool_calls }) + } + } + + fn into_row(self, span_id: String, root_span_id: String, parent: String) -> SpanRow { + let has_split = self.cache_creation_5m_tokens > 0 || self.cache_creation_1h_tokens > 0; + let creation = if has_split { + self.cache_creation_5m_tokens + self.cache_creation_1h_tokens + } else { + self.cache_creation_tokens + }; + let prompt = self.prompt_tokens + self.cache_read_tokens + creation; + let mut metrics = Map::new(); + metrics.insert("prompt_tokens".into(), json!(prompt)); + metrics.insert("completion_tokens".into(), json!(self.completion_tokens)); + metrics.insert("tokens".into(), json!(prompt + self.completion_tokens)); + metrics.insert("prompt_cached_tokens".into(), json!(self.cache_read_tokens)); + if has_split { + metrics.insert( + "prompt_cache_creation_5m_tokens".into(), + json!(self.cache_creation_5m_tokens), + ); + metrics.insert( + "prompt_cache_creation_1h_tokens".into(), + json!(self.cache_creation_1h_tokens), + ); + } else { + metrics.insert( + "prompt_cache_creation_tokens".into(), + json!(self.cache_creation_tokens), + ); + } + let output = self.output_message(); + let mut metadata = Map::new(); + metadata.insert("model".into(), json!(self.model)); + metadata.insert("request_id".into(), json!(self.request_id)); + if let Some(status) = self.api_error_status { + metadata.insert("api_error_status".into(), json!(status)); + } + SpanRow { + span_id, + root_span_id, + parent_span_ids: vec![parent], + name: self.model.clone(), + span_type: SpanType::Llm, + start_ms: Some(self.start_ms), + end_ms: Some(self.end_ms), + input: Some(Value::Array(self.input)), + output: Some(output), + metadata: Some(Value::Object(metadata)), + metrics: Some(Value::Object(metrics)), + error: self.error, + ..Default::default() + } + } +} + +struct TranscriptTool { + call_id: String, + tool_name: String, + input: Value, + output: Option, + error: Option, + start_ms: i64, + end_ms: i64, +} + +impl TranscriptTool { + fn into_row(self, span_id: String, root_span_id: String, parent: String) -> SpanRow { + SpanRow { + span_id, + root_span_id, + parent_span_ids: vec![parent], + name: tool_span_name(&self.tool_name, &self.input), + span_type: SpanType::Tool, + start_ms: Some(self.start_ms), + end_ms: Some(self.end_ms), + input: Some(self.input), + output: self.output, + metadata: Some(json!({ + "tool_name": self.tool_name, + "tool_approval": "approved", + "tool_call_id": self.call_id, + "recovered_from_transcript": true + })), + error: self.error, + ..Default::default() + } + } +} + +fn read_records_until(path: &str, offset: &mut u64, cutoff_ms: i64) -> Vec { + let Ok(mut file) = std::fs::File::open(path) else { + return Vec::new(); + }; + let len = file.metadata().map(|m| m.len()).unwrap_or(0); + if *offset > len { + *offset = 0; + } + if file.seek(SeekFrom::Start(*offset)).is_err() { + return Vec::new(); + } + let mut reader = std::io::BufReader::new(file); + read_buffered_until(&mut reader, offset, cutoff_ms) +} + +fn read_buffered_until( + reader: &mut std::io::BufReader, + offset: &mut u64, + cutoff_ms: i64, +) -> Vec { + let mut records = Vec::new(); + let mut line = String::new(); + loop { + line.clear(); + let start = *offset; + let Ok(read) = reader.read_line(&mut line) else { + break; + }; + if read == 0 { + break; + } + let Ok(value) = serde_json::from_str::(line.trim()) else { + *offset += read as u64; + continue; + }; + if parse_timestamp_ms(&value).is_some_and(|timestamp| timestamp > cutoff_ms) { + *offset = start; + break; + } + *offset += read as u64; + records.push(value); + } + records +} + +fn read_event_records(event: &Envelope, path: &str, offset: &mut u64) -> Vec { + let import_through_offset = event + .payload + .get("_bt_import_through_offset") + .and_then(Value::as_u64); + let snapshot = event + .payload + .get("_bt_transcript_snapshot") + .filter(|snapshot| snapshot.get("path").and_then(Value::as_str) == Some(path)) + .and_then(|snapshot| snapshot.get("contents")) + .and_then(Value::as_str); + match (snapshot, import_through_offset) { + (Some(contents), Some(through)) => read_snapshot_through_offset(contents, offset, through), + (None, Some(through)) => read_records_through_offset(path, offset, through), + (Some(contents), None) => read_snapshot_until(contents, offset, event.ts_ms), + (None, None) => read_records_until(path, offset, event.ts_ms), + } +} + +fn read_records_through_offset(path: &str, offset: &mut u64, through: u64) -> Vec { + let Ok(mut file) = std::fs::File::open(path) else { + return Vec::new(); + }; + let len = file.metadata().map(|metadata| metadata.len()).unwrap_or(0); + if *offset > len { + *offset = 0; + } + if file.seek(SeekFrom::Start(*offset)).is_err() { + return Vec::new(); + } + read_buffered_through_offset(&mut std::io::BufReader::new(file), offset, through.min(len)) +} + +fn read_snapshot_through_offset(contents: &str, offset: &mut u64, through: u64) -> Vec { + if *offset > contents.len() as u64 { + *offset = 0; + } + let mut reader = std::io::BufReader::new(std::io::Cursor::new(contents.as_bytes())); + if reader.seek(SeekFrom::Start(*offset)).is_err() { + return Vec::new(); + } + read_buffered_through_offset(&mut reader, offset, through.min(contents.len() as u64)) +} + +fn read_buffered_through_offset( + reader: &mut std::io::BufReader, + offset: &mut u64, + through: u64, +) -> Vec { + let mut records = Vec::new(); + let mut line = String::new(); + while *offset < through { + line.clear(); + let start = *offset; + let Ok(read) = reader.read_line(&mut line) else { + break; + }; + if read == 0 || start + read as u64 > through { + break; + } + *offset += read as u64; + if let Ok(value) = serde_json::from_str::(line.trim()) { + records.push(value); + } + } + records +} + +fn read_snapshot_until(contents: &str, offset: &mut u64, cutoff_ms: i64) -> Vec { + if *offset > contents.len() as u64 { + *offset = 0; + } + let mut reader = std::io::BufReader::new(std::io::Cursor::new(contents.as_bytes())); + if reader.seek(SeekFrom::Start(*offset)).is_err() { + return Vec::new(); + } + read_buffered_until(&mut reader, offset, cutoff_ms) +} + +fn tool_metadata( + event: &Envelope, + tool_name: &str, + call_id: &str, + approval: &str, + input: &Value, +) -> Value { + let mut metadata = Map::new(); + metadata.insert("tool_name".into(), json!(tool_name)); + metadata.insert("tool_approval".into(), json!(approval)); + metadata.insert("tool_call_id".into(), json!(call_id)); + for (target, direct, nested) in [ + ("permission_id", "permission_id", "/permission/id"), + ("permission_type", "permission_type", "/permission/type"), + ("permission_title", "permission_title", "/permission/title"), + ] { + if let Some(value) = string_field(&event.payload, direct).or_else(|| { + event + .payload + .pointer(nested) + .and_then(Value::as_str) + .map(str::to_owned) + }) { + metadata.insert(target.into(), json!(value)); + } + } + if tool_name == "Skill" { + let skill_name = ["name", "skill", "skill_name", "skillName"] + .iter() + .find_map(|key| string_field(input, key)); + metadata.insert("tool_kind".into(), json!("skill")); + metadata.insert("skill_name".into(), json!(skill_name)); + metadata.insert("skill_load_trigger".into(), json!("explicit")); + } + Value::Object(metadata) +} + +fn tool_input(payload: &Value) -> Value { + payload + .get("tool_input") + .or_else(|| payload.get("input")) + .cloned() + .unwrap_or_else(|| json!({})) +} + +fn tool_output(payload: &Value) -> Option { + payload + .get("tool_response") + .or_else(|| payload.get("output")) + .cloned() + .or_else(|| payload.pointer("/tool_response/output").cloned()) +} + +fn tool_name(payload: &Value) -> Option { + string_field(payload, "tool_name").or_else(|| string_field(payload, "tool")) +} + +fn parse_timestamp_ms(value: &Value) -> Option { + chrono::DateTime::parse_from_rfc3339(value.get("timestamp")?.as_str()?) + .ok() + .map(|timestamp| timestamp.timestamp_millis()) +} + +fn string_field(value: &Value, key: &str) -> Option { + value.get(key).and_then(|value| match value { + Value::String(text) if !text.is_empty() => Some(text.clone()), + Value::Number(number) => Some(number.to_string()), + _ => None, + }) +} + +fn u64_field(value: &Value, key: &str) -> u64 { + value.get(key).and_then(Value::as_u64).unwrap_or(0) +} + +fn basename(path: &str) -> String { + std::path::Path::new(path) + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.is_empty()) + .unwrap_or("workspace") + .to_string() +} + +fn normalize_skill_name(name: &str) -> String { + name.trim() + .trim_start_matches('/') + .trim_end_matches([',', ')', '.', ';', ':']) + .trim() + .to_string() +} + +fn skill_listing_contains(path: &str, name: &str) -> bool { + let Ok(file) = std::fs::File::open(path) else { + return false; + }; + std::io::BufReader::new(file) + .lines() + .map_while(Result::ok) + .any(|line| { + serde_json::from_str::(&line) + .ok() + .and_then(|row| { + row.pointer("/attachment/names") + .and_then(Value::as_array) + .cloned() + }) + .is_some_and(|names| { + names + .iter() + .any(|candidate| candidate.as_str() == Some(name)) + }) + }) +} + +fn explicit_skill_metadata(names: &[String]) -> Option { + (!names.is_empty()).then(|| { + json!({ + "loaded_skill_names": names, + "loaded_skills": names.iter().map(|name| json!({ "name": name })).collect::>() + }) + }) +} + +fn hostname() -> String { + std::env::var("HOSTNAME") + .ok() + .filter(|value| !value.is_empty()) + .or_else(|| command_output("", "hostname", &[])) + .unwrap_or_default() +} + +fn username() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_default() +} + +fn command_output(cwd: &str, command: &str, args: &[&str]) -> Option { + let mut process = Command::new(command); + if !cwd.is_empty() { + process.current_dir(cwd); + } + let output = process + .args(args) + .env("GIT_OPTIONAL_LOCKS", "0") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8(output.stdout).ok()?.trim().to_string(); + (!value.is_empty()).then_some(value) +} + +fn git_metadata(cwd: &str) -> Map { + if cwd.is_empty() { + return Map::new(); + } + let mut metadata = Map::new(); + if let Some(mut origin) = command_output(cwd, "git", &["remote", "get-url", "origin"]) { + if let Some(scheme) = origin.find("://") { + let start = scheme + 3; + let end = origin[start..] + .find('/') + .map(|offset| start + offset) + .unwrap_or(origin.len()); + if let Some(at) = origin[start..end].rfind('@') { + origin = format!("{}{}", &origin[..start], &origin[start + at + 1..]); + } + } + metadata.insert("git_origin_url".into(), json!(origin)); + } + if let Some(branch) = + command_output(cwd, "git", &["symbolic-ref", "--quiet", "--short", "HEAD"]) + { + metadata.insert("git_branch".into(), json!(branch)); + } + if let Some(commit) = command_output(cwd, "git", &["rev-parse", "HEAD"]) { + metadata.insert("git_commit_sha".into(), json!(commit)); + } + metadata +} + +fn tool_span_name(tool: &str, input: &Value) -> String { + match tool { + "Skill" => string_field(input, "name") + .or_else(|| string_field(input, "skill")) + .map(|name| format!("skill: {name}")) + .unwrap_or_else(|| "skill".into()), + "Read" | "Write" | "Edit" | "MultiEdit" => string_field(input, "file_path") + .or_else(|| string_field(input, "path")) + .map(|path| format!("{tool}: {}", basename(&path))) + .unwrap_or_else(|| tool.to_string()), + "Bash" | "Terminal" => { + let command = string_field(input, "command").unwrap_or_else(|| "command".into()); + format!("Terminal: {}", command.chars().take(50).collect::()) + } + name if name.starts_with("mcp__") => { + format!( + "MCP: {}", + name.trim_start_matches("mcp__").replace("__", " - ") + ) + } + _ => tool.to_string(), + } +} + +fn tool_error(payload: &Value) -> Option { + for value in [ + payload.get("error"), + payload.get("message"), + payload.pointer("/tool_response/error"), + payload.pointer("/tool_response/stderr"), + payload.pointer("/tool_response/message"), + ] + .into_iter() + .flatten() + { + if let Some(text) = value.as_str().filter(|value| !value.is_empty()) { + return Some(text.lines().next().unwrap_or(text).to_string()); + } + } + let response = payload.get("tool_response")?; + let failed = response + .get("interrupted") + .and_then(Value::as_bool) + .unwrap_or(false) + || response + .get("is_error") + .or_else(|| response.get("isError")) + .and_then(Value::as_bool) + .unwrap_or(false) + || response + .get("status") + .and_then(Value::as_str) + .is_some_and(|value| matches!(value, "error" | "failed")); + failed.then(|| "Tool execution failed".to_string()) +} diff --git a/bt-daemon/src/translate/codex.rs b/bt-daemon/src/translate/codex.rs new file mode 100644 index 0000000..6312177 --- /dev/null +++ b/bt-daemon/src/translate/codex.rs @@ -0,0 +1,1611 @@ +//! Codex translator. +//! +//! Ported from the TS `trace-codex` event-processor. Codex hook events are only +//! *triggers*; the session transcript ("rollout" JSONL at `transcript_path`) is +//! the source of truth for LLM calls, token usage, and execution order. On each +//! hook event this reads the relevant transcript from a saved byte offset and +//! turns new records into spans. +//! +//! Hierarchy: root (session, task) → turn (task) → { llm, tool } spans. +//! Subagents get their own transcript *scope* whose turns hang under a +//! `subagent` root span that is a sibling of the `spawn_agent` tool span (both +//! under the spawning turn). Compaction turns are relabeled `compaction` with a +//! synthetic llm span showing the before/after context. +//! +//! Turn-terminal transcript *polling* (TS waits up to 10s for a late +//! `task_complete`) is replaced by re-reading on the next event and on +//! `flush()`. Native turn ids keep those late records correlated even when a +//! newer turn has already started. + +use super::{AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory}; +use crate::ids; +use crate::wire::Envelope; +use regex::Regex; +use serde_json::{json, Map, Value}; +use std::collections::{HashMap, HashSet}; +use std::path::Path; +use std::process::Command; +use std::sync::OnceLock; + +const SPAWN_AGENT_TOOL: &str = "spawn_agent"; +const MISSING_TOOL_OUTPUT_ERROR: &str = "Tool output missing before turn ended"; + +pub struct CodexTranslatorFactory; + +impl TranslatorFactory for CodexTranslatorFactory { + fn source(&self) -> &str { + "codex" + } + fn create(&self, session_id: &str) -> Box { + Box::new(CodexTranslator { + session_id: session_id.to_string(), + root_span_id: ids::span_id(session_id, "root"), + root_opened: false, + root_ended: false, + source: None, + permission_mode: None, + root_cwd: None, + project: None, + additional_metadata: Map::new(), + main_path: None, + // The main scope is created lazily once we learn its transcript path. + scopes: HashMap::new(), + spawn_turn_by_call_id: HashMap::new(), + spawn_turn_by_agent_id: HashMap::new(), + compaction_trigger_by_turn: HashMap::new(), + compaction_spans: HashSet::new(), + }) + } +} + +#[derive(PartialEq, Eq, Clone, Copy)] +enum ScopeKind { + Main, + Subagent, +} + +struct OpenTurn { + turn_id: String, + span_id: String, + start_ms: i64, + last_child_end_ms: Option, + llm_seq: u32, + explicit_skill_names: Vec, +} + +struct OpenLlm { + span_id: String, + turn_id: String, + start_ms: i64, + last_output_ms: i64, + output: Vec, + output_preset: bool, +} + +struct Scope { + path: String, + kind: ScopeKind, + offset: u64, + /// Parent span id for this scope's turn spans (main root, or subagent root). + turn_parent_span_id: String, + /// Whether this scope's session/root span has been emitted. + root_created: bool, + model: Option, + open_turns: Vec, + conversation_history: Vec, + open_llm: Option, + open_tools: HashMap, // call_id -> (tool span_id, turn_id) + last_turn_end_ms: Option, + turn_seq: u32, + // Subagent-only: + agent_id: Option, + agent_type: Option, + spawning_turn_span_id: Option, + subagent_ended: bool, +} + +struct CodexTranslator { + session_id: String, + root_span_id: String, + root_opened: bool, + root_ended: bool, + source: Option, + permission_mode: Option, + root_cwd: Option, + project: Option, + additional_metadata: Map, + main_path: Option, + scopes: HashMap, + spawn_turn_by_call_id: HashMap, + spawn_turn_by_agent_id: HashMap, + compaction_trigger_by_turn: HashMap, + compaction_spans: HashSet, +} + +impl AgentTranslator for CodexTranslator { + fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result> { + let payload = &event.payload; + let mut ops = Vec::new(); + + if let Some(config) = &ctx.config { + self.project = config.project.clone(); + self.additional_metadata = config + .additional_metadata + .as_ref() + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + } + + // --- hook-specific side effects (before catch-up) --- + match event.event.as_str() { + "SessionStart" => { + self.source = str_field(payload, "source"); + self.permission_mode = str_field(payload, "permission_mode"); + } + "SubagentStart" => self.handle_subagent_start(payload), + "PreCompact" | "PostCompact" => self.record_compaction_trigger(payload, &mut ops), + _ => {} + } + + // --- pick the scope and catch up its transcript --- + let agent_id = str_field(payload, "agent_id"); + let path = if event.event == "SubagentStop" { + str_field(payload, "agent_transcript_path") + } else { + str_field(payload, "transcript_path") + .or_else(|| str_field(payload, "agent_transcript_path")) + }; + + if let Some(path) = path { + if agent_id.is_none() { + self.main_path.get_or_insert(path.clone()); + self.ensure_main_scope(&path); + } + let import_through_ms = payload.get("_bt_import_through_ms").and_then(Value::as_i64); + self.catch_up(&path, event.ts_ms, import_through_ms, &mut ops); + } + + // --- hook-specific handling (after catch-up) --- + match event.event.as_str() { + // Catch up first: this same hook may be the first observation of + // the spawn_agent transcript record that establishes call -> turn. + "PostToolUse" if agent_id.is_none() => self.record_spawned_agent(payload), + "SubagentStop" => { + if let Some(p) = str_field(payload, "agent_transcript_path") { + self.close_subagent(&p, event.ts_ms, &mut ops); + } + } + "Stop" if agent_id.is_none() => { + // Codex writes task_complete slightly after the Stop hook in + // real sessions. Close the active turn from the hook payload + // now so a short-lived process cannot flush an open turn. + self.close_main_turn(payload, event.ts_ms, &mut ops); + self.end_main_root(event.ts_ms, &mut ops); + } + "PostCompact" => self.close_compaction_turn(payload, event.ts_ms, &mut ops), + _ => {} + } + + Ok(ops) + } + + fn flush(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { + let mut ops = Vec::new(); + // Re-read each scope to catch a late task_complete, then close dangling. + let paths: Vec = self.scopes.keys().cloned().collect(); + for path in paths { + self.catch_up(&path, 0, None, &mut ops); + if let Some(mut scope) = self.scopes.remove(&path) { + self.close_dangling(&mut scope, None, &mut ops); + self.scopes.insert(path, scope); + } + } + Ok(ops) + } +} + +impl CodexTranslator { + fn ensure_main_scope(&mut self, path: &str) { + if self.scopes.contains_key(path) { + return; + } + let turn_parent = self.root_span_id.clone(); + self.scopes.insert( + path.to_string(), + Scope::new(path, ScopeKind::Main, turn_parent), + ); + } + + fn record_compaction_trigger(&mut self, payload: &Value, ops: &mut Vec) { + let Some(turn_id) = str_field(payload, "turn_id") else { + return; + }; + let trigger = str_field(payload, "trigger").unwrap_or_else(|| "manual".to_string()); + self.compaction_trigger_by_turn + .insert(turn_id.clone(), trigger.clone()); + // Back-fill onto an already-built compaction span. + if self.compaction_spans.contains(&turn_id) { + let span_id = ids::span_id(&self.session_id, &format!("turn:{turn_id}")); + ops.push(SpanOp::Merge(SpanRow { + span_id, + root_span_id: self.root_span_id.clone(), + metadata: Some(json!({ "compaction": { "trigger": trigger } })), + ..Default::default() + })); + } + } + + fn record_spawned_agent(&mut self, payload: &Value) { + if str_field(payload, "tool_name").as_deref() != Some(SPAWN_AGENT_TOOL) { + return; + } + let Some(call_id) = str_field(payload, "tool_use_id") else { + return; + }; + let agent_id = payload.get("tool_response").and_then(|r| match r { + Value::String(s) => serde_json::from_str::(s) + .ok() + .and_then(|v| v.get("agent_id").and_then(Value::as_str).map(String::from)), + Value::Object(_) => r.get("agent_id").and_then(Value::as_str).map(String::from), + _ => None, + }); + let Some(agent_id) = agent_id else { return }; + if let Some(turn_span) = self.spawn_turn_by_call_id.get(&call_id) { + self.spawn_turn_by_agent_id + .insert(agent_id, turn_span.clone()); + } + } + + fn handle_subagent_start(&mut self, payload: &Value) { + let (Some(agent_id), Some(path)) = ( + str_field(payload, "agent_id"), + str_field(payload, "transcript_path"), + ) else { + return; + }; + if self.scopes.contains_key(&path) { + return; + } + let parent = self + .spawn_turn_by_agent_id + .get(&agent_id) + .cloned() + .unwrap_or_else(|| self.root_span_id.clone()); + let subagent_root = ids::span_id(&self.session_id, &format!("subagent:{agent_id}")); + let mut scope = Scope::new(&path, ScopeKind::Subagent, subagent_root); + scope.agent_id = Some(agent_id); + scope.agent_type = str_field(payload, "agent_type"); + scope.spawning_turn_span_id = Some(parent); + self.scopes.insert(path, scope); + } + + /// Read new transcript lines for `path` and process them against its scope. + fn catch_up( + &mut self, + path: &str, + hook_ts: i64, + through_ms: Option, + ops: &mut Vec, + ) { + let Some(mut scope) = self.scopes.remove(path) else { + return; + }; + let lines = read_new_lines(&scope.path, &mut scope.offset, through_ms); + for line in lines { + if let Ok(rec) = serde_json::from_str::(&line) { + self.process_record(&mut scope, &rec, hook_ts, ops); + } + } + self.scopes.insert(path.to_string(), scope); + } + + fn process_record( + &mut self, + scope: &mut Scope, + rec: &Value, + hook_ts: i64, + ops: &mut Vec, + ) { + let ts = parse_ts(rec).unwrap_or(hook_ts); + let ty = rec.get("type").and_then(Value::as_str).unwrap_or(""); + let payload = rec.get("payload").cloned().unwrap_or(Value::Null); + + match ty { + "session_meta" => self.open_root(scope, &payload, ts, ops), + "turn_context" => { + if let Some(m) = str_field(&payload, "model") { + scope.model = Some(m.clone()); + if scope.root_created { + let input = if scope.kind == ScopeKind::Main { + json!({ + "model": m, + "cwd": self.root_cwd, + "source": self.source, + }) + } else { + json!({ "model": m }) + }; + ops.push(SpanOp::Merge(SpanRow { + span_id: scope.turn_parent_span_id.clone(), + root_span_id: self.root_span_id.clone(), + input: Some(input), + metadata: Some(json!({ "model": m })), + ..Default::default() + })); + } + } + } + "event_msg" => { + let sub = payload.get("type").and_then(Value::as_str).unwrap_or(""); + match sub { + "task_started" => self.open_turn(scope, &payload, ts, ops), + "user_message" => self.set_turn_input(scope, &payload, ops), + "token_count" => self.close_llm_with_tokens(scope, &payload, ts, ops), + "task_complete" => self.close_turn(scope, &payload, ts, ops), + _ => {} + } + } + "response_item" => { + let sub = payload.get("type").and_then(Value::as_str).unwrap_or(""); + match sub { + "message" => self.on_message(scope, &payload, ts, ops), + "reasoning" => self.on_reasoning(scope, &payload, ts, ops), + "function_call" | "custom_tool_call" | "tool_search_call" => { + self.on_tool_call(scope, &payload, ts, ops) + } + "function_call_output" | "custom_tool_call_output" | "tool_search_output" => { + self.on_tool_output(scope, &payload, ts, ops) + } + _ => {} + } + } + "compacted" => self.on_compacted(scope, rec, &payload, ts, ops), + _ => {} + } + } + + fn open_root(&mut self, scope: &mut Scope, payload: &Value, ts: i64, ops: &mut Vec) { + match scope.kind { + ScopeKind::Main => { + if self.root_opened { + return; + } + self.root_opened = true; + let name = match str_field(payload, "cwd") { + Some(cwd) => format!("codex: {}", basename(&cwd)), + None => "codex session".to_string(), + }; + let cwd = str_field(payload, "cwd"); + self.root_cwd = cwd.clone(); + let mut md = self.additional_metadata.clone(); + for k in ["id", "cwd", "cli_version"] { + if let Some(v) = str_field(payload, k) { + md.insert( + if k == "id" { + "session_id".into() + } else { + k.to_string() + }, + json!(v), + ); + } + } + if let Some(s) = &self.source { + md.insert("source".into(), json!(s)); + } + if let Some(pm) = &self.permission_mode { + md.insert("permission_mode".into(), json!(pm)); + } + if let Some(tp) = &self.main_path { + md.insert("transcript_path".into(), json!(tp)); + } + if let Some(m) = &scope.model { + md.insert("model".into(), json!(m)); + } + if let Some(project) = &self.project { + md.insert("project".into(), json!(project)); + } + if let Some(cwd) = &cwd { + for (key, value) in git_metadata(cwd) { + md.insert(key, value); + } + } + scope.root_created = true; + ops.push(SpanOp::Insert(SpanRow { + span_id: self.root_span_id.clone(), + root_span_id: self.root_span_id.clone(), + name, + span_type: SpanType::Task, + start_ms: Some(ts), + input: Some(json!({ + "model": scope.model, + "cwd": cwd, + "source": self.source, + })), + metadata: Some(Value::Object(md)), + ..Default::default() + })); + } + ScopeKind::Subagent => { + if scope.root_created { + return; + } + scope.root_created = true; + let agent_id = scope.agent_id.clone().unwrap_or_default(); + let parent = scope + .spawning_turn_span_id + .clone() + .unwrap_or_else(|| self.root_span_id.clone()); + ops.push(SpanOp::Insert(SpanRow { + span_id: scope.turn_parent_span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![parent], + name: format!("subagent: {agent_id}"), + span_type: SpanType::Task, + start_ms: Some(ts), + metadata: Some(json!({ + "agent_id": agent_id, + "agent_type": scope.agent_type, + "transcript_path": scope.path, + })), + ..Default::default() + })); + } + } + } + + fn open_turn(&mut self, scope: &mut Scope, payload: &Value, ts: i64, ops: &mut Vec) { + let turn_id = str_field(payload, "turn_id").unwrap_or_else(|| { + scope.turn_seq += 1; + format!("turn-{}", scope.turn_seq) + }); + if scope.open_turns.iter().any(|turn| turn.turn_id == turn_id) { + return; + } + let span_id = ids::span_id(&self.session_id, &format!("turn:{turn_id}")); + ops.push(SpanOp::Insert(SpanRow { + span_id: span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![scope.turn_parent_span_id.clone()], + name: format!("turn: {turn_id}"), + span_type: SpanType::Task, + start_ms: Some(ts), + metadata: Some(json!({ "turn_id": turn_id, "model": scope.model })), + ..Default::default() + })); + scope.open_turns.push(OpenTurn { + turn_id, + span_id, + start_ms: ts, + last_child_end_ms: None, + llm_seq: 0, + explicit_skill_names: Vec::new(), + }); + } + + fn set_turn_input(&mut self, scope: &mut Scope, payload: &Value, ops: &mut Vec) { + let text = str_field(payload, "message") + .or_else(|| str_field(payload, "text")) + .or_else(|| str_field(payload, "prompt")); + let Some(text) = text else { return }; + // Explicit skill mentions in the prompt (e.g. "$skill", "/skills name"). + let names = explicit_skill_names(&text); + if let Some(turn) = scope.open_turns.last_mut() { + for n in names { + if !turn.explicit_skill_names.contains(&n) { + turn.explicit_skill_names.push(n); + } + } + ops.push(SpanOp::Merge(SpanRow { + span_id: turn.span_id.clone(), + root_span_id: self.root_span_id.clone(), + input: Some(json!(text)), + metadata: explicit_skill_metadata(&turn.explicit_skill_names), + ..Default::default() + })); + } + } + + fn ensure_llm( + &mut self, + scope: &mut Scope, + turn_id: Option<&str>, + _ts: i64, + ops: &mut Vec, + ) { + if scope.open_llm.is_some() { + return; + } + let index = turn_id + .and_then(|id| scope.open_turns.iter().position(|turn| turn.turn_id == id)) + .or_else(|| scope.open_turns.len().checked_sub(1)); + let Some(index) = index else { + return; + }; + let input = Value::Array(scope.conversation_history.clone()); + let turn = &mut scope.open_turns[index]; + let seq = turn.llm_seq; + turn.llm_seq += 1; + // Start where the model's work began — end of the turn's last child, or + // the turn's start for the first child — not the record time (which is + // when output landed, yielding a near-instant span). + let start = turn.last_child_end_ms.unwrap_or(turn.start_ms); + let span_id = ids::span_id(&self.session_id, &format!("llm:{}:{}", turn.turn_id, seq)); + let name = scope.model.clone().unwrap_or_else(|| "llm".to_string()); + let turn_span = turn.span_id.clone(); + let turn_id = turn.turn_id.clone(); + ops.push(SpanOp::Insert(SpanRow { + span_id: span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![turn_span], + name, + span_type: SpanType::Llm, + start_ms: Some(start), + input: Some(input), + metadata: Some(json!({ "model": scope.model, "turn_id": turn_id })), + ..Default::default() + })); + scope.open_llm = Some(OpenLlm { + span_id, + turn_id, + start_ms: start, + last_output_ms: start, + output: Vec::new(), + output_preset: false, + }); + } + + fn on_message(&mut self, scope: &mut Scope, payload: &Value, ts: i64, ops: &mut Vec) { + let role = str_field(payload, "role").unwrap_or_else(|| "user".to_string()); + let text = message_text(payload); + if text.is_empty() { + return; + } + let msg = json!({ "role": role, "content": text }); + if role == "assistant" { + self.ensure_llm(scope, None, ts, ops); + if let Some(llm) = &mut scope.open_llm { + llm.output.push(msg.clone()); + llm.last_output_ms = llm.last_output_ms.max(ts); + } + } else if role == "user" { + let names = explicit_skill_names(&text); + if let Some(turn) = scope.open_turns.last_mut() { + for name in names { + if !turn.explicit_skill_names.contains(&name) { + turn.explicit_skill_names.push(name); + } + } + if let Some(metadata) = explicit_skill_metadata(&turn.explicit_skill_names) { + ops.push(SpanOp::Merge(SpanRow { + span_id: turn.span_id.clone(), + root_span_id: self.root_span_id.clone(), + metadata: Some(metadata), + ..Default::default() + })); + } + } + } + scope.conversation_history.push(msg); + } + + fn on_reasoning(&mut self, scope: &mut Scope, payload: &Value, ts: i64, ops: &mut Vec) { + self.ensure_llm(scope, None, ts, ops); + if let Some(llm) = &mut scope.open_llm { + llm.last_output_ms = llm.last_output_ms.max(ts); + } + let summary: Vec = payload + .get("summary") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|s| { + str_field(s, "text") + .filter(|text| !text.is_empty()) + .map(|text| json!({ "type": "summary_text", "text": text })) + }) + .collect() + }) + .unwrap_or_default(); + if summary.is_empty() { + return; // encrypted reasoning: only opens/advances the span + } + let item = json!({ "type": "reasoning", "summary": summary }); + if let Some(llm) = &mut scope.open_llm { + llm.output.push(item.clone()); + } + scope.conversation_history.push(item); + } + + fn on_tool_call(&mut self, scope: &mut Scope, payload: &Value, ts: i64, ops: &mut Vec) { + let call_id = str_field(payload, "call_id"); + let tool_name = str_field(payload, "name").unwrap_or_else(|| { + payload + .get("type") + .and_then(Value::as_str) + .unwrap_or("tool") + .trim_end_matches("_call") + .to_string() + }); + let input = payload + .get("arguments") + .cloned() + .or_else(|| payload.get("input").cloned()); + let args_string = input + .as_ref() + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| { + serde_json::to_string(input.as_ref().unwrap_or(&Value::Null)).unwrap() + }); + let tool_call_message = json!({ + "role": "assistant", + "content": Value::Null, + "tool_calls": [{ + "id": call_id.clone().unwrap_or_default(), + "type": "function", + "function": { "name": tool_name, "arguments": args_string }, + }], + }); + scope.conversation_history.push(tool_call_message.clone()); + + let Some(call_id) = call_id else { return }; + let turn_id = payload + .get("metadata") + .and_then(|metadata| str_field(metadata, "turn_id")) + .or_else(|| scope.open_turns.last().map(|turn| turn.turn_id.clone())); + let Some(turn_id) = turn_id else { return }; + let Some(turn_index) = scope + .open_turns + .iter() + .position(|turn| turn.turn_id == turn_id) + else { + return; + }; + if scope.open_tools.contains_key(&call_id) { + return; + } + let turn_span = scope.open_turns[turn_index].span_id.clone(); + let explicit_skills = scope.open_turns[turn_index].explicit_skill_names.clone(); + + self.ensure_llm(scope, Some(&turn_id), ts, ops); + if let Some(llm) = &mut scope.open_llm { + llm.output.push(tool_call_message); + llm.last_output_ms = llm.last_output_ms.max(ts); + } + + let span_id = ids::span_id(&self.session_id, &format!("tool:{call_id}")); + // spawn_agent: remember which turn ran it, so a later SubagentStart can + // nest the subagent root under this turn (main scope only). + if scope.kind == ScopeKind::Main && tool_name == SPAWN_AGENT_TOOL { + self.spawn_turn_by_call_id + .insert(call_id.clone(), turn_span.clone()); + } + + // Skill / permission classification. + let skill = detect_skill(&tool_name, input.as_ref()); + let permission = permission_info(input.as_ref()); + let mut name = tool_name.clone(); + let mut tags: Vec = Vec::new(); + let mut metadata = Map::new(); + metadata.insert("tool_name".into(), json!(tool_name)); + metadata.insert("call_id".into(), json!(call_id)); + metadata.insert("turn_id".into(), json!(turn_id)); + if let Some(skill) = &skill { + if let Some(skill_name) = &skill.name { + name = format!("skill: {skill_name}"); + metadata.insert("skill_name".into(), json!(skill_name)); + if explicit_skills.contains(skill_name) { + metadata.insert("skill_load_trigger".into(), json!("explicit")); + } + } + if let Some(skill_path) = &skill.path { + metadata.insert("skill_path".into(), json!(skill_path)); + } + metadata.insert("tool_kind".into(), json!("skill")); + } + if let Some(permission) = permission { + metadata.insert("permission".into(), permission); + tags.push("permission-request".to_string()); + } + + ops.push(SpanOp::Insert(SpanRow { + span_id: span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![turn_span], + name, + span_type: SpanType::Tool, + start_ms: Some(ts), + input, + metadata: Some(Value::Object(metadata)), + tags: if tags.is_empty() { None } else { Some(tags) }, + ..Default::default() + })); + scope.open_tools.insert(call_id, (span_id, turn_id)); + } + + fn on_tool_output( + &mut self, + scope: &mut Scope, + payload: &Value, + ts: i64, + ops: &mut Vec, + ) { + let Some(call_id) = str_field(payload, "call_id") else { + push_tool_result(scope, None, payload); + return; + }; + push_tool_result(scope, Some(&call_id), payload); + let Some((span_id, _turn_id)) = scope.open_tools.remove(&call_id) else { + return; + }; + if let Some(turn) = scope + .open_turns + .iter_mut() + .find(|turn| turn.turn_id == _turn_id) + { + turn.last_child_end_ms = Some(turn.last_child_end_ms.map_or(ts, |p| p.max(ts))); + } + let output = payload + .get("output") + .or_else(|| payload.get("result")) + .cloned(); + let error = output.as_ref().and_then(classify_tool_output); + ops.push(SpanOp::Merge(SpanRow { + span_id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(ts), + output, + metadata: Some(json!({ "tool_approval": "approved" })), + error, + ..Default::default() + })); + } + + fn close_llm_with_tokens( + &mut self, + scope: &mut Scope, + payload: &Value, + ts: i64, + ops: &mut Vec, + ) { + let Some(llm) = scope.open_llm.take() else { + return; + }; + let usage = payload.get("info").and_then(|i| i.get("last_token_usage")); + let metrics = usage.map(token_metrics).filter(|m| !m.is_empty()); + let usage_metadata = if metrics.is_none() { + Some(json!({ + "usage_unavailable_reason": if usage + .and_then(Value::as_object) + .is_none_or(Map::is_empty) + { + "codex_token_count_missing_usage" + } else { + "codex_token_count_unrecognized_usage" + } + })) + } else { + None + }; + let end = llm.last_output_ms.max(llm.start_ms); + if let Some(turn) = scope + .open_turns + .iter_mut() + .find(|turn| turn.turn_id == llm.turn_id) + { + turn.last_child_end_ms = Some(turn.last_child_end_ms.map_or(end, |p| p.max(end))); + } + let output = if llm.output_preset { + None + } else { + Some(llm_output(&llm.output)) + }; + ops.push(SpanOp::Merge(SpanRow { + span_id: llm.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(end), + output, + metadata: usage_metadata, + metrics: metrics.map(Value::Object), + ..Default::default() + })); + let _ = ts; + } + + fn close_turn(&mut self, scope: &mut Scope, payload: &Value, ts: i64, ops: &mut Vec) { + let requested_turn_id = str_field(payload, "turn_id"); + let turn_index = requested_turn_id + .as_deref() + .and_then(|id| scope.open_turns.iter().position(|turn| turn.turn_id == id)) + .or_else(|| { + if requested_turn_id.is_none() { + scope.open_turns.len().checked_sub(1) + } else { + None + } + }); + let Some(turn_index) = turn_index else { + return; + }; + let turn_id = scope.open_turns[turn_index].turn_id.clone(); + + if scope + .open_llm + .as_ref() + .is_some_and(|llm| llm.turn_id == turn_id) + { + let llm = scope.open_llm.take().expect("checked above"); + let output = if llm.output_preset { + None + } else { + Some(llm_output(&llm.output)) + }; + ops.push(SpanOp::Merge(SpanRow { + span_id: llm.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(llm.last_output_ms), + output, + metadata: Some(json!({ + "usage_unavailable_reason": "codex_transcript_missing_token_count" + })), + ..Default::default() + })); + } + self.close_tools_for_turn(scope, &turn_id, Some(ts), ops); + let turn = scope.open_turns.remove(turn_index); + scope.last_turn_end_ms = Some(ts); + let output = str_field(payload, "last_agent_message") + .or_else(|| str_field(payload, "last_assistant_message")) + .map(|s| json!(s)); + ops.push(SpanOp::Merge(SpanRow { + span_id: turn.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: Some(ts), + output, + ..Default::default() + })); + } + + fn close_tools_for_turn( + &mut self, + scope: &mut Scope, + turn_id: &str, + end_ms: Option, + ops: &mut Vec, + ) { + let call_ids: Vec = scope + .open_tools + .iter() + .filter(|(_, (_, owner))| owner == turn_id) + .map(|(call_id, _)| call_id.clone()) + .collect(); + for call_id in call_ids { + if let Some((span_id, _)) = scope.open_tools.remove(&call_id) { + ops.push(SpanOp::Merge(SpanRow { + span_id, + root_span_id: self.root_span_id.clone(), + end_ms, + metadata: Some(json!({ "tool_approval": "approved" })), + error: Some(MISSING_TOOL_OUTPUT_ERROR.to_string()), + ..Default::default() + })); + } + } + } + + fn close_main_turn(&mut self, payload: &Value, ts: i64, ops: &mut Vec) { + let Some(path) = self.main_path.clone() else { + return; + }; + let Some(mut scope) = self.scopes.remove(&path) else { + return; + }; + self.close_turn(&mut scope, payload, ts, ops); + self.scopes.insert(path, scope); + } + + fn on_compacted( + &mut self, + scope: &mut Scope, + _rec: &Value, + payload: &Value, + ts: i64, + ops: &mut Vec, + ) { + let Some(turn) = scope.open_turns.last() else { + return; + }; + let turn_id = turn.turn_id.clone(); + let turn_span = turn.span_id.clone(); + let turn_start = turn.start_ms; + let turn_last_child = turn.last_child_end_ms; + let replacement = payload + .get("replacement_history") + .and_then(Value::as_array) + .cloned(); + let trigger = self.compaction_trigger_by_turn.get(&turn_id).cloned(); + self.compaction_spans.insert(turn_id.clone()); + + // Relabel the turn as a compaction span. + ops.push(SpanOp::Merge(SpanRow { + span_id: turn_span.clone(), + root_span_id: self.root_span_id.clone(), + name: "compaction".to_string(), + span_type: SpanType::Task, + metadata: Some(json!({ "compaction": { + "trigger": trigger, + "replaced_message_count": replacement.as_ref().map(|r| r.len()), + "window_id": payload.get("window_id"), + }})), + tags: Some(vec!["compaction".to_string()]), + ..Default::default() + })); + + // Synthetic llm span for the compaction call: before/after context. + let start = turn_last_child.unwrap_or(turn_start); + let before = scope.conversation_history.clone(); + let span_id = ids::span_id(&self.session_id, &format!("llm:{turn_id}:compaction")); + let name = scope + .model + .clone() + .unwrap_or_else(|| "compaction".to_string()); + ops.push(SpanOp::Insert(SpanRow { + span_id: span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![turn_span.clone()], + name: name.clone(), + span_type: SpanType::Llm, + start_ms: Some(start), + input: Some(json!({ "messages_before_compaction": before.len(), "history": before })), + output: Some(compaction_output(replacement.as_ref())), + metadata: Some(json!({ "model": scope.model, "turn_id": turn_id, "compaction": true })), + ..Default::default() + })); + if let Some(replacement) = replacement { + scope.conversation_history = replacement; + } + let _ = (turn_span, name); + scope.open_llm = Some(OpenLlm { + span_id, + turn_id, + start_ms: start, + last_output_ms: ts, + output: Vec::new(), + output_preset: true, + }); + } + + fn end_main_root(&mut self, fallback_ts: i64, ops: &mut Vec) { + if self.root_ended || !self.root_opened { + return; + } + self.root_ended = true; + let end_ms = self + .main_path + .as_ref() + .and_then(|path| self.scopes.get(path)) + .and_then(|scope| scope.last_turn_end_ms) + .unwrap_or(fallback_ts); + ops.push(SpanOp::Merge(SpanRow { + span_id: self.root_span_id.clone(), + root_span_id: self.root_span_id.clone(), + end_ms: Some(end_ms), + ..Default::default() + })); + } + + fn close_compaction_turn(&mut self, payload: &Value, ts: i64, ops: &mut Vec) { + let Some(path) = self.main_path.clone() else { + return; + }; + let Some(mut scope) = self.scopes.remove(&path) else { + return; + }; + // The compaction turn may not get a task_complete of its own. + self.close_turn(&mut scope, payload, ts, ops); + self.scopes.insert(path, scope); + } + + fn close_subagent(&mut self, path: &str, ts: i64, ops: &mut Vec) { + let Some(mut scope) = self.scopes.remove(path) else { + return; + }; + if !scope.subagent_ended && scope.root_created { + scope.subagent_ended = true; + let end = scope.last_turn_end_ms.unwrap_or(ts); + self.close_dangling(&mut scope, Some(end), ops); + // End the subagent root span. + ops.push(SpanOp::Merge(SpanRow { + span_id: scope.turn_parent_span_id.clone(), + root_span_id: self.root_span_id.clone(), + end_ms: Some(end), + ..Default::default() + })); + } + self.scopes.insert(path.to_string(), scope); + } + + /// Close any open llm/tool/turn in `scope` (used on subagent stop + flush). + fn close_dangling(&mut self, scope: &mut Scope, end: Option, ops: &mut Vec) { + let end_ms = end.or(scope.last_turn_end_ms); + if let Some(llm) = scope.open_llm.take() { + let output = if llm.output_preset { + None + } else { + Some(llm_output(&llm.output)) + }; + ops.push(SpanOp::Merge(SpanRow { + span_id: llm.span_id, + root_span_id: self.root_span_id.clone(), + end_ms: end_ms.or(Some(llm.last_output_ms)), + output, + metadata: Some(json!({ + "usage_unavailable_reason": "codex_transcript_missing_token_count" + })), + ..Default::default() + })); + } + let tools: Vec<(String, String)> = scope + .open_tools + .drain() + .map(|(_, (span_id, _))| (span_id, MISSING_TOOL_OUTPUT_ERROR.to_string())) + .collect(); + for (sid, error) in tools { + ops.push(SpanOp::Merge(SpanRow { + span_id: sid, + root_span_id: self.root_span_id.clone(), + end_ms, + metadata: Some(json!({ "tool_approval": "approved" })), + error: Some(error), + ..Default::default() + })); + } + for turn in scope.open_turns.drain(..) { + ops.push(SpanOp::Merge(SpanRow { + span_id: turn.span_id, + root_span_id: self.root_span_id.clone(), + end_ms, + ..Default::default() + })); + } + } +} + +impl Scope { + fn new(path: &str, kind: ScopeKind, turn_parent_span_id: String) -> Self { + Scope { + path: path.to_string(), + kind, + offset: 0, + turn_parent_span_id, + root_created: false, + model: None, + open_turns: Vec::new(), + conversation_history: Vec::new(), + open_llm: None, + open_tools: HashMap::new(), + last_turn_end_ms: None, + turn_seq: 0, + agent_id: None, + agent_type: None, + spawning_turn_span_id: None, + subagent_ended: false, + } + } +} + +// ---- helpers --------------------------------------------------------------- + +fn str_field(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_str).map(|s| s.to_string()) +} + +fn basename(path: &str) -> String { + let trimmed = path.trim_end_matches(['/', '\\']); + trimmed + .rsplit(['/', '\\']) + .next() + .unwrap_or(trimmed) + .to_string() +} + +fn message_text(payload: &Value) -> String { + payload + .get("content") + .and_then(Value::as_array) + .map(|parts| { + parts + .iter() + .filter_map(|p| str_field(p, "text")) + .collect::>() + .join("") + }) + .unwrap_or_default() +} + +fn llm_output(items: &[Value]) -> Value { + if items.len() == 1 { + items[0].clone() + } else { + Value::Array(items.to_vec()) + } +} + +fn push_tool_result(scope: &mut Scope, call_id: Option<&str>, payload: &Value) { + let output = payload + .get("output") + .or_else(|| payload.get("result")) + .cloned() + .unwrap_or(Value::Null); + let content = output + .as_str() + .map(str::to_string) + .unwrap_or_else(|| serde_json::to_string(&output).unwrap_or_else(|_| "null".to_string())); + scope.conversation_history.push(json!({ + "role": "tool", + "content": content, + "tool_call_id": call_id.unwrap_or_default(), + })); +} + +fn args_object(args: Option<&Value>) -> Option> { + match args? { + Value::Object(map) => Some(map.clone()), + Value::String(raw) => serde_json::from_str::(raw) + .ok() + .and_then(|value| value.as_object().cloned()), + _ => None, + } +} + +fn concise_error(value: &Value, fallback: &str) -> String { + if let Some(text) = value.as_str() { + return text.lines().next().unwrap_or(fallback).to_string(); + } + if let Some(object) = value.as_object() { + for key in ["error", "message", "stderr", "output", "result"] { + if let Some(text) = object.get(key).and_then(Value::as_str) { + return text.lines().next().unwrap_or(fallback).to_string(); + } + } + } + fallback.to_string() +} + +fn classify_tool_output(output: &Value) -> Option { + if let Some(object) = output.as_object() { + if object.get("is_error").and_then(Value::as_bool) == Some(true) + || object.get("isError").and_then(Value::as_bool) == Some(true) + || matches!( + object.get("status").and_then(Value::as_str), + Some("error" | "failed") + ) + { + return Some(concise_error(output, "Tool execution failed")); + } + if let Some(error) = object.get("error") { + return Some(concise_error(error, "Tool execution failed")); + } + if let Some(exit_code) = object + .get("exit_code") + .or_else(|| object.get("exitCode")) + .and_then(Value::as_i64) + { + if exit_code != 0 { + return Some(concise_error(output, &format!("Exit code {exit_code}"))); + } + } + } + if let Some(text) = output.as_str() { + let first = text.lines().next().unwrap_or(text); + if first.to_ascii_lowercase().starts_with("error:") { + return Some(first.to_string()); + } + if let Some(code) = first + .strip_prefix("Exit code ") + .and_then(|value| value.split_whitespace().next()) + .and_then(|value| value.parse::().ok()) + { + if code != 0 { + return Some(first.to_string()); + } + } + } + None +} + +#[derive(Default)] +struct SkillLoad { + name: Option, + path: Option, +} + +fn string_candidates(args: Option<&Value>) -> Vec { + let mut candidates = Vec::new(); + if let Some(raw) = args.and_then(Value::as_str) { + candidates.push(raw.to_string()); + } + if let Some(object) = args_object(args) { + for key in [ + "path", + "file_path", + "filePath", + "file", + "command", + "cmd", + "resource", + ] { + if let Some(value) = object.get(key).and_then(Value::as_str) { + candidates.push(value.to_string()); + } + } + } + candidates +} + +fn detect_skill(tool_name: &str, args: Option<&Value>) -> Option { + if tool_name == "skills.read" { + if let Some(object) = args_object(args) { + return Some(SkillLoad { + name: object + .get("name") + .or_else(|| object.get("package")) + .and_then(Value::as_str) + .map(str::to_string), + path: None, + }); + } + } + static SKILL_PATH: OnceLock = OnceLock::new(); + static SCRIPT_PATH: OnceLock = OnceLock::new(); + let skill_path = + SKILL_PATH.get_or_init(|| Regex::new(r#"(?i)([^\s"']*SKILL\.md)"#).expect("regex")); + let script_path = SCRIPT_PATH + .get_or_init(|| Regex::new(r#"(?i)([^\s"']*[\\/]scripts[\\/][^\s"']+)"#).expect("regex")); + for candidate in string_candidates(args) { + if let Some(path) = skill_path + .captures(&candidate) + .and_then(|capture| capture.get(1)) + .map(|capture| capture.as_str().to_string()) + { + let normalized = path.replace('\\', "/"); + let name = Path::new(&normalized) + .parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .map(str::to_string); + return Some(SkillLoad { + name, + path: Some(path), + }); + } + if let Some(path) = script_path + .captures(&candidate) + .and_then(|capture| capture.get(1)) + .map(|capture| capture.as_str().to_string()) + { + let normalized = path.replace('\\', "/"); + let name = Path::new(&normalized) + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .map(str::to_string); + return Some(SkillLoad { + name, + path: Some(path), + }); + } + } + None +} + +fn permission_info(args: Option<&Value>) -> Option { + let object = args_object(args)?; + let sandbox_permissions = object.get("sandbox_permissions")?.as_str()?; + if sandbox_permissions.is_empty() { + return None; + } + let mut permission = Map::new(); + permission.insert("sandbox_permissions".into(), json!(sandbox_permissions)); + if let Some(justification) = object.get("justification").and_then(Value::as_str) { + permission.insert("justification".into(), json!(justification)); + } + if let Some(prefix_rule) = object.get("prefix_rule") { + permission.insert("prefix_rule".into(), prefix_rule.clone()); + } + Some(Value::Object(permission)) +} + +fn explicit_skill_names(text: &str) -> Vec { + static EXPLICIT_SKILLS: OnceLock> = OnceLock::new(); + static SKILL_XML: OnceLock = OnceLock::new(); + static SKILL_XML_NAME: OnceLock = OnceLock::new(); + static SKILL_FRONTMATTER_NAME: OnceLock = OnceLock::new(); + let patterns = EXPLICIT_SKILLS.get_or_init(|| { + [ + r#"\$([A-Za-z0-9_.:-]+)"#, + r#"(?:^|\s)/skills\s+([A-Za-z0-9_.:-]+)"#, + r#"skill://([A-Za-z0-9_.:-]+)"#, + r#"(?i)(?:^|[\s"'])([^\s"']*SKILL\.md)(?:$|[\s"'])"#, + r#"UserInput::Skill\([^)]*(?:name|skill|id)\s*[:=]\s*["']?([A-Za-z0-9_.:-]+)"#, + ] + .into_iter() + .map(|pattern| Regex::new(pattern).expect("regex")) + .collect() + }); + let mut names = Vec::new(); + for (index, pattern) in patterns.iter().enumerate() { + for capture in pattern.captures_iter(text) { + let Some(value) = capture.get(1).map(|capture| capture.as_str()) else { + continue; + }; + let name = if index == 3 { + let normalized = value.replace('\\', "/"); + Path::new(&normalized) + .parent() + .and_then(Path::file_name) + .and_then(|value| value.to_str()) + .unwrap_or(value) + .to_string() + } else { + value + .trim() + .trim_start_matches('$') + .trim_end_matches([',', ')', '.', ';']) + .to_string() + }; + if !name.is_empty() && !names.contains(&name) { + names.push(name); + } + } + } + let xml = SKILL_XML + .get_or_init(|| Regex::new(r#"(?is)]*)>(.*?)"#).expect("regex")); + let attr_name = SKILL_XML_NAME + .get_or_init(|| Regex::new(r#"(?:name|id)=["']([^"']+)["']"#).expect("regex")); + let frontmatter_name = SKILL_FRONTMATTER_NAME + .get_or_init(|| Regex::new(r#"(?m)(?:^|\n)name:\s*([A-Za-z0-9_.:-]+)"#).expect("regex")); + for capture in xml.captures_iter(text) { + let candidate = capture + .get(1) + .and_then(|attrs| attr_name.captures(attrs.as_str())) + .and_then(|capture| capture.get(1)) + .or_else(|| { + capture + .get(2) + .and_then(|body| frontmatter_name.captures(body.as_str())) + .and_then(|capture| capture.get(1)) + }) + .map(|capture| capture.as_str().to_string()); + if let Some(name) = candidate { + if !name.is_empty() && !names.contains(&name) { + names.push(name); + } + } + } + names +} + +fn explicit_skill_metadata(names: &[String]) -> Option { + (!names.is_empty()).then(|| { + json!({ + "loaded_skill_names": names, + "loaded_skills": names.iter().map(|name| json!({ "name": name })).collect::>(), + }) + }) +} + +fn git_metadata(cwd: &str) -> Map { + fn git(cwd: &str, args: &[&str]) -> Option { + let output = Command::new("git") + .arg("-C") + .arg(cwd) + .args(args) + .env("GIT_OPTIONAL_LOCKS", "0") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8(output.stdout).ok()?.trim().to_string(); + (!value.is_empty()).then_some(value) + } + fn redact_remote(remote: String) -> String { + let Some(scheme) = remote.find("://") else { + return remote; + }; + let authority_start = scheme + 3; + let authority_end = remote[authority_start..] + .find('/') + .map(|offset| authority_start + offset) + .unwrap_or(remote.len()); + if let Some(at) = remote[authority_start..authority_end].rfind('@') { + let at = authority_start + at; + return format!("{}{}", &remote[..authority_start], &remote[at + 1..]); + } + remote + } + let mut metadata = Map::new(); + if let Some(origin) = git(cwd, &["remote", "get-url", "origin"]) { + metadata.insert("git_origin_url".into(), json!(redact_remote(origin))); + } + if let Some(branch) = git(cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) { + metadata.insert("git_branch".into(), json!(branch)); + } + if let Some(commit) = git(cwd, &["rev-parse", "HEAD"]) { + metadata.insert("git_commit_sha".into(), json!(commit)); + } + metadata +} + +fn compaction_output(replacement: Option<&Vec>) -> Value { + let Some(items) = replacement else { + return json!({ "summary": "[unavailable]", "kept_messages": [] }); + }; + let mut kept = Vec::new(); + let mut summary_encrypted = false; + for item in items { + if item.get("type").and_then(Value::as_str) == Some("compaction") { + if item + .get("encrypted_content") + .and_then(Value::as_str) + .is_some() + { + summary_encrypted = true; + } + continue; + } + kept.push(item.clone()); + } + json!({ + "summary": if summary_encrypted { + "[summary unavailable — encrypted by Codex]" + } else { + "[no summary]" + }, + "kept_messages": kept, + }) +} + +fn parse_ts(rec: &Value) -> Option { + let s = rec.get("timestamp").and_then(Value::as_str)?; + chrono::DateTime::parse_from_rfc3339(s) + .ok() + .map(|dt| dt.timestamp_millis()) +} + +fn read_new_lines(path: &str, offset: &mut u64, through_ms: Option) -> Vec { + use std::io::{BufRead, BufReader, Seek, SeekFrom}; + let Ok(mut f) = std::fs::File::open(path) else { + return Vec::new(); + }; + if let Ok(meta) = f.metadata() { + if *offset > meta.len() { + *offset = 0; + } + } + if f.seek(SeekFrom::Start(*offset)).is_err() { + return Vec::new(); + } + let mut reader = BufReader::new(f); + let mut lines = Vec::new(); + loop { + let mut line = String::new(); + let Ok(bytes) = reader.read_line(&mut line) else { + break; + }; + if bytes == 0 || !line.ends_with('\n') { + break; + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if let Some(limit) = through_ms { + if serde_json::from_str::(trimmed) + .ok() + .and_then(|record| parse_ts(&record)) + .is_some_and(|timestamp| timestamp > limit) + { + break; + } + } + *offset += bytes as u64; + if !trimmed.trim().is_empty() { + lines.push(trimmed.to_string()); + } + } + lines +} + +fn token_metrics(usage: &Value) -> Map { + const MAP: &[(&str, &str)] = &[ + ("input_tokens", "prompt_tokens"), + ("prompt_tokens", "prompt_tokens"), + ("output_tokens", "completion_tokens"), + ("completion_tokens", "completion_tokens"), + ("total_tokens", "tokens"), + ("tokens", "tokens"), + ("cached_input_tokens", "prompt_cached_tokens"), + ("prompt_cached_tokens", "prompt_cached_tokens"), + ("input_tokens_details.cached_tokens", "prompt_cached_tokens"), + ( + "prompt_tokens_details.cached_tokens", + "prompt_cached_tokens", + ), + ( + "prompt_cache_creation_tokens", + "prompt_cache_creation_tokens", + ), + ( + "input_tokens_details.cache_creation_tokens", + "prompt_cache_creation_tokens", + ), + ( + "input_tokens_details.cache_write_tokens", + "prompt_cache_creation_tokens", + ), + ( + "prompt_tokens_details.cache_creation_tokens", + "prompt_cache_creation_tokens", + ), + ( + "prompt_tokens_details.cache_write_tokens", + "prompt_cache_creation_tokens", + ), + ("reasoning_output_tokens", "completion_reasoning_tokens"), + ("completion_reasoning_tokens", "completion_reasoning_tokens"), + ("reasoning_tokens", "completion_reasoning_tokens"), + ( + "output_tokens_details.reasoning_tokens", + "completion_reasoning_tokens", + ), + ( + "completion_tokens_details.reasoning_tokens", + "completion_reasoning_tokens", + ), + ("cost", "cost"), + ("cost", "estimated_cost"), + ("estimated_cost", "estimated_cost"), + ("total_cost", "estimated_cost"), + ("cost_usd", "estimated_cost"), + ]; + let mut metrics = Map::new(); + for (from, to) in MAP { + if metrics.contains_key(*to) { + continue; + } + if let Some(v) = num_at(usage, from) { + metrics.insert((*to).to_string(), json!(v)); + } + } + if !metrics.contains_key("tokens") { + if let (Some(p), Some(c)) = ( + metrics.get("prompt_tokens").and_then(Value::as_f64), + metrics.get("completion_tokens").and_then(Value::as_f64), + ) { + metrics.insert("tokens".to_string(), json!(p + c)); + } + } + metrics +} + +fn num_at(v: &Value, path: &str) -> Option { + let mut cur = v; + for part in path.split('.') { + cur = cur.get(part)?; + } + cur.as_f64().filter(|n| n.is_finite()) +} + +#[cfg(test)] +mod tests { + use super::basename; + + #[test] + fn basename_accepts_unix_and_windows_paths() { + assert_eq!(basename("/tmp/project"), "project"); + assert_eq!(basename(r"C:\Users\agent\project"), "project"); + assert_eq!(basename(r"C:\Users\agent\project\\"), "project"); + } +} diff --git a/bt-daemon/src/translate/debug.rs b/bt-daemon/src/translate/debug.rs new file mode 100644 index 0000000..2bbaf5c --- /dev/null +++ b/bt-daemon/src/translate/debug.rs @@ -0,0 +1,80 @@ +//! A pass-through translator used by the prototype and tests. It builds a +//! minimal but real span tree — one session root plus one `tool`-typed span +//! per event — so the end-to-end pipeline (transport → dispatch → journal → +//! translate → sink) can be exercised before any agent-specific translator +//! exists. + +use super::{AgentTranslator, SessionCtx, SpanOp, SpanRow, SpanType, TranslatorFactory}; +use crate::ids; +use crate::wire::Envelope; + +pub struct DebugTranslatorFactory; + +impl TranslatorFactory for DebugTranslatorFactory { + fn source(&self) -> &str { + "debug" + } + fn create(&self, session_id: &str) -> Box { + Box::new(DebugTranslator { + root_span_id: ids::span_id(session_id, "root"), + root_emitted: false, + event_seq: 0, + }) + } +} + +struct DebugTranslator { + root_span_id: String, + root_emitted: bool, + event_seq: u64, +} + +impl AgentTranslator for DebugTranslator { + fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result> { + let mut ops = Vec::new(); + + if !self.root_emitted { + self.root_emitted = true; + ops.push(SpanOp::Insert(SpanRow { + span_id: self.root_span_id.clone(), + root_span_id: self.root_span_id.clone(), + parent_span_ids: Vec::new(), + name: format!("{}: {}", event.source, ctx.session_id), + span_type: SpanType::Task, + start_ms: Some(event.ts_ms), + end_ms: None, + input: None, + output: None, + metadata: Some(serde_json::json!({ "session_id": ctx.session_id })), + metrics: None, + error: None, + tags: None, + })); + } + + let seq = self.event_seq; + self.event_seq += 1; + let span_id = ids::span_id(&ctx.session_id, &format!("event:{seq}")); + ops.push(SpanOp::Insert(SpanRow { + span_id, + root_span_id: self.root_span_id.clone(), + parent_span_ids: vec![self.root_span_id.clone()], + name: event.event.clone(), + span_type: SpanType::Tool, + start_ms: Some(event.ts_ms), + end_ms: Some(event.ts_ms), + input: Some(event.payload.clone()), + output: None, + metadata: Some(serde_json::json!({ "seq": seq, "source": event.source })), + metrics: None, + error: None, + tags: None, + })); + + Ok(ops) + } + + fn flush(&mut self, _ctx: &SessionCtx) -> anyhow::Result> { + Ok(Vec::new()) + } +} diff --git a/bt-daemon/src/translate/mod.rs b/bt-daemon/src/translate/mod.rs new file mode 100644 index 0000000..d376f38 --- /dev/null +++ b/bt-daemon/src/translate/mod.rs @@ -0,0 +1,148 @@ +//! Translators turn agent-native hook events into a sink-neutral span +//! representation ([`SpanOp`]). Each session gets its own stateful translator +//! instance (created by a [`TranslatorFactory`]); the state machine that pairs +//! start/stop events and builds the span tree lives inside that instance. +//! +//! Keeping the output ([`SpanRow`]) independent of the Braintrust SDK lets the +//! whole pipeline be exercised with a debug sink and makes translators unit- +//! testable without any network. + +mod claude; +mod codex; +mod debug; + +pub use claude::ClaudeTranslatorFactory; +pub use codex::CodexTranslatorFactory; +pub use debug::DebugTranslatorFactory; + +use crate::wire::{Envelope, SessionConfig}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Braintrust span kinds we emit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SpanType { + #[default] + Task, + Llm, + Tool, +} + +/// A resolved span row, ready for a sink to insert or merge. Field set is the +/// subset every current plugin uses; extend as translators need more. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SpanRow { + pub span_id: String, + pub root_span_id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub parent_span_ids: Vec, + pub name: String, + pub span_type: SpanType, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub start_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub end_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub metrics: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Labels for filtering in Braintrust (e.g. `compaction`, `permission-request`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, +} + +/// A span operation. `Insert` creates (or replaces) a row; `Merge` updates an +/// existing row by id (maps to `_is_merge` at the sink). Re-emitting an +/// `Insert` after journal replay merges server-side thanks to deterministic +/// ids, so replay is idempotent. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SpanOp { + Insert(SpanRow), + Merge(SpanRow), +} + +/// Cross-cutting per-session context handed to the translator on each call. +/// The translator's own state lives in the translator instance; this carries +/// only what the dispatcher owns. +pub struct SessionCtx { + pub session_id: String, + /// Latest config seen for this session (auth, project, span-attach ids). + pub config: Option, +} + +/// A per-session state machine. One instance per session; `&mut self` so it +/// can hold open-span maps, transcript offsets, etc. +pub trait AgentTranslator: Send { + /// Handle one event, returning span ops to emit. + fn handle(&mut self, event: &Envelope, ctx: &SessionCtx) -> anyhow::Result>; + + /// Emit any pending spans (e.g. close dangling turns) at flush/shutdown. + fn flush(&mut self, ctx: &SessionCtx) -> anyhow::Result> { + let _ = ctx; + Ok(Vec::new()) + } +} + +/// Builds translator instances for a given `source`. +pub trait TranslatorFactory: Send + Sync { + fn source(&self) -> &str; + fn create(&self, session_id: &str) -> Box; +} + +/// Maps a `source` string to its factory, with a fallback for unknown sources. +pub struct Registry { + factories: HashMap>, + fallback: Box, +} + +impl Registry { + /// A registry whose only translator (and fallback) is the debug + /// pass-through. This is the Phase 1 default. + pub fn debug_only() -> Self { + let mut r = Registry { + factories: HashMap::new(), + fallback: Box::new(DebugTranslatorFactory), + }; + r.register(Box::new(DebugTranslatorFactory)); + r + } + + /// The production registry: all real agent translators registered, debug + /// as the fallback for unknown sources. + pub fn default_agents() -> Self { + let mut r = Registry::debug_only(); + r.register(Box::new(ClaudeTranslatorFactory)); + r.register(Box::new(CodexTranslatorFactory)); + r + } + + pub fn register(&mut self, factory: Box) { + self.factories.insert(factory.source().to_string(), factory); + } + + /// Known sources, for the `initialize` capabilities list. + pub fn sources(&self) -> Vec { + let mut v: Vec = self.factories.keys().cloned().collect(); + v.sort(); + v + } + + /// Create a translator for `source`, falling back (with a warning) to the + /// debug translator for an unknown source. + pub fn create(&self, source: &str, session_id: &str) -> Box { + match self.factories.get(source) { + Some(f) => f.create(session_id), + None => { + tracing::warn!(source, "no translator registered; using debug fallback"); + self.fallback.create(session_id) + } + } + } +} diff --git a/bt-daemon/src/transport.rs b/bt-daemon/src/transport.rs new file mode 100644 index 0000000..36bda78 --- /dev/null +++ b/bt-daemon/src/transport.rs @@ -0,0 +1,194 @@ +//! Local daemon transport. +//! +//! Unix hosts use a Unix-domain socket. Windows uses a byte-mode named pipe. +//! Both transports expose the same async byte stream to the JSON-lines RPC +//! layer, keeping framing and daemon behavior platform-independent. + +use std::future::Future; +use std::path::Path; +#[cfg(windows)] +use std::time::Duration; + +#[cfg(unix)] +pub(crate) type ClientStream = tokio::net::UnixStream; +#[cfg(windows)] +pub(crate) type ClientStream = tokio::net::windows::named_pipe::NamedPipeClient; + +#[cfg(unix)] +pub(crate) type ServerStream = tokio::net::UnixStream; +#[cfg(windows)] +pub(crate) type ServerStream = tokio::net::windows::named_pipe::NamedPipeServer; + +/// Connect to the local daemon. Windows retries briefly when all named-pipe +/// instances are occupied (`ERROR_PIPE_BUSY`) so normal concurrent hooks do +/// not spuriously conclude that the daemon is absent. +#[cfg(unix)] +pub(crate) async fn connect(endpoint: &Path) -> std::io::Result { + tokio::net::UnixStream::connect(endpoint).await +} + +#[cfg(windows)] +pub(crate) async fn connect(endpoint: &Path) -> std::io::Result { + use tokio::net::windows::named_pipe::ClientOptions; + + const ERROR_PIPE_BUSY: i32 = 231; + let mut last_busy = None; + for _ in 0..20 { + match ClientOptions::new().open(endpoint) { + Ok(stream) => return Ok(stream), + Err(error) if error.raw_os_error() == Some(ERROR_PIPE_BUSY) => { + last_busy = Some(error); + tokio::time::sleep(Duration::from_millis(10)).await; + } + Err(error) => return Err(error), + } + } + Err(last_busy.unwrap_or_else(|| { + std::io::Error::new(std::io::ErrorKind::WouldBlock, "named pipe remained busy") + })) +} + +#[cfg(unix)] +pub(crate) struct Listener { + inner: tokio::net::UnixListener, +} + +#[cfg(unix)] +impl Listener { + fn bind_raw(endpoint: &Path) -> std::io::Result { + Ok(Self { + inner: tokio::net::UnixListener::bind(endpoint)?, + }) + } + + pub(crate) async fn accept(&mut self) -> std::io::Result { + self.inner.accept().await.map(|(stream, _)| stream) + } +} + +#[cfg(windows)] +pub(crate) struct Listener { + endpoint: std::ffi::OsString, + next: tokio::net::windows::named_pipe::NamedPipeServer, +} + +#[cfg(windows)] +impl Listener { + /// Create the first server instance exclusively. This is the named-pipe + /// equivalent of binding a Unix socket and is what resolves daemon races. + fn bind_raw(endpoint: &Path) -> std::io::Result { + use tokio::net::windows::named_pipe::ServerOptions; + + let next = ServerOptions::new() + .first_pipe_instance(true) + .create(endpoint)?; + Ok(Self { + endpoint: endpoint.as_os_str().to_owned(), + next, + }) + } + + pub(crate) async fn accept(&mut self) -> std::io::Result { + use tokio::net::windows::named_pipe::ServerOptions; + + self.next.connect().await?; + // Install another listening instance before handing the connected + // stream to a task, avoiding a gap where concurrent hook clients see + // ERROR_PIPE_BUSY. + let next = ServerOptions::new().create(&self.endpoint)?; + Ok(std::mem::replace(&mut self.next, next)) + } +} + +/// Claim the daemon endpoint. Returns `None` when another healthy daemon +/// already owns it. +pub(crate) async fn claim( + endpoint: &Path, + mut probe_alive: F, +) -> anyhow::Result> +where + F: FnMut() -> Fut, + Fut: Future, +{ + #[cfg(unix)] + { + claim_unix(endpoint, &mut probe_alive).await + } + #[cfg(windows)] + { + claim_windows(endpoint, &mut probe_alive).await + } +} + +#[cfg(unix)] +async fn claim_unix( + endpoint: &Path, + probe_alive: &mut F, +) -> anyhow::Result> +where + F: FnMut() -> Fut, + Fut: Future, +{ + if endpoint.exists() { + if probe_alive().await { + return Ok(None); + } + cleanup(endpoint); + } + match Listener::bind_raw(endpoint) { + Ok(listener) => Ok(Some(listener)), + Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => { + if probe_alive().await { + Ok(None) + } else { + cleanup(endpoint); + Ok(Some(Listener::bind_raw(endpoint)?)) + } + } + Err(error) => Err(error.into()), + } +} + +#[cfg(windows)] +async fn claim_windows( + endpoint: &Path, + probe_alive: &mut F, +) -> anyhow::Result> +where + F: FnMut() -> Fut, + Fut: Future, +{ + if probe_alive().await { + return Ok(None); + } + + // Named pipes have no stale filesystem node: the name is released when + // the last server handle closes. Retry briefly to cover a rival daemon + // winning the probe/create race or a just-terminated daemon unwinding. + let mut last_error = None; + for _ in 0..50 { + match Listener::bind_raw(endpoint) { + Ok(listener) => return Ok(Some(listener)), + Err(error) => { + last_error = Some(error); + if probe_alive().await { + return Ok(None); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + } + Err(last_error + .unwrap_or_else(|| std::io::Error::other("could not create named pipe")) + .into()) +} + +/// Unix socket nodes survive process death and need explicit cleanup. Windows +/// named-pipe names disappear automatically when their final handle closes. +#[cfg(unix)] +pub(crate) fn cleanup(endpoint: &Path) { + let _ = std::fs::remove_file(endpoint); +} + +#[cfg(windows)] +pub(crate) fn cleanup(_endpoint: &Path) {} diff --git a/bt-daemon/src/wire/envelope.rs b/bt-daemon/src/wire/envelope.rs new file mode 100644 index 0000000..ae50463 --- /dev/null +++ b/bt-daemon/src/wire/envelope.rs @@ -0,0 +1,231 @@ +//! The `event.log` envelope and its session config, plus auth redaction for +//! the journal. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// One captured hook event, forwarded from a shim to the daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Envelope { + /// Which daemon-side translator interprets `payload` (e.g. `codex`, + /// `claude-code`, `debug`). + pub source: String, + /// The agent version, for payload-drift handling. Optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_version: Option, + /// Per-session queue + state key. + pub session_id: String, + /// Agent-native hook event name (not normalized). + pub event: String, + /// Epoch milliseconds, stamped by the shim at capture time. + pub ts_ms: i64, + /// The raw agent-native hook payload; opaque except to the translator. + pub payload: serde_json::Value, + /// Shim-resolved credentials + trace settings. Present on every event from + /// a stateless shim; the daemon keeps the latest per session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, +} + +/// Trace settings and backend credentials resolved by the shim. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionConfig { + pub auth: BackendAuth, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_span_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_span_id: Option, + #[serde(default)] + pub flush_mode: FlushMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_metadata: Option, +} + +/// Backend credentials. `token` is an API key or an OAuth access token; the +/// daemon does not care which. Never persisted (see [`SessionConfig::redacted`]). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendAuth { + pub token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub org_name: Option, + /// Optional org id. `bt` may or may not know it; the SDK's project + /// registration works from `org_name` alone, so this is best-effort and + /// only feeds the SDK's per-session credential/batch key. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub org_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FlushMode { + /// Deliver in the background; flush on session end / idle. The default. + #[default] + FireAndForget, + /// Additionally block on `session.flush` at each turn boundary. + FlushOnTurnEnd, +} + +/// A non-secret fingerprint of [`BackendAuth`], written to the journal in +/// place of the token so replay can detect a credential change without ever +/// persisting the secret. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthFingerprint { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub api_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub org_name: Option, + /// First 12 hex chars of SHA-256(token). Enough to detect rotation, far + /// too little to recover the token. + pub token_sha256_prefix: String, +} + +impl BackendAuth { + pub fn fingerprint(&self) -> AuthFingerprint { + let digest = Sha256::digest(self.token.as_bytes()); + let hex = digest.iter().fold(String::with_capacity(64), |mut s, b| { + use std::fmt::Write; + let _ = write!(s, "{b:02x}"); + s + }); + AuthFingerprint { + api_url: self.api_url.clone(), + app_url: self.app_url.clone(), + org_name: self.org_name.clone(), + token_sha256_prefix: hex[..12].to_string(), + } + } +} + +impl Envelope { + /// A copy of this envelope safe to write to the journal: the live token is + /// replaced by an [`AuthFingerprint`]. The rest of `config` (project, + /// span-attach ids, flush mode, metadata) is retained — none of it secret. + pub fn redacted(&self) -> RedactedEnvelope { + RedactedEnvelope { + source: self.source.clone(), + source_version: self.source_version.clone(), + session_id: self.session_id.clone(), + event: self.event.clone(), + ts_ms: self.ts_ms, + payload: self.payload.clone(), + config: self.config.as_ref().map(|c| RedactedConfig { + auth: c.auth.fingerprint(), + project: c.project.clone(), + parent_span_id: c.parent_span_id.clone(), + root_span_id: c.root_span_id.clone(), + flush_mode: c.flush_mode, + additional_metadata: c.additional_metadata.clone(), + }), + } + } +} + +/// Journal form of [`Envelope`] with the token redacted. Deserializable so a +/// replay pass can read it back (and re-supply live credentials separately). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedactedEnvelope { + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_version: Option, + pub session_id: String, + pub event: String, + pub ts_ms: i64, + pub payload: serde_json::Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RedactedConfig { + pub auth: AuthFingerprint, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_span_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub root_span_id: Option, + #[serde(default)] + pub flush_mode: FlushMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_metadata: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Envelope { + Envelope { + source: "codex".into(), + source_version: Some("1.2.3".into()), + session_id: "sess-1".into(), + event: "PostToolUse".into(), + ts_ms: 1_753_639_552_123, + payload: serde_json::json!({ "session_id": "sess-1", "tool_name": "shell" }), + config: Some(SessionConfig { + auth: BackendAuth { + token: "sk-super-secret".into(), + api_url: Some("https://api.braintrust.dev".into()), + app_url: None, + org_name: Some("acme".into()), + org_id: None, + }, + project: Some("codex".into()), + parent_span_id: None, + root_span_id: None, + flush_mode: FlushMode::FireAndForget, + additional_metadata: None, + }), + } + } + + #[test] + fn envelope_round_trips() { + let e = sample(); + let s = serde_json::to_string(&e).unwrap(); + let back: Envelope = serde_json::from_str(&s).unwrap(); + assert_eq!(back.session_id, "sess-1"); + assert_eq!(back.config.unwrap().auth.token, "sk-super-secret"); + } + + #[test] + fn redaction_drops_the_token_but_keeps_settings() { + let e = sample(); + let r = e.redacted(); + let s = serde_json::to_string(&r).unwrap(); + assert!( + !s.contains("sk-super-secret"), + "token leaked into journal form: {s}" + ); + let cfg = r.config.unwrap(); + assert_eq!(cfg.project.as_deref(), Some("codex")); + assert_eq!(cfg.auth.org_name.as_deref(), Some("acme")); + assert_eq!(cfg.auth.token_sha256_prefix.len(), 12); + } + + #[test] + fn fingerprint_changes_with_token() { + let mut a = sample().config.unwrap().auth; + let f1 = a.fingerprint(); + a.token = "sk-different".into(); + let f2 = a.fingerprint(); + assert_ne!(f1.token_sha256_prefix, f2.token_sha256_prefix); + } + + #[test] + fn flush_mode_defaults_to_fire_and_forget() { + let json = serde_json::json!({ + "auth": { "token": "t" }, + }); + let cfg: SessionConfig = serde_json::from_value(json).unwrap(); + assert_eq!(cfg.flush_mode, FlushMode::FireAndForget); + } +} diff --git a/bt-daemon/src/wire/methods.rs b/bt-daemon/src/wire/methods.rs new file mode 100644 index 0000000..c4c0fbb --- /dev/null +++ b/bt-daemon/src/wire/methods.rs @@ -0,0 +1,94 @@ +//! Method names and their param/result types. + +use serde::{Deserialize, Serialize}; + +/// Method name constants — one source of truth for both sides. +pub mod method { + pub const INITIALIZE: &str = "initialize"; + pub const EVENT_LOG: &str = "event.log"; + pub const SESSION_FLUSH: &str = "session.flush"; + pub const STATUS_GET: &str = "status.get"; + pub const DAEMON_SHUTDOWN: &str = "daemon.shutdown"; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitializeParams { + pub protocol_version: u32, + pub client: ClientInfo, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClientInfo { + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pid: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InitializeResult { + pub protocol_version: u32, + pub daemon_version: String, + #[serde(default)] + pub capabilities: Capabilities, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Capabilities { + #[serde(default)] + pub sources: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventLogResult { + pub accepted: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlushParams { + pub session_id: String, + #[serde(default = "default_flush_timeout_ms")] + pub timeout_ms: u64, +} + +fn default_flush_timeout_ms() -> u64 { + 10_000 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FlushResult { + pub flushed: bool, + pub pending: u64, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StatusParams { + /// Omit for daemon-wide status. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatusResult { + pub daemon_version: String, + pub uptime_ms: u64, + pub sessions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionStatus { + pub session_id: String, + pub source: String, + pub queued: u64, + pub spans_emitted: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permalink: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShutdownResult { + pub ok: bool, +} diff --git a/bt-daemon/src/wire/mod.rs b/bt-daemon/src/wire/mod.rs new file mode 100644 index 0000000..ff3e7c4 --- /dev/null +++ b/bt-daemon/src/wire/mod.rs @@ -0,0 +1,24 @@ +//! bt-daemon wire protocol: the envelope types and JSON-RPC framing shared by +//! the daemon (`serve`) and the plugin shims (`hook`). +//! +//! This module is pure data + (de)serialization — no I/O, no async. The +//! canonical description of the protocol lives in `docs/protocol.md`; keep +//! the two in sync. + +mod envelope; +mod methods; +mod rpc; + +pub use envelope::{ + AuthFingerprint, BackendAuth, Envelope, FlushMode, RedactedConfig, RedactedEnvelope, + SessionConfig, +}; +pub use methods::{ + method, Capabilities, ClientInfo, EventLogResult, FlushParams, FlushResult, InitializeParams, + InitializeResult, SessionStatus, ShutdownResult, StatusParams, StatusResult, +}; +pub use rpc::{error_code, Message, Request, RequestId, Response, RpcError}; + +/// The protocol version this build speaks. Bumped on any breaking change to +/// the envelope or method contracts. See `docs/protocol.md`. +pub const PROTOCOL_VERSION: u32 = 1; diff --git a/bt-daemon/src/wire/rpc.rs b/bt-daemon/src/wire/rpc.rs new file mode 100644 index 0000000..3a836ba --- /dev/null +++ b/bt-daemon/src/wire/rpc.rs @@ -0,0 +1,201 @@ +//! JSON-RPC 2.0 message types, newline-delimited on the wire. + +use serde::{Deserialize, Serialize}; + +/// A JSON-RPC request id: an integer or a string. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum RequestId { + Int(i64), + Str(String), +} + +/// A single JSON-RPC frame. Untagged so one type round-trips a request, a +/// response, or a notification; disambiguated by which fields are present. +/// +/// Note: `Response` must come before `Notification` in the enum. A response +/// carries `id` but no `method`; a notification carries `method` but no `id`; +/// a request carries both. Serde's untagged matching tries variants in order, +/// so ordering here plus `deny_unknown_fields`-free structs keeps them +/// unambiguous. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Message { + Request(Request), + Response(Response), + Notification(Notification), +} + +impl Message { + /// Parse one newline-delimited frame. + pub fn from_line(line: &str) -> Result { + serde_json::from_str(line) + } + + /// Serialize to a single line (no trailing newline). + pub fn to_line(&self) -> Result { + serde_json::to_string(self) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Request { + pub jsonrpc: JsonRpcV2, + pub id: RequestId, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +impl Request { + pub fn new(id: RequestId, method: impl Into, params: serde_json::Value) -> Self { + Self { + jsonrpc: JsonRpcV2, + id, + method: method.into(), + params: Some(params), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Notification { + pub jsonrpc: JsonRpcV2, + pub method: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub jsonrpc: JsonRpcV2, + pub id: RequestId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Response { + pub fn ok(id: RequestId, result: serde_json::Value) -> Self { + Self { + jsonrpc: JsonRpcV2, + id, + result: Some(result), + error: None, + } + } + + pub fn err(id: RequestId, error: RpcError) -> Self { + Self { + jsonrpc: JsonRpcV2, + id, + result: None, + error: Some(error), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcError { + pub code: i32, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl RpcError { + pub fn new(code: i32, message: impl Into) -> Self { + Self { + code, + message: message.into(), + data: None, + } + } +} + +/// Reserved JSON-RPC error codes plus the application range. +pub mod error_code { + pub const PARSE: i32 = -32700; + pub const INVALID_REQUEST: i32 = -32600; + pub const METHOD_NOT_FOUND: i32 = -32601; + pub const INVALID_PARAMS: i32 = -32602; + pub const INTERNAL: i32 = -32603; + /// Application errors: -32000 ..= -32099. + pub const APP: i32 = -32000; +} + +/// A zero-sized marker that serializes to the string `"2.0"` and refuses any +/// other value, so the `jsonrpc` field is validated for free. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JsonRpcV2; + +impl Serialize for JsonRpcV2 { + fn serialize(&self, s: S) -> Result { + s.serialize_str("2.0") + } +} + +impl<'de> Deserialize<'de> for JsonRpcV2 { + fn deserialize>(d: D) -> Result { + let v = String::deserialize(d)?; + if v == "2.0" { + Ok(JsonRpcV2) + } else { + Err(serde::de::Error::custom(format!( + "unsupported jsonrpc version {v:?}" + ))) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_round_trips() { + let req = Request::new( + RequestId::Int(7), + "event.log", + serde_json::json!({ "a": 1 }), + ); + let line = Message::Request(req).to_line().unwrap(); + match Message::from_line(&line).unwrap() { + Message::Request(r) => { + assert_eq!(r.method, "event.log"); + assert_eq!(r.id, RequestId::Int(7)); + } + other => panic!("expected request, got {other:?}"), + } + } + + #[test] + fn response_and_notification_disambiguate() { + let resp = Message::Response(Response::ok(RequestId::Int(1), serde_json::json!({}))) + .to_line() + .unwrap(); + assert!(matches!( + Message::from_line(&resp).unwrap(), + Message::Response(_) + )); + + let note = Message::Notification(Notification { + jsonrpc: JsonRpcV2, + method: "event.log".into(), + params: Some(serde_json::json!({})), + }) + .to_line() + .unwrap(); + assert!(matches!( + Message::from_line(¬e).unwrap(), + Message::Notification(_) + )); + } + + #[test] + fn bad_jsonrpc_version_rejected() { + let line = r#"{"jsonrpc":"1.0","id":1,"method":"x"}"#; + assert!(Message::from_line(line).is_err()); + } +} diff --git a/bt-daemon/tests/agent_integration.rs b/bt-daemon/tests/agent_integration.rs new file mode 100644 index 0000000..e5d48e4 --- /dev/null +++ b/bt-daemon/tests/agent_integration.rs @@ -0,0 +1,260 @@ +mod support; + +use axum::http::StatusCode; +use serde_json::{json, Value}; +use support::agent_process::AgentTestWorld; +use support::agents::{ClaudeAgent, ClaudeRun, CodexAgent, CodexRun}; +use support::inference::{ + AnthropicMock, AnthropicRequest, AnthropicTurn, MockReply, OpenAiMock, OpenAiRequest, + OpenAiTurn, +}; +use support::ingest::IngestScenario; +use support::server::TestServer; + +fn codex_tool_call(request: &OpenAiRequest) -> OpenAiTurn { + let names = request.tool_names(); + if names.contains(&"exec_command") { + return OpenAiTurn::tool_call( + "call_mock_1", + "exec_command", + json!({"cmd":codex_tool_command(),"login":false}), + ); + } + if names.contains(&"shell") { + return OpenAiTurn::tool_call( + "call_mock_1", + "shell", + json!({"command":codex_tool_command()}), + ); + } + if names.contains(&"shell_command") { + return OpenAiTurn::tool_call( + "call_mock_1", + "shell_command", + json!({"command":codex_tool_command()}), + ); + } + panic!("Codex offered no supported shell tool; offered tools: {names:?}"); +} + +fn codex_tool_command() -> &'static str { + #[cfg(unix)] + { + "printf CODEX_TOOL_OK" + } + #[cfg(windows)] + { + "Write-Output CODEX_TOOL_OK" + } +} + +fn row_contains(row: &Value, fragments: &[&str]) -> bool { + let serialized = serde_json::to_string(row).expect("serialize trace row"); + fragments + .iter() + .all(|fragment| serialized.contains(fragment)) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires the Codex CLI installed on PATH"] +async fn codex_session_emits_traces() { + let inference = OpenAiMock::new(|context, request| { + assert_eq!(request.model(), Some("mock-model")); + match context.request_index { + 0 => { + assert!( + request.contains_text("CODEX_TOOL_OK"), + "unexpected Codex request: {}", + request.body + ); + MockReply::response(codex_tool_call(&request)) + } + 1 => { + assert!( + request.has_function_output("call_mock_1"), + "Codex did not return the tool result: {}", + request.body + ); + MockReply::response(OpenAiTurn::text("CODEX_MOCK_OK")) + } + 2 => MockReply::http_error( + StatusCode::BAD_REQUEST, + json!({ + "error": { + "type": "invalid_request_error", + "code": "mock_bad_request", + "message": "deterministic Codex inference failure" + } + }), + ), + index => panic!( + "unexpected Codex inference request {index}: {}", + request.body + ), + } + }); + let inference_server = TestServer::start(inference.router()).await; + let world = AgentTestWorld::start().await; + let codex = CodexAgent::install(&world).await; + + let output = codex + .run( + &world, + CodexRun::new("Run the command `printf CODEX_TOOL_OK` and then reply briefly.") + .mock_inference(inference_server.uri()), + ) + .await; + output.assert_success(); + if world.uses_mock_inference() { + output.assert_contains("CODEX_MOCK_OK"); + assert_eq!(inference.requests().len(), 2); + + let failed = codex + .run( + &world, + CodexRun::new("Trigger the deterministic inference error.") + .mock_inference(inference_server.uri()), + ) + .await; + failed.assert_failure(); + failed.assert_contains("deterministic Codex inference failure"); + assert_eq!(inference.requests().len(), 3); + } + + let rows = world.wait_for_trace_delivery().await; + if world.uses_mock_ingest() { + assert!( + rows.iter() + .any(|row| { row_contains(row, &["braintrust.plugin.codex", "test_harness"]) }), + "Codex trace origin metadata was not emitted" + ); + } + if world.uses_mock_inference() && world.uses_mock_ingest() { + let scenario = IngestScenario::new() + .expect("Codex trace origin", |row| { + row_contains(row, &["braintrust.plugin.codex", "test_harness"]) + }) + .expect("Codex tool output", |row| { + row_contains(row, &[r#""type":"tool""#, "CODEX_TOOL_OK"]) + }); + world.wait_for_mock_ingest_scenario(&scenario).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires the Claude Code CLI installed on PATH"] +async fn claude_session_emits_traces() { + let inference = AnthropicMock::new(|context, request| match context.request_index { + 0 => { + assert_eq!(request.model(), Some("mock-model")); + assert!( + request.contains_text("CLAUDE_TOOL_OK"), + "unexpected Claude request: {}", + request.body + ); + MockReply::response(AnthropicTurn::tool_use( + "toolu_mock_1", + "Bash", + json!({"command":"printf CLAUDE_TOOL_OK"}), + )) + } + 1 => { + assert!( + request.has_tool_result("toolu_mock_1"), + "Claude did not return the tool result: {}", + request.body + ); + MockReply::response(AnthropicTurn::text("CLAUDE_MOCK_OK")) + } + 2 => MockReply::http_error( + StatusCode::BAD_REQUEST, + json!({ + "type": "error", + "error": { + "type": "invalid_request_error", + "message": "deterministic Claude inference failure" + } + }), + ), + index => panic!( + "unexpected Claude inference request {index}: {}", + request.body + ), + }); + let inference_server = TestServer::start(inference.router()).await; + let world = AgentTestWorld::start().await; + let claude = ClaudeAgent::new(&world); + + let output = claude + .run( + &world, + ClaudeRun::new("Run the command `printf CLAUDE_TOOL_OK` and then reply briefly.") + .mock_inference(inference_server.uri()), + ) + .await; + output.assert_success(); + if world.uses_mock_inference() { + output.assert_contains("CLAUDE_MOCK_OK"); + assert_eq!(inference.requests().len(), 2); + + let failed = claude + .run( + &world, + ClaudeRun::new("Trigger the deterministic inference error.") + .mock_inference(inference_server.uri()), + ) + .await; + failed.assert_failure(); + failed.assert_contains("deterministic Claude inference failure"); + assert_eq!(inference.requests().len(), 3); + } + + let rows = world.wait_for_trace_delivery().await; + if world.uses_mock_ingest() { + assert!( + rows.iter() + .any(|row| { row_contains(row, &[r#""source":"claude-code""#, "test_harness"]) }), + "Claude trace source metadata was not emitted" + ); + } + if world.uses_mock_inference() && world.uses_mock_ingest() { + let scenario = IngestScenario::new() + .expect("Claude trace source", |row| { + row_contains(row, &[r#""source":"claude-code""#, "test_harness"]) + }) + .expect("Claude tool output", |row| { + row_contains(row, &[r#""type":"tool""#, "CLAUDE_TOOL_OK"]) + }); + world.wait_for_mock_ingest_scenario(&scenario).await; + } +} + +#[test] +fn request_helpers_recognize_tool_results_and_advertised_tools() { + let openai = OpenAiRequest { + body: json!({ + "input":[{"type":"function_call_output","call_id":"call-1"}], + "tools":[{"type":"function","name":"shell_command"}] + }), + }; + assert!(openai.has_function_output("call-1")); + assert_eq!(openai.tool_names(), vec!["shell_command"]); + match codex_tool_call(&openai) { + OpenAiTurn::ToolCall { + name, arguments, .. + } => { + assert_eq!(name, "shell_command"); + assert!(arguments["command"].is_string()); + } + _ => panic!("expected a Codex tool call"), + } + + let anthropic = AnthropicRequest { + body: json!({ + "messages":[{ + "content":[{"type":"tool_result","tool_use_id":"toolu-1"}] + }] + }), + }; + assert!(anthropic.has_tool_result("toolu-1")); +} diff --git a/bt-daemon/tests/braintrust_sink.rs b/bt-daemon/tests/braintrust_sink.rs new file mode 100644 index 0000000..d36dbf8 --- /dev/null +++ b/bt-daemon/tests/braintrust_sink.rs @@ -0,0 +1,353 @@ +//! Phase 2: the Braintrust sink actually delivers spans. Runs against a +//! wiremock stand-in for the Braintrust backend (the endpoints the SDK hits +//! with `skip_login`: GET /version, POST /api/project/register, POST /logs3). + +use bt_daemon::wire::{BackendAuth, FlushMode, SessionConfig}; +use bt_daemon::{ + BraintrustSinkConfig, BraintrustSinkFactory, SinkFactory, SpanOp, SpanRow, SpanType, +}; +use serde_json::json; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +fn session_config(base: &str) -> SessionConfig { + SessionConfig { + auth: BackendAuth { + token: "sk-test".into(), + api_url: Some(base.to_string()), + app_url: Some(base.to_string()), + org_name: Some("acme".into()), + org_id: None, + }, + project: Some("my-project".into()), + parent_span_id: None, + root_span_id: None, + flush_mode: FlushMode::FireAndForget, + additional_metadata: None, + } +} + +fn row( + span_id: &str, + root: &str, + parents: &[&str], + name: &str, + ty: SpanType, + start: i64, + end: Option, +) -> SpanRow { + SpanRow { + span_id: span_id.into(), + root_span_id: root.into(), + parent_span_ids: parents.iter().map(|s| s.to_string()).collect(), + name: name.into(), + span_type: ty, + start_ms: Some(start), + end_ms: end, + input: None, + output: None, + metadata: None, + metrics: None, + error: None, + tags: None, + } +} + +/// Mount the three endpoints the SDK hits under `skip_login`. +async fn mock_backend() -> MockServer { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/version")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/project/register")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "project": { "id": "proj-1" } })), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/logs3")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + server +} + +async fn logs3_bodies(server: &MockServer) -> String { + server + .received_requests() + .await + .unwrap() + .iter() + .filter(|r| r.url.path() == "/logs3") + .map(|r| String::from_utf8_lossy(&r.body).into_owned()) + .collect::>() + .join("\n") +} + +/// Two sessions on two different backend URLs, from one factory, each deliver +/// only to their own collector — the per-`(api_url, app_url)` client cache. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_profile_sessions_route_to_their_own_backend() { + let server_a = mock_backend().await; + let server_b = mock_backend().await; + let (base_a, base_b) = (server_a.uri(), server_b.uri()); + + // No daemon-level default URLs: each session brings its own (as bt does). + let factory = BraintrustSinkFactory::new(BraintrustSinkConfig { + api_url: None, + app_url: None, + version: "test".into(), + }); + + let mut sink_a = factory.create("sess-a", "codex").unwrap(); + sink_a.configure(&session_config(&base_a)); + sink_a + .emit(&[SpanOp::Insert(row( + "span-A", + "span-A", + &[], + "A", + SpanType::Task, + 1, + Some(2), + ))]) + .await + .unwrap(); + sink_a.flush().await.unwrap(); + + let mut sink_b = factory.create("sess-b", "codex").unwrap(); + sink_b.configure(&session_config(&base_b)); + sink_b + .emit(&[SpanOp::Insert(row( + "span-B", + "span-B", + &[], + "B", + SpanType::Task, + 1, + Some(2), + ))]) + .await + .unwrap(); + sink_b.flush().await.unwrap(); + + let a = logs3_bodies(&server_a).await; + let b = logs3_bodies(&server_b).await; + assert!(a.contains("span-A"), "server A missing its span"); + assert!(!a.contains("span-B"), "server A leaked session B's span"); + assert!(b.contains("span-B"), "server B missing its span"); + assert!(!b.contains("span-A"), "server B leaked session A's span"); +} + +/// Regression: an `Insert` that names a span, followed by a `Merge` that +/// doesn't (the common "close/annotate" pattern, which builds `SpanRow` with +/// `..Default::default()` and an empty `name`), must not clobber the name. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn merge_with_empty_name_does_not_clobber_the_original_name() { + let server = mock_backend().await; + let base = server.uri(); + let factory = BraintrustSinkFactory::new(BraintrustSinkConfig { + api_url: Some(base.clone()), + app_url: Some(base.clone()), + version: "test".into(), + }); + let mut sink = factory.create("sess-1", "codex").unwrap(); + sink.configure(&session_config(&base)); + + let named = row("s1", "s1", &[], "codex: myapp", SpanType::Task, 1, None); + let mut closing = row("s1", "s1", &[], "", SpanType::Task, 1, Some(2)); + closing.name = String::new(); // as produced by `..Default::default()` + + sink.emit(&[SpanOp::Insert(named), SpanOp::Merge(closing)]) + .await + .unwrap(); + sink.flush().await.unwrap(); + + let bodies = logs3_bodies(&server).await; + assert!( + bodies.contains("codex: myapp"), + "name lost after merge: {bodies}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attached_trace_children_keep_the_external_root() { + let server = mock_backend().await; + let base = server.uri(); + let factory = BraintrustSinkFactory::new(BraintrustSinkConfig { + api_url: Some(base.clone()), + app_url: Some(base.clone()), + version: "test".into(), + }); + let mut sink = factory.create("sess-1", "codex").unwrap(); + let mut config = session_config(&base); + config.parent_span_id = Some("external-parent".into()); + config.root_span_id = Some("external-root".into()); + sink.configure(&config); + sink.emit(&[SpanOp::Insert(row( + "child", + "daemon-internal-root", + &["daemon-parent"], + "tool", + SpanType::Tool, + 1, + Some(2), + ))]) + .await + .unwrap(); + sink.flush().await.unwrap(); + + let bodies = logs3_bodies(&server).await; + assert!( + bodies.contains("external-root"), + "child lost attached trace root: {bodies}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn braintrust_sink_delivers_spans_to_collector() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/version")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/api/project/register")) + .respond_with( + ResponseTemplate::new(200).set_body_json(json!({ "project": { "id": "proj-1" } })), + ) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/logs3")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .mount(&server) + .await; + + let base = server.uri(); + let factory = BraintrustSinkFactory::new(BraintrustSinkConfig { + api_url: Some(base.clone()), + app_url: Some(base.clone()), + version: "test".into(), + }); + + let mut sink = factory.create("sess-1", "codex").unwrap(); + sink.configure(&session_config(&base)); + + // A session root (task) and a child tool span under it. + let root = row( + "rootspan1", + "rootspan1", + &[], + "codex: sess-1", + SpanType::Task, + 1000, + None, + ); + let tool = row( + "toolspan1", + "rootspan1", + &["rootspan1"], + "shell", + SpanType::Tool, + 1001, + Some(1002), + ); + let mut tool = tool; + tool.input = Some(json!({ "command": "ls" })); + tool.output = Some(json!("ok")); + + sink.emit(&[SpanOp::Insert(root), SpanOp::Insert(tool)]) + .await + .unwrap(); + sink.flush().await.unwrap(); + + let requests = server.received_requests().await.unwrap(); + let logs: Vec<_> = requests + .iter() + .filter(|r| r.url.path() == "/logs3") + .collect(); + assert!(!logs.is_empty(), "expected at least one POST /logs3"); + + let bodies: String = logs + .iter() + .map(|r| String::from_utf8_lossy(&r.body).into_owned()) + .collect::>() + .join("\n"); + + // Both spans, our deterministic ids, and the parent linkage made it into a + // logs3 payload. + assert!( + bodies.contains("rootspan1"), + "root span id missing from logs3 body" + ); + assert!( + bodies.contains("toolspan1"), + "tool span id missing from logs3 body" + ); + assert!(bodies.contains("codex: sess-1"), "root span name missing"); + assert!(bodies.contains("\"command\""), "tool input missing"); + + // Project registration happened (org_name path, no login). + assert!( + requests + .iter() + .any(|r| r.url.path() == "/api/project/register"), + "expected project registration" + ); + // skip_login: no apikey login call. + assert!( + !requests.iter().any(|r| r.url.path() == "/api/apikey/login"), + "should not have called login with skip_login" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn experiment_sessions_use_experiment_object_type_and_id() { + let server = mock_backend().await; + let base = server.uri(); + let factory = BraintrustSinkFactory::new(BraintrustSinkConfig { + api_url: Some(base.clone()), + app_url: Some(base.clone()), + version: "test".into(), + }); + let mut sink = factory.create("sess-exp", "claude-code").unwrap(); + let mut config = session_config(&base); + config.additional_metadata = Some(json!({"_bt_experiment_id":"exp-42"})); + sink.configure(&config); + sink.emit(&[ + SpanOp::Insert(row( + "exp-root", + "exp-root", + &[], + "Claude Code", + SpanType::Task, + 1, + None, + )), + SpanOp::Insert(row( + "exp-child", + "exp-root", + &["exp-root"], + "Turn 1", + SpanType::Task, + 2, + Some(3), + )), + ]) + .await + .unwrap(); + sink.flush().await.unwrap(); + + let bodies = logs3_bodies(&server).await; + assert!(bodies.contains("exp-42"), "experiment id absent: {bodies}"); + assert!( + !bodies.contains("\"project_id\""), + "experiment spans were routed as project logs: {bodies}" + ); +} diff --git a/bt-daemon/tests/claude_translator.rs b/bt-daemon/tests/claude_translator.rs new file mode 100644 index 0000000..51fe3fc --- /dev/null +++ b/bt-daemon/tests/claude_translator.rs @@ -0,0 +1,518 @@ +use bt_daemon::wire::Envelope; +use bt_daemon::{Registry, SessionCtx, SpanOp, SpanRow, SpanType}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../src/plugins/claude/content/plugins/trace-claude-code/test/fixtures/sessions") + .join(name) +} + +fn replay(name: &str) -> Vec { + let dir = fixture(name); + let contents = std::fs::read_to_string(dir.join("events.ndjson")).unwrap(); + let first: Value = serde_json::from_str(contents.lines().next().unwrap()).unwrap(); + let session_id = first["payload"]["session_id"].as_str().unwrap(); + let registry = Registry::default_agents(); + let mut translator = registry.create("claude-code", session_id); + let ctx = SessionCtx { + session_id: session_id.to_string(), + config: None, + }; + let mut ops = Vec::new(); + + for line in contents.lines() { + let record: Value = serde_json::from_str(line).unwrap(); + let mut payload = record["payload"].clone(); + for field in ["transcript_path", "agent_transcript_path"] { + let Some(original) = payload.get(field).and_then(Value::as_str) else { + continue; + }; + let basename = Path::new(original).file_name().unwrap(); + let local = dir.join("transcripts").join(basename); + if local.exists() { + payload[field] = json!(local.to_str().unwrap()); + payload["_bt_transcript_snapshot"] = json!({ + "path": local.to_str().unwrap(), + "contents": std::fs::read_to_string(&local).unwrap() + }); + } + } + let ts_ms = chrono::DateTime::parse_from_rfc3339(record["ts"].as_str().unwrap()) + .unwrap() + .timestamp_millis(); + let env = Envelope { + source: "claude-code".into(), + source_version: None, + session_id: session_id.into(), + event: record["hook"].as_str().unwrap().into(), + ts_ms, + payload, + config: None, + }; + ops.extend(translator.handle(&env, &ctx).unwrap()); + } + ops.extend(translator.flush(&ctx).unwrap()); + ops +} + +fn reduce(ops: Vec) -> HashMap { + let mut rows = HashMap::::new(); + for op in ops { + match op { + SpanOp::Insert(row) => { + rows.insert(row.span_id.clone(), row); + } + SpanOp::Merge(update) => { + let row = rows.entry(update.span_id.clone()).or_default(); + if update.end_ms.is_some() { + row.end_ms = update.end_ms; + } + if update.output.is_some() { + row.output = update.output; + } + if update.metadata.is_some() { + row.metadata = update.metadata; + } + if update.error.is_some() { + row.error = update.error; + } + } + } + } + rows +} + +#[test] +fn claude_real_fixture_matches_session_turn_tool_and_token_contract() { + let rows = reduce(replay("test-fixture")); + let roots: Vec<_> = rows + .values() + .filter(|row| row.name.starts_with("Claude Code:")) + .collect(); + let turns: Vec<_> = rows + .values() + .filter(|row| row.name.starts_with("Turn ")) + .collect(); + let tools: Vec<_> = rows + .values() + .filter(|row| row.span_type == SpanType::Tool) + .collect(); + let llms: Vec<_> = rows + .values() + .filter(|row| row.span_type == SpanType::Llm) + .collect(); + + assert_eq!(roots.len(), 1); + assert_eq!(turns.len(), 4); + assert_eq!(tools.len(), 13); + assert_eq!(llms.len(), 7, "one LLM span per unique requestId"); + assert!(turns.iter().all(|turn| turn.end_ms.is_some())); + assert!(tools.iter().all(|tool| { + tool.metadata.as_ref().and_then(|m| m.get("tool_approval")) == Some(&json!("approved")) + })); + + let total = |key: &str| -> u64 { + llms.iter() + .map(|row| { + row.metrics + .as_ref() + .and_then(|m| m.get(key)) + .and_then(Value::as_u64) + .unwrap_or(0) + }) + .sum() + }; + assert_eq!(total("prompt_tokens"), 187_216); + assert_eq!(total("completion_tokens"), 1_867); + assert_eq!(total("prompt_cached_tokens"), 165_784); + assert_eq!(total("tokens"), 189_083); + + let mut llms_per_turn = turns + .iter() + .map(|turn| { + ( + turn.name.clone(), + llms.iter() + .filter(|llm| llm.parent_span_ids.first() == Some(&turn.span_id)) + .count(), + ) + }) + .collect::>(); + llms_per_turn.sort(); + assert_eq!( + llms_per_turn, + vec![ + ("Turn 1".into(), 2), + ("Turn 2".into(), 1), + ("Turn 3".into(), 3), + ("Turn 4".into(), 1), + ], + "late transcript rows must remain attached to the turn that produced them" + ); + assert!(llms.iter().any(|llm| { + let roles = llm + .input + .as_ref() + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|message| message.get("role").and_then(Value::as_str)) + .collect::>(); + roles.contains(&"assistant") && roles.contains(&"tool") + })); + assert!(llms.iter().any(|llm| { + llm.output + .as_ref() + .and_then(|output| output.get("tool_calls")) + .and_then(Value::as_array) + .into_iter() + .flatten() + .any(|call| { + call.pointer("/function/arguments") + .is_some_and(Value::is_string) + }) + })); +} + +#[test] +fn claude_subagent_fixture_builds_nested_subagent_llms() { + let rows = reduce(replay("subagent-compact")); + let subagents: Vec<_> = rows + .values() + .filter(|row| row.name.starts_with("subagent:")) + .collect(); + assert!(subagents.len() >= 2); + assert!(subagents.iter().all(|row| row.end_ms.is_some())); + + let subagent_ids: Vec<_> = subagents.iter().map(|row| row.span_id.as_str()).collect(); + let nested_llms: Vec<_> = rows + .values() + .filter(|row| { + row.span_type == SpanType::Llm + && row + .parent_span_ids + .first() + .is_some_and(|parent| subagent_ids.contains(&parent.as_str())) + }) + .collect(); + assert!( + !nested_llms.is_empty(), + "subagent transcripts should produce LLM children" + ); + let nested_tools = rows + .values() + .filter(|row| { + row.span_type == SpanType::Tool + && row + .parent_span_ids + .first() + .is_some_and(|parent| subagent_ids.contains(&parent.as_str())) + }) + .count(); + assert!( + nested_tools >= 20, + "subagent hook tools should be children of their subagent task" + ); +} + +#[test] +fn claude_permission_denied_and_failed_tools_are_first_class_spans() { + let registry = Registry::default_agents(); + let mut translator = registry.create("claude-code", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let event = |name: &str, payload: Value| Envelope { + source: "claude-code".into(), + source_version: None, + session_id: "s".into(), + event: name.into(), + ts_ms: 1, + payload, + config: None, + }; + let mut ops = translator + .handle( + &event( + "UserPromptSubmit", + json!({"session_id":"s","cwd":"/tmp/x","prompt":"go"}), + ), + &ctx, + ) + .unwrap(); + ops.extend( + translator + .handle( + &event( + "PermissionDenied", + json!({ + "session_id":"s", + "tool_name":"Bash", + "tool_use_id":"a", + "tool_input":{"command":"no"}, + "permission":{"id":"p1","type":"tool","title":"Run command"} + }), + ), + &ctx, + ) + .unwrap(), + ); + ops.extend( + translator + .handle( + &event( + "PostToolUseFailure", + json!({"session_id":"s","tool_name":"Read","tool_use_id":"b","tool_input":{"file_path":"x"},"error":"missing"}), + ), + &ctx, + ) + .unwrap(), + ); + let rows = reduce(ops); + let tools: Vec<_> = rows + .values() + .filter(|row| row.span_type == SpanType::Tool) + .collect(); + assert_eq!(tools.len(), 2); + assert!(tools + .iter() + .any(|row| { row.metadata.as_ref().unwrap()["tool_approval"] == json!("denied") })); + let denied = tools + .iter() + .find(|row| row.metadata.as_ref().unwrap()["tool_approval"] == json!("denied")) + .unwrap(); + assert_eq!( + denied.metadata.as_ref().unwrap()["permission_id"], + json!("p1") + ); + assert_eq!( + denied.metadata.as_ref().unwrap()["permission_title"], + json!("Run command") + ); + assert!(tools + .iter() + .any(|row| row.error.as_deref() == Some("missing"))); +} + +#[test] +fn claude_pairs_tool_lifecycle_and_marks_explicit_skills_and_stop_failures() { + let registry = Registry::default_agents(); + let mut translator = registry.create("claude-code", "lifecycle"); + let ctx = SessionCtx { + session_id: "lifecycle".into(), + config: None, + }; + let event = |name: &str, ts_ms: i64, payload: Value| Envelope { + source: "claude-code".into(), + source_version: Some("2.0.0".into()), + session_id: "lifecycle".into(), + event: name.into(), + ts_ms, + payload, + config: None, + }; + let mut ops = Vec::new(); + for envelope in [ + event( + "UserPromptSubmit", + 10, + json!({"session_id":"lifecycle","cwd":"/tmp/x","prompt":"go"}), + ), + event( + "PreToolUse", + 20, + json!({"session_id":"lifecycle","tool_name":"Skill","tool_use_id":"skill-1","tool_input":{"skill":"review"}}), + ), + event( + "PostToolUse", + 30, + json!({"session_id":"lifecycle","tool_name":"Skill","tool_use_id":"skill-1","tool_input":{"skill":"review"},"tool_response":{"output":"loaded"}}), + ), + event( + "StopFailure", + 40, + json!({"session_id":"lifecycle","error":"model process exited"}), + ), + ] { + ops.extend(translator.handle(&envelope, &ctx).unwrap()); + } + let rows = reduce(ops); + let skill = rows + .values() + .find(|row| row.span_type == SpanType::Tool) + .unwrap(); + assert_eq!(skill.start_ms, Some(20)); + assert_eq!(skill.end_ms, Some(30)); + assert_eq!(skill.error, None); + assert_eq!( + skill.metadata.as_ref().unwrap()["skill_load_trigger"], + json!("explicit") + ); + let turn = rows.values().find(|row| row.name == "Turn 1").unwrap(); + assert_eq!(turn.error.as_deref(), Some("model process exited")); +} + +#[test] +fn claude_groups_streamed_rows_and_reads_late_final_output_at_session_end() { + let base = chrono::DateTime::parse_from_rfc3339("2026-07-28T16:00:00Z") + .unwrap() + .timestamp_millis(); + let dir = tempfile::tempdir().unwrap(); + let transcript = dir.path().join("session.jsonl"); + let usage = json!({ + "input_tokens": 10, + "output_tokens": 5, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0 + }); + let records = [ + json!({ + "type": "user", + "uuid": "user-1", + "timestamp": "2026-07-28T16:00:00Z", + "message": {"role": "user", "content": "run it"} + }), + json!({ + "type": "assistant", + "uuid": "assistant-thinking-row", + "timestamp": "2026-07-28T16:00:01Z", + "message": { + "id": "msg-native-request", + "model": "claude-test", + "role": "assistant", + "content": [{"type": "thinking", "thinking": ""}], + "usage": usage + } + }), + json!({ + "type": "assistant", + "uuid": "assistant-tool-row", + "timestamp": "2026-07-28T16:00:02Z", + "message": { + "id": "msg-native-request", + "model": "claude-test", + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "tool-1", + "name": "Bash", + "input": {"command": "true"} + }], + "usage": usage + } + }), + ]; + std::fs::write( + &transcript, + records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"), + ) + .unwrap(); + + let registry = Registry::default_agents(); + let mut translator = registry.create("claude-code", "streamed"); + let ctx = SessionCtx { + session_id: "streamed".into(), + config: None, + }; + let event = |name: &str, ts_ms: i64, payload: Value| Envelope { + source: "claude-code".into(), + source_version: None, + session_id: "streamed".into(), + event: name.into(), + ts_ms, + payload, + config: None, + }; + let mut ops = translator + .handle( + &event( + "UserPromptSubmit", + base, + json!({"session_id":"streamed","cwd":"/tmp/x","prompt":"run it"}), + ), + &ctx, + ) + .unwrap(); + ops.extend( + translator + .handle( + &event( + "Stop", + base + 2_500, + json!({ + "session_id": "streamed", + "transcript_path": transcript, + "last_assistant_message": "" + }), + ), + &ctx, + ) + .unwrap(), + ); + + let final_record = json!({ + "type": "assistant", + "uuid": "assistant-final-row", + "timestamp": "2026-07-28T16:00:03Z", + "message": { + "id": "msg-final-request", + "model": "claude-test", + "role": "assistant", + "content": [{"type": "text", "text": "done"}], + "usage": { + "input_tokens": 11, + "output_tokens": 1, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0 + } + } + }); + let previous = std::fs::read_to_string(&transcript).unwrap(); + std::fs::write(&transcript, format!("{previous}\n{final_record}")).unwrap(); + ops.extend( + translator + .handle( + &event( + "SessionEnd", + base + 4_000, + json!({ + "session_id": "streamed", + "transcript_path": transcript + }), + ), + &ctx, + ) + .unwrap(), + ); + + let rows = reduce(ops); + let llms = rows + .values() + .filter(|row| row.span_type == SpanType::Llm) + .collect::>(); + assert_eq!(llms.len(), 2); + let streamed = llms + .iter() + .find(|row| row.metadata.as_ref().unwrap()["request_id"] == json!("msg-native-request")) + .unwrap(); + assert_eq!( + streamed.output.as_ref().unwrap()["tool_calls"][0]["id"], + json!("tool-1") + ); + let final_output = llms + .iter() + .find(|row| row.metadata.as_ref().unwrap()["request_id"] == json!("msg-final-request")) + .unwrap(); + assert_eq!( + final_output.output.as_ref().unwrap()["content"], + json!("done") + ); +} diff --git a/bt-daemon/tests/codex_translator.rs b/bt-daemon/tests/codex_translator.rs new file mode 100644 index 0000000..95ebc2d --- /dev/null +++ b/bt-daemon/tests/codex_translator.rs @@ -0,0 +1,944 @@ +//! Phase 3 core: the Codex translator turns a transcript ("rollout" JSONL) plus +//! hook triggers into a session → turn → {llm, tool} span tree. Mirrors the +//! happy-path shape of the TS `event-processor` tests. + +use bt_daemon::wire::{BackendAuth, Envelope, FlushMode, SessionConfig}; +use bt_daemon::{Registry, SessionCtx, SpanOp, SpanRow, SpanType}; +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::io::Write; + +fn line(v: Value) -> String { + serde_json::to_string(&v).unwrap() +} + +/// Write the full happy-path transcript to `path`. +fn write_transcript(path: &std::path::Path) { + let records = vec![ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "session-1", "cwd": "/whatever/myapp", "cli_version": "1.2.3" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "turn_context", + "payload": { "model": "gpt-5.5" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "event_msg", + "payload": { "type": "user_message", "message": "list the files" } }), + json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "response_item", + "payload": { "type": "reasoning", + "summary": [{ "type": "summary_text", "text": "I'll run ls" }], + "encrypted_content": "opaque" } }), + json!({ "timestamp": "2026-01-01T00:00:06Z", "type": "response_item", + "payload": { "type": "message", "role": "assistant", + "content": [{ "type": "output_text", "text": "Running ls." }] } }), + json!({ "timestamp": "2026-01-01T00:00:07Z", "type": "response_item", + "payload": { "type": "function_call", "call_id": "c1", "name": "shell", + "arguments": "{\"command\":\"ls\"}", "metadata": { "turn_id": "t1" } } }), + json!({ "timestamp": "2026-01-01T00:00:08Z", "type": "event_msg", + "payload": { "type": "token_count", + "info": { "last_token_usage": { "input_tokens": 100, "output_tokens": 20, "total_tokens": 120 } } } }), + json!({ "timestamp": "2026-01-01T00:00:09Z", "type": "response_item", + "payload": { "type": "function_call_output", "call_id": "c1", "output": "README.md\nsrc" } }), + json!({ "timestamp": "2026-01-01T00:00:10Z", "type": "event_msg", + "payload": { "type": "task_complete", "last_agent_message": "Here are the files." } }), + ]; + let mut f = std::fs::File::create(path).unwrap(); + for r in records { + writeln!(f, "{}", line(r)).unwrap(); + } +} + +fn envelope(session: &str, event: &str, transcript_path: &str, extra: Value) -> Envelope { + let mut payload = json!({ "session_id": session, "hook_event_name": event, "transcript_path": transcript_path }); + if let (Value::Object(p), Value::Object(e)) = (&mut payload, &extra) { + for (k, v) in e { + p.insert(k.clone(), v.clone()); + } + } + Envelope { + source: "codex".into(), + source_version: None, + session_id: session.into(), + event: event.into(), + ts_ms: 0, + payload, + config: None, + } +} + +/// Reduce a stream of span ops into final rows, applying merges by span_id. +fn reduce(ops: Vec) -> HashMap { + let mut map: HashMap = HashMap::new(); + for op in ops { + match op { + SpanOp::Insert(r) => { + map.insert(r.span_id.clone(), r); + } + SpanOp::Merge(r) => { + let e = map.entry(r.span_id.clone()).or_insert_with(|| r.clone()); + if r.end_ms.is_some() { + e.end_ms = r.end_ms; + } + if r.output.is_some() { + e.output = r.output.clone(); + } + if r.input.is_some() { + e.input = r.input.clone(); + } + if r.metrics.is_some() { + e.metrics = r.metrics.clone(); + } + if r.error.is_some() { + e.error = r.error.clone(); + } + if !r.parent_span_ids.is_empty() { + e.parent_span_ids = r.parent_span_ids.clone(); + } + if !r.name.is_empty() { + e.name = r.name.clone(); + } + if let Some(t) = &r.tags { + e.tags = Some(t.clone()); + } + // Merge metadata objects key-by-key. + if let Some(Value::Object(incoming)) = &r.metadata { + let base = match e.metadata.take() { + Some(Value::Object(m)) => m, + _ => serde_json::Map::new(), + }; + let mut merged = base; + for (k, v) in incoming { + merged.insert(k.clone(), v.clone()); + } + e.metadata = Some(Value::Object(merged)); + } + } + } + } + map +} + +fn find<'a>(rows: &'a HashMap, ty: SpanType, name: &str) -> &'a SpanRow { + rows.values() + .find(|r| r.span_type == ty && r.name == name) + .unwrap_or_else(|| { + panic!( + "no {ty:?} span named {name:?}; have: {:?}", + rows.values() + .map(|r| (&r.name, r.span_type)) + .collect::>() + ) + }) +} + +#[test] +fn codex_happy_path_builds_session_turn_llm_tool_tree() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + write_transcript(&transcript); + let tpath = transcript.to_str().unwrap(); + + let reg = Registry::default_agents(); + let mut tr = reg.create("codex", "sess-1"); + let ctx = SessionCtx { + session_id: "sess-1".into(), + config: None, + }; + + let mut ops = Vec::new(); + // SessionStart carries source/permission_mode and triggers the first read. + ops.extend( + tr.handle( + &envelope( + "sess-1", + "SessionStart", + tpath, + json!({ "source": "startup", "permission_mode": "auto" }), + ), + &ctx, + ) + .unwrap(), + ); + // A later trigger (Stop) — nothing new in the transcript here. + ops.extend( + tr.handle(&envelope("sess-1", "Stop", tpath, json!({})), &ctx) + .unwrap(), + ); + ops.extend(tr.flush(&ctx).unwrap()); + + let rows = reduce(ops); + + // Root (session). + let root = find(&rows, SpanType::Task, "codex: myapp"); + assert!( + root.parent_span_ids.is_empty(), + "root should have no parent" + ); + let md = root.metadata.as_ref().unwrap(); + assert_eq!(md["session_id"], json!("session-1")); + assert_eq!( + md["model"], + json!("gpt-5.5"), + "model backfilled from turn_context" + ); + assert_eq!(md["source"], json!("startup")); + assert_eq!(md["permission_mode"], json!("auto")); + + // Turn. + let turn = find(&rows, SpanType::Task, "turn: t1"); + assert_eq!(turn.parent_span_ids, vec![root.span_id.clone()]); + assert_eq!(turn.input, Some(json!("list the files"))); + assert_eq!(turn.output, Some(json!("Here are the files."))); + assert!( + turn.end_ms.is_some(), + "turn should be closed by task_complete" + ); + + // LLM span under the turn, with token metrics. + let llm = find(&rows, SpanType::Llm, "gpt-5.5"); + assert_eq!(llm.parent_span_ids, vec![turn.span_id.clone()]); + assert!(llm.end_ms.is_some(), "llm closed by token_count"); + let m = llm.metrics.as_ref().unwrap(); + assert_eq!(m["prompt_tokens"], json!(100.0)); + assert_eq!(m["completion_tokens"], json!(20.0)); + assert_eq!(m["tokens"], json!(120.0)); + assert_eq!( + llm.output.as_ref().unwrap()[0]["summary"][0], + json!({ "type": "summary_text", "text": "I'll run ls" }) + ); + + // Tool span under the turn. + let tool = find(&rows, SpanType::Tool, "shell"); + assert_eq!(tool.parent_span_ids, vec![turn.span_id.clone()]); + assert_eq!(tool.input, Some(json!("{\"command\":\"ls\"}"))); + assert_eq!(tool.output, Some(json!("README.md\nsrc"))); + assert!(tool.end_ms.is_some(), "tool closed by function_call_output"); + + // Exactly one of each in this trace. + assert_eq!( + rows.values() + .filter(|r| r.span_type == SpanType::Task) + .count(), + 2 + ); + assert_eq!( + rows.values() + .filter(|r| r.span_type == SpanType::Llm) + .count(), + 1 + ); + assert_eq!( + rows.values() + .filter(|r| r.span_type == SpanType::Tool) + .count(), + 1 + ); +} + +#[test] +fn codex_incremental_reads_advance_offset() { + // Two reads: the second only sees records appended after the first. + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + let tpath = transcript.to_str().unwrap(); + + let reg = Registry::default_agents(); + let mut tr = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + + let mut f = std::fs::File::create(&transcript).unwrap(); + writeln!(f, "{}", line(json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", "payload": { "id": "s", "cwd": "/x/app" } }))).unwrap(); + f.flush().unwrap(); + + let first = tr + .handle(&envelope("s", "SessionStart", tpath, json!({})), &ctx) + .unwrap(); + assert_eq!(first.len(), 1, "first read: just the root insert"); + + writeln!(f, "{}", line(json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg", "payload": { "type": "task_started", "turn_id": "t1" } }))).unwrap(); + f.flush().unwrap(); + + let second = tr + .handle(&envelope("s", "UserPromptSubmit", tpath, json!({})), &ctx) + .unwrap(); + assert_eq!(second.len(), 1, "second read: only the new turn insert"); + match &second[0] { + SpanOp::Insert(r) => assert_eq!(r.name, "turn: t1"), + _ => panic!("expected a turn insert"), + } +} + +#[test] +fn codex_import_checkpoints_preserve_native_turn_boundaries() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + let tpath = transcript.to_str().unwrap(); + for value in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg", "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", "payload": { "type": "task_complete", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "event_msg", "payload": { "type": "task_started", "turn_id": "t2" } }), + ] { + append(&transcript, value); + } + + let reg = Registry::default_agents(); + let mut translator = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let first = translator + .handle( + &envelope( + "s", + "ImportCheckpoint", + tpath, + json!({ "_bt_import_through_ms": 1_767_225_603_000_i64 }), + ), + &ctx, + ) + .unwrap(); + assert!(first + .iter() + .any(|op| matches!(op, SpanOp::Insert(row) if row.name == "turn: t1"))); + assert!(!first + .iter() + .any(|op| matches!(op, SpanOp::Insert(row) if row.name == "turn: t2"))); + + let second = translator + .handle( + &envelope( + "s", + "ImportCheckpoint", + tpath, + json!({ "_bt_import_through_ms": 1_767_225_604_000_i64 }), + ), + &ctx, + ) + .unwrap(); + assert!(second + .iter() + .any(|op| matches!(op, SpanOp::Insert(row) if row.name == "turn: t2"))); +} + +#[test] +fn codex_stop_closes_turn_before_late_task_complete() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + let tpath = transcript.to_str().unwrap(); + for v in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", + "payload": { "type": "user_message", "message": "say done" } }), + ] { + append(&transcript, v); + } + + let reg = Registry::default_agents(); + let mut tr = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let mut ops = tr + .handle( + &envelope( + "s", + "Stop", + tpath, + json!({ "last_assistant_message": "done" }), + ), + &ctx, + ) + .unwrap(); + ops.extend(tr.flush(&ctx).unwrap()); + let rows = reduce(ops); + + let turn = find(&rows, SpanType::Task, "turn: t1"); + assert_eq!(turn.end_ms, Some(0), "Stop hook closes the active turn"); + assert_eq!(turn.output, Some(json!("done"))); +} + +// ---- compaction & subagent coverage -------------------------------------- + +fn append(path: &std::path::Path, v: Value) { + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .unwrap(); + writeln!(f, "{}", line(v)).unwrap(); +} + +fn configured_ctx(session_id: &str, additional_metadata: Value) -> SessionCtx { + SessionCtx { + session_id: session_id.into(), + config: Some(SessionConfig { + auth: BackendAuth { + token: "test-token".into(), + api_url: None, + app_url: None, + org_name: None, + org_id: None, + }, + project: Some("team-project".into()), + parent_span_id: None, + root_span_id: None, + flush_mode: FlushMode::FireAndForget, + additional_metadata: Some(additional_metadata), + }), + } +} + +#[test] +fn late_task_complete_is_correlated_by_turn_id() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + let path = transcript.to_str().unwrap(); + for record in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + ] { + append(&transcript, record); + } + + let reg = Registry::default_agents(); + let mut translator = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let mut ops = translator + .handle(&envelope("s", "SessionStart", path, json!({})), &ctx) + .unwrap(); + ops.extend( + translator + .handle( + &envelope( + "s", + "Stop", + path, + json!({ "turn_id": "t1", "last_assistant_message": "one" }), + ), + &ctx, + ) + .unwrap(), + ); + + // t2 begins before Codex appends t1's delayed task_complete. + append( + &transcript, + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t2" } }), + ); + append( + &transcript, + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "event_msg", + "payload": { "type": "task_complete", "turn_id": "t1", + "last_agent_message": "one" } }), + ); + ops.extend( + translator + .handle(&envelope("s", "UserPromptSubmit", path, json!({})), &ctx) + .unwrap(), + ); + + let rows = reduce(ops); + let t1 = find(&rows, SpanType::Task, "turn: t1"); + let t2 = find(&rows, SpanType::Task, "turn: t2"); + assert!(t1.end_ms.is_some()); + assert_eq!(t1.output, Some(json!("one"))); + assert_eq!(t2.end_ms, None, "late t1 completion must not close t2"); +} + +#[test] +fn root_preserves_config_input_and_git_metadata() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + let git = |args: &[&str]| { + let status = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(args) + .status() + .unwrap(); + assert!(status.success(), "git command failed: {args:?}"); + }; + git(&["init", "-b", "main"]); + git(&["config", "user.email", "test@example.com"]); + git(&["config", "user.name", "Test"]); + std::fs::write(repo.join("README.md"), "test").unwrap(); + git(&["add", "README.md"]); + git(&["commit", "-m", "initial"]); + git(&[ + "remote", + "add", + "origin", + "https://secret@example.com/acme/app.git", + ]); + let commit = std::process::Command::new("git") + .arg("-C") + .arg(&repo) + .args(["rev-parse", "HEAD"]) + .output() + .unwrap(); + let commit = String::from_utf8(commit.stdout).unwrap().trim().to_string(); + + let transcript = tmp.path().join("rollout.jsonl"); + for record in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "s", "cwd": repo, "cli_version": "1.2.3" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "turn_context", + "payload": { "model": "gpt-5.5" } }), + ] { + append(&transcript, record); + } + + let reg = Registry::default_agents(); + let mut translator = reg.create("codex", "s"); + let ctx = configured_ctx("s", json!({ "team": "platform", "model": "wrong" })); + let rows = reduce( + translator + .handle( + &envelope( + "s", + "SessionStart", + transcript.to_str().unwrap(), + json!({ "source": "resume", "permission_mode": "acceptEdits" }), + ), + &ctx, + ) + .unwrap(), + ); + let root = find(&rows, SpanType::Task, "codex: repo"); + let metadata = root.metadata.as_ref().unwrap(); + assert_eq!(metadata["team"], json!("platform")); + assert_eq!(metadata["model"], json!("gpt-5.5")); + assert_eq!(metadata["project"], json!("team-project")); + assert_eq!( + metadata["git_origin_url"], + json!("https://example.com/acme/app.git") + ); + assert_eq!(metadata["git_branch"], json!("main")); + assert_eq!(metadata["git_commit_sha"], json!(commit)); + assert_eq!(root.input.as_ref().unwrap()["model"], json!("gpt-5.5")); + assert_eq!(root.input.as_ref().unwrap()["source"], json!("resume")); + assert_eq!(root.input.as_ref().unwrap()["cwd"], json!(repo)); +} + +#[test] +fn tool_and_llm_payloads_preserve_original_contract() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + let path = transcript.to_str().unwrap(); + for record in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "turn_context", + "payload": { "model": "gpt-5.5" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "event_msg", + "payload": { "type": "user_message", "message": "$review inspect this" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "response_item", + "payload": { "type": "message", "role": "user", + "content": [{ "type": "input_text", "text": "$review inspect this" }] } }), + json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "response_item", + "payload": { "type": "function_call", "call_id": "c1", "name": "exec_command", + "arguments": "{\"cmd\":\"cat /tmp/review/SKILL.md\",\"sandbox_permissions\":\"require_escalated\",\"justification\":\"Need access\",\"prefix_rule\":[\"cat\"]}", + "metadata": { "turn_id": "t1" } } }), + json!({ "timestamp": "2026-01-01T00:00:06Z", "type": "event_msg", + "payload": { "type": "token_count", "info": { "last_token_usage": { + "input_tokens": 10, "output_tokens": 2, "cost": 0.25 + } } } }), + json!({ "timestamp": "2026-01-01T00:00:07Z", "type": "response_item", + "payload": { "type": "function_call_output", "call_id": "c1", + "output": { "status": "failed", "error": "boom" } } }), + json!({ "timestamp": "2026-01-01T00:00:08Z", "type": "response_item", + "payload": { "type": "message", "role": "assistant", + "content": [{ "type": "output_text", "text": "Recovered" }] } }), + json!({ "timestamp": "2026-01-01T00:00:09Z", "type": "event_msg", + "payload": { "type": "token_count", "info": { "last_token_usage": {} } } }), + json!({ "timestamp": "2026-01-01T00:00:10Z", "type": "event_msg", + "payload": { "type": "task_complete", "turn_id": "t1", + "last_agent_message": "done" } }), + ] { + append(&transcript, record); + } + + let reg = Registry::default_agents(); + let mut translator = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let rows = reduce( + translator + .handle(&envelope("s", "SessionStart", path, json!({})), &ctx) + .unwrap(), + ); + + let turn = find(&rows, SpanType::Task, "turn: t1"); + assert_eq!(turn.input, Some(json!("$review inspect this"))); + assert_eq!( + turn.metadata.as_ref().unwrap()["loaded_skill_names"], + json!(["review"]) + ); + + let tool = find(&rows, SpanType::Tool, "skill: review"); + assert!(tool.input.as_ref().unwrap().is_string()); + let metadata = tool.metadata.as_ref().unwrap(); + assert_eq!(metadata["tool_name"], json!("exec_command")); + assert_eq!(metadata["call_id"], json!("c1")); + assert_eq!(metadata["turn_id"], json!("t1")); + assert_eq!(metadata["tool_kind"], json!("skill")); + assert_eq!(metadata["skill_name"], json!("review")); + assert_eq!(metadata["skill_path"], json!("/tmp/review/SKILL.md")); + assert_eq!(metadata["skill_load_trigger"], json!("explicit")); + assert_eq!( + metadata["permission"]["sandbox_permissions"], + json!("require_escalated") + ); + assert_eq!( + metadata["permission"]["justification"], + json!("Need access") + ); + assert_eq!(metadata["permission"]["prefix_rule"], json!(["cat"])); + assert_eq!(metadata["tool_approval"], json!("approved")); + assert_eq!(tool.tags, Some(vec!["permission-request".into()])); + assert_eq!(tool.error.as_deref(), Some("boom")); + + let mut llms: Vec<&SpanRow> = rows + .values() + .filter(|row| row.span_type == SpanType::Llm) + .collect(); + llms.sort_by_key(|row| row.start_ms); + assert_eq!(llms.len(), 2); + assert_eq!( + llms[0].output.as_ref().unwrap()["tool_calls"][0]["function"]["arguments"], + json!("{\"cmd\":\"cat /tmp/review/SKILL.md\",\"sandbox_permissions\":\"require_escalated\",\"justification\":\"Need access\",\"prefix_rule\":[\"cat\"]}") + ); + assert_eq!(llms[0].metrics.as_ref().unwrap()["cost"], json!(0.25)); + assert_eq!( + llms[0].metrics.as_ref().unwrap()["estimated_cost"], + json!(0.25) + ); + let second_input = llms[1].input.as_ref().unwrap().as_array().unwrap(); + assert_eq!(second_input.last().unwrap()["role"], json!("tool")); + assert_eq!(second_input.last().unwrap()["tool_call_id"], json!("c1")); + assert_eq!( + llms[1].metadata.as_ref().unwrap()["usage_unavailable_reason"], + json!("codex_token_count_missing_usage") + ); +} + +#[test] +fn missing_tool_output_is_an_error() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + for record in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", + "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "event_msg", + "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "response_item", + "payload": { "type": "function_call", "call_id": "c1", "name": "shell", + "arguments": "{}", "metadata": { "turn_id": "t1" } } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "event_msg", + "payload": { "type": "task_complete", "turn_id": "t1", + "last_agent_message": "done" } }), + ] { + append(&transcript, record); + } + let reg = Registry::default_agents(); + let mut translator = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let rows = reduce( + translator + .handle( + &envelope("s", "SessionStart", transcript.to_str().unwrap(), json!({})), + &ctx, + ) + .unwrap(), + ); + let tool = find(&rows, SpanType::Tool, "shell"); + assert_eq!( + tool.error.as_deref(), + Some("Tool output missing before turn ended") + ); + assert_eq!( + tool.metadata.as_ref().unwrap()["tool_approval"], + json!("approved") + ); +} + +#[test] +fn codex_compaction_relabels_turn_and_adds_compaction_llm() { + let tmp = tempfile::tempdir().unwrap(); + let t = tmp.path().join("rollout.jsonl"); + let tpath = t.to_str().unwrap(); + for v in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "compacted", "payload": { + "window_id": "w1", + "replacement_history": [ + { "role": "user", "content": "kept" }, + { "type": "compaction", "encrypted_content": "opaque" } + ] + } }), + json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "event_msg", "payload": { "type": "token_count", "info": { "last_token_usage": { "input_tokens": 5000, "output_tokens": 50 } } } }), + ] { + append(&t, v); + } + + let reg = Registry::default_agents(); + let mut tr = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + + let mut ops = Vec::new(); + ops.extend( + tr.handle(&envelope("s", "SessionStart", tpath, json!({})), &ctx) + .unwrap(), + ); + // PostCompact closes the compaction turn and supplies the trigger. + ops.extend( + tr.handle( + &envelope( + "s", + "PostCompact", + tpath, + json!({ "turn_id": "t1", "trigger": "auto" }), + ), + &ctx, + ) + .unwrap(), + ); + let rows = reduce(ops); + + let compaction = find(&rows, SpanType::Task, "compaction"); + assert!(compaction + .tags + .as_ref() + .unwrap() + .contains(&"compaction".to_string())); + assert_eq!( + compaction.metadata.as_ref().unwrap()["compaction"]["trigger"], + json!("auto") + ); + assert!( + compaction.end_ms.is_some(), + "compaction turn closed by PostCompact" + ); + + // The synthetic compaction llm span carries before/after context + metrics. + let llm = find(&rows, SpanType::Llm, "gpt-5.5"); + assert_eq!(llm.parent_span_ids, vec![compaction.span_id.clone()]); + assert!(llm.output.as_ref().unwrap()["kept_messages"].is_array()); + assert_eq!( + llm.output.as_ref().unwrap()["summary"], + json!("[summary unavailable — encrypted by Codex]") + ); + assert_eq!( + llm.output.as_ref().unwrap()["kept_messages"] + .as_array() + .unwrap() + .len(), + 1, + "encrypted compaction entry should not be exposed as a kept message" + ); + assert_eq!( + llm.metrics.as_ref().unwrap()["prompt_tokens"], + json!(5000.0) + ); + assert!(llm.end_ms.is_some()); +} + +#[test] +fn codex_compaction_replaces_history_for_following_llms() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + for record in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "response_item", "payload": { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "discard me" }] } }), + json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "event_msg", "payload": { "type": "token_count", "info": { "last_token_usage": { "input_tokens": 10, "output_tokens": 2 } } } }), + json!({ "timestamp": "2026-01-01T00:00:06Z", "type": "compacted", "payload": { "replacement_history": [{ "role": "user", "content": "compacted context" }] } }), + json!({ "timestamp": "2026-01-01T00:00:07Z", "type": "event_msg", "payload": { "type": "token_count", "info": { "last_token_usage": { "input_tokens": 5, "output_tokens": 1 } } } }), + json!({ "timestamp": "2026-01-01T00:00:08Z", "type": "event_msg", "payload": { "type": "task_complete", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:09Z", "type": "event_msg", "payload": { "type": "task_started", "turn_id": "t2" } }), + json!({ "timestamp": "2026-01-01T00:00:10Z", "type": "response_item", "payload": { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "after" }] } }), + json!({ "timestamp": "2026-01-01T00:00:11Z", "type": "event_msg", "payload": { "type": "token_count", "info": { "last_token_usage": { "input_tokens": 6, "output_tokens": 1 } } } }), + ] { + append(&transcript, record); + } + + let registry = Registry::default_agents(); + let mut translator = registry.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + let rows = reduce( + translator + .handle( + &envelope("s", "SessionStart", transcript.to_str().unwrap(), json!({})), + &ctx, + ) + .unwrap(), + ); + let following = rows + .values() + .find(|row| { + row.span_type == SpanType::Llm + && row + .metadata + .as_ref() + .is_some_and(|metadata| metadata["turn_id"] == json!("t2")) + }) + .unwrap(); + let input = following.input.as_ref().unwrap().as_array().unwrap(); + assert_eq!( + input, + &[json!({ "role": "user", "content": "compacted context" })] + ); +} + +#[test] +fn codex_subagent_nests_under_spawning_turn() { + let tmp = tempfile::tempdir().unwrap(); + let main_t = tmp.path().join("main.jsonl"); + let sub_t = tmp.path().join("sub.jsonl"); + let main_p = main_t.to_str().unwrap(); + let sub_p = sub_t.to_str().unwrap(); + + // Main session opens a turn and runs a spawn_agent tool. + for v in [ + json!({ "timestamp": "2026-01-01T00:00:01Z", "type": "session_meta", "payload": { "id": "s", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:02Z", "type": "turn_context", "payload": { "model": "gpt-5.5" } }), + json!({ "timestamp": "2026-01-01T00:00:03Z", "type": "event_msg", "payload": { "type": "task_started", "turn_id": "t1" } }), + json!({ "timestamp": "2026-01-01T00:00:04Z", "type": "response_item", "payload": { "type": "function_call", "call_id": "c1", "name": "spawn_agent", "arguments": "{}" } }), + ] { + append(&main_t, v); + } + + let reg = Registry::default_agents(); + let mut tr = reg.create("codex", "s"); + let ctx = SessionCtx { + session_id: "s".into(), + config: None, + }; + + let mut ops = Vec::new(); + // The spawn_agent transcript record is first observed on this same + // PostToolUse. Catch-up must establish call -> turn before mapping agent_id. + ops.extend( + tr.handle( + &envelope("s", "PostToolUse", main_p, json!({ "tool_name": "spawn_agent", "tool_use_id": "c1", "tool_response": { "agent_id": "a1" } })), + &ctx, + ) + .unwrap(), + ); + // SubagentStart registers the subagent scope (its own transcript). + ops.extend( + tr.handle( + &envelope( + "s", + "SubagentStart", + main_p, + json!({ "agent_id": "a1", "transcript_path": sub_p, "agent_type": "reviewer" }), + ), + &ctx, + ) + .unwrap(), + ); + + // The subagent runs and writes its own transcript. + for v in [ + json!({ "timestamp": "2026-01-01T00:00:05Z", "type": "session_meta", "payload": { "id": "a1", "cwd": "/x/app" } }), + json!({ "timestamp": "2026-01-01T00:00:06Z", "type": "turn_context", "payload": { "model": "gpt-5.5-mini" } }), + json!({ "timestamp": "2026-01-01T00:00:07Z", "type": "event_msg", "payload": { "type": "task_started", "turn_id": "st1" } }), + json!({ "timestamp": "2026-01-01T00:00:08Z", "type": "response_item", "payload": { "type": "message", "role": "assistant", "content": [{ "type": "output_text", "text": "reviewed" }] } }), + json!({ "timestamp": "2026-01-01T00:00:09Z", "type": "event_msg", "payload": { "type": "token_count", "info": { "last_token_usage": { "input_tokens": 10, "output_tokens": 3 } } } }), + json!({ "timestamp": "2026-01-01T00:00:10Z", "type": "event_msg", "payload": { "type": "task_complete", "last_agent_message": "done" } }), + ] { + append(&sub_t, v); + } + + // A subagent-scoped event (carries agent_id + its transcript_path) drives + // the subagent catch-up, then SubagentStop closes it. + ops.extend( + tr.handle( + &envelope( + "s", + "PostToolUse", + main_p, + json!({ "agent_id": "a1", "transcript_path": sub_p }), + ), + &ctx, + ) + .unwrap(), + ); + ops.extend( + tr.handle( + &envelope( + "s", + "SubagentStop", + main_p, + json!({ "agent_id": "a1", "agent_transcript_path": sub_p }), + ), + &ctx, + ) + .unwrap(), + ); + + let rows = reduce(ops); + + let root = find(&rows, SpanType::Task, "codex: app"); + let main_turn = find(&rows, SpanType::Task, "turn: t1"); + let subagent = find(&rows, SpanType::Task, "subagent: a1"); + let sub_turn = find(&rows, SpanType::Task, "turn: st1"); + + // Whole thing is one trace under the main root. + for r in [main_turn, subagent, sub_turn] { + assert_eq!( + r.root_span_id, root.span_id, + "span {:?} not in main trace", + r.name + ); + } + // subagent root is a sibling of the spawn_agent tool, under the spawning turn. + assert_eq!(subagent.parent_span_ids, vec![main_turn.span_id.clone()]); + assert_eq!( + subagent.metadata.as_ref().unwrap()["agent_type"], + json!("reviewer") + ); + assert!( + subagent.end_ms.is_some(), + "subagent root closed by SubagentStop" + ); + // subagent turn hangs under the subagent root. + assert_eq!(sub_turn.parent_span_ids, vec![subagent.span_id.clone()]); + assert_eq!(sub_turn.output, Some(json!("done"))); + // subagent's llm is its own model, under its turn. + let sub_llm = find(&rows, SpanType::Llm, "gpt-5.5-mini"); + assert_eq!(sub_llm.parent_span_ids, vec![sub_turn.span_id.clone()]); +} diff --git a/bt-daemon/tests/inference_mocks.rs b/bt-daemon/tests/inference_mocks.rs new file mode 100644 index 0000000..03f3990 --- /dev/null +++ b/bt-daemon/tests/inference_mocks.rs @@ -0,0 +1,101 @@ +mod support; + +use axum::http::StatusCode; +use serde_json::json; +use support::inference::{AnthropicMock, AnthropicTurn, MockReply, OpenAiMock, OpenAiTurn}; +use support::server::TestServer; + +#[tokio::test] +async fn openai_mock_streams_text_and_captures_requests() { + let mock = OpenAiMock::new(|context, request| { + assert_eq!(context.request_index, 0); + assert_eq!(request.model(), Some("mock-model")); + MockReply::response(OpenAiTurn::text("deterministic")) + }); + let server = TestServer::start(mock.router()).await; + + let response = reqwest::Client::new() + .post(format!("{}/v1/responses", server.uri())) + .json(&json!({"model":"mock-model","input":[],"stream":true})) + .send() + .await + .unwrap(); + let body = response.text().await.unwrap(); + + assert!(body.contains("response.output_item.done")); + assert!(body.contains("deterministic")); + assert_eq!(mock.requests().len(), 1); +} + +#[tokio::test] +async fn openai_mock_injects_retryable_and_malformed_responses() { + let mock = OpenAiMock::new(|context, _request| match context.request_index { + 0 => MockReply::http_error( + StatusCode::TOO_MANY_REQUESTS, + json!({"error":{"type":"rate_limit_error","message":"deterministic limit"}}), + ), + _ => MockReply::raw_sse("event: response.output_item.done\ndata: not-json\n\n"), + }); + let server = TestServer::start(mock.router()).await; + let client = reqwest::Client::new(); + + let limited = client + .post(format!("{}/v1/responses", server.uri())) + .json(&json!({"model":"mock-model","input":[],"stream":true})) + .send() + .await + .unwrap(); + assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS); + + let malformed = client + .post(format!("{}/v1/responses", server.uri())) + .json(&json!({"model":"mock-model","input":[],"stream":true})) + .send() + .await + .unwrap(); + assert!(malformed.text().await.unwrap().contains("not-json")); +} + +#[tokio::test] +async fn anthropic_mock_supports_tool_use_and_http_errors() { + let mock = AnthropicMock::new(|context, request| match context.request_index { + 0 => { + assert!(request.contains_text("run a command")); + MockReply::response(AnthropicTurn::tool_use( + "toolu_mock", + "Bash", + json!({"command":"printf hello"}), + )) + } + _ => MockReply::http_error( + StatusCode::TOO_MANY_REQUESTS, + json!({ + "type":"error", + "error":{"type":"rate_limit_error","message":"deterministic limit"} + }), + ), + }); + let server = TestServer::start(mock.router()).await; + + let client = reqwest::Client::new(); + let first = client + .post(format!("{}/v1/messages", server.uri())) + .json(&json!({ + "model":"mock-model", + "messages":[{"role":"user","content":"run a command"}], + "stream":true + })) + .send() + .await + .unwrap(); + assert!(first.text().await.unwrap().contains("toolu_mock")); + + let second = client + .post(format!("{}/v1/messages", server.uri())) + .json(&json!({"model":"mock-model","messages":[],"stream":true})) + .send() + .await + .unwrap(); + assert_eq!(second.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(mock.requests().len(), 2); +} diff --git a/bt-daemon/tests/ingest_mock.rs b/bt-daemon/tests/ingest_mock.rs new file mode 100644 index 0000000..9d9430b --- /dev/null +++ b/bt-daemon/tests/ingest_mock.rs @@ -0,0 +1,37 @@ +mod support; + +use serde_json::json; +use support::ingest::{IngestMock, IngestScenario}; +use support::server::TestServer; + +#[tokio::test] +async fn ingest_router_captures_rows_and_matches_ordered_shapes() { + let ingest = IngestMock::new(); + let server = TestServer::start(ingest.router()).await; + + let response = reqwest::Client::new() + .post(format!("{}/logs3", server.uri())) + .json(&json!({ + "rows": [ + {"span_attributes":{"type":"task"},"metadata":{"source":"codex"}}, + {"span_attributes":{"type":"llm"}}, + {"span_attributes":{"type":"tool"},"output":"deterministic"} + ] + })) + .send() + .await + .unwrap(); + assert!(response.status().is_success()); + + let scenario = IngestScenario::new() + .expect("root task", |row| row["span_attributes"]["type"] == "task") + .expect("tool result", |row| { + row["span_attributes"]["type"] == "tool" && row["output"] == "deterministic" + }); + assert_eq!(ingest.evaluate(&scenario).unwrap().len(), 3); + + let reversed = IngestScenario::new() + .expect("tool first", |row| row["span_attributes"]["type"] == "tool") + .expect("task later", |row| row["span_attributes"]["type"] == "task"); + assert!(ingest.evaluate(&reversed).is_err()); +} diff --git a/bt-daemon/tests/pipeline.rs b/bt-daemon/tests/pipeline.rs new file mode 100644 index 0000000..84177aa --- /dev/null +++ b/bt-daemon/tests/pipeline.rs @@ -0,0 +1,449 @@ +//! Phase 1 end-to-end: hook client → UDS → dispatch → journal → translate → +//! debug sink. Runs the daemon in-process on a temp socket (no process +//! spawning, so it's deterministic). + +use bt_daemon::wire::{BackendAuth, Envelope, FlushMode, SessionConfig}; +use bt_daemon::{ + debug_serve_options, flush_session, forward_envelope, run_serve, run_status, shutdown_daemon, + HostInfo, ServeArgs, StatusArgs, +}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +fn dummy_host() -> HostInfo { + // The daemon is started in-process, so the client never spawns; serve_argv + // is unused but must be non-empty. + HostInfo { + serve_argv: vec![OsString::from("unused")], + version: "test".into(), + } +} + +fn config_with_secret() -> SessionConfig { + SessionConfig { + auth: BackendAuth { + token: "sk-TOP-SECRET-abc123".into(), + api_url: Some("https://api.braintrust.dev".into()), + app_url: None, + org_name: Some("acme".into()), + org_id: None, + }, + project: Some("codex".into()), + parent_span_id: None, + root_span_id: None, + flush_mode: FlushMode::FireAndForget, + additional_metadata: None, + } +} + +fn envelope(session_id: &str, event: &str, ts_ms: i64) -> Envelope { + Envelope { + source: "debug".into(), + source_version: Some("0.0.0".into()), + session_id: session_id.into(), + event: event.into(), + ts_ms, + payload: serde_json::json!({ "session_id": session_id, "hook_event_name": event, "n": ts_ms }), + config: Some(config_with_secret()), + } +} + +fn test_endpoint(tmp: &Path) -> PathBuf { + #[cfg(unix)] + { + tmp.join("d.sock") + } + #[cfg(windows)] + { + let _ = tmp; + PathBuf::from(format!(r"\\.\pipe\bt-daemon-test-{}", uuid::Uuid::new_v4())) + } +} + +async fn wait_for(endpoint: &Path) { + for _ in 0..200 { + if let Ok(Some(_)) = run_status(StatusArgs { + socket: Some(endpoint.to_path_buf()), + session_id: None, + }) + .await + { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("daemon never answered at: {}", endpoint.display()); +} + +async fn wait_until_gone(endpoint: &Path) { + for _ in 0..1000 { + match run_status(StatusArgs { + socket: Some(endpoint.to_path_buf()), + session_id: None, + }) + .await + { + Ok(Some(_)) => {} + // A Windows named pipe can still accept a client while the + // daemon is unwinding, then close before answering initialize. + // Either that or an unavailable endpoint means it is no longer + // serving status requests. + Ok(None) | Err(_) => return, + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("daemon still answered at: {}", endpoint.display()); +} + +/// Start an in-process daemon on a fresh temp socket/data dir. Returns +/// (data_dir, socket, serve task handle, tempdir guard). +async fn start_daemon() -> ( + PathBuf, + PathBuf, + tokio::task::JoinHandle<()>, + tempfile::TempDir, +) { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().join("data"); + let socket = test_endpoint(tmp.path()); + std::fs::create_dir_all(&data_dir).unwrap(); + + let args = ServeArgs { + socket: Some(socket.clone()), + data_dir: Some(data_dir.clone()), + idle_timeout_secs: 0, // disable the watchdog for the test + }; + let opts = debug_serve_options("test", &data_dir); + let handle = tokio::spawn(async move { + let _ = run_serve(args, opts).await; + }); + wait_for(&socket).await; + (data_dir, socket, handle, tmp) +} + +async fn start_daemon_at(data_dir: PathBuf, socket: PathBuf) -> tokio::task::JoinHandle<()> { + let args = ServeArgs { + socket: Some(socket.clone()), + data_dir: Some(data_dir.clone()), + idle_timeout_secs: 0, + }; + let opts = debug_serve_options("test", &data_dir); + let handle = tokio::spawn(async move { + let _ = run_serve(args, opts).await; + }); + wait_for(&socket).await; + handle +} + +async fn shutdown(socket: &Path) { + shutdown_daemon(socket).await.unwrap(); +} + +#[tokio::test] +async fn events_are_ordered_journaled_and_emitted() { + let (data_dir, socket, handle, _tmp) = start_daemon().await; + let host = dummy_host(); + let session = "sess-1"; + + for (i, event) in ["SessionStart", "PostToolUse", "Stop"].iter().enumerate() { + let env = envelope(session, event, 1000 + i as i64); + forward_envelope(&env, &socket, &host, false).await.unwrap(); + } + + let flushed = flush_session(session, &socket, 5000).await.unwrap(); + assert!(flushed.flushed, "flush did not complete: {flushed:?}"); + assert_eq!(flushed.pending, 0); + + // Journal: three events, in order, token redacted. + let journal = data_dir.join("journal").join("sess-1.ndjson"); + let jtext = std::fs::read_to_string(&journal).unwrap(); + let jlines: Vec<&str> = jtext.lines().filter(|l| !l.trim().is_empty()).collect(); + assert_eq!( + jlines.len(), + 3, + "expected 3 journal lines, got {}", + jlines.len() + ); + assert!( + !jtext.contains("sk-TOP-SECRET-abc123"), + "token leaked into journal!" + ); + assert!( + jtext.contains("token_sha256_prefix"), + "journal missing auth fingerprint" + ); + + let events: Vec = jlines + .iter() + .map(|l| { + serde_json::from_str::(l).unwrap()["event"] + .as_str() + .unwrap() + .to_string() + }) + .collect(); + assert_eq!(events, vec!["SessionStart", "PostToolUse", "Stop"]); + + // Spans: debug translator emits a root once + one span per event = 4. + let spans = data_dir.join("spans").join("sess-1.ndjson"); + let stext = std::fs::read_to_string(&spans).unwrap(); + let slines: Vec<&str> = stext.lines().filter(|l| !l.trim().is_empty()).collect(); + assert_eq!( + slines.len(), + 4, + "expected 4 span rows, got {}: {stext}", + slines.len() + ); + // The span rows carry the raw payload but never the auth token. + assert!( + !stext.contains("sk-TOP-SECRET-abc123"), + "token leaked into spans!" + ); + + handle.abort(); +} + +#[tokio::test] +async fn distinct_sessions_are_isolated() { + let (data_dir, socket, handle, _tmp) = start_daemon().await; + let host = dummy_host(); + + forward_envelope(&envelope("a", "SessionStart", 1), &socket, &host, false) + .await + .unwrap(); + forward_envelope(&envelope("b", "SessionStart", 1), &socket, &host, false) + .await + .unwrap(); + forward_envelope(&envelope("a", "Stop", 2), &socket, &host, false) + .await + .unwrap(); + + flush_session("a", &socket, 5000).await.unwrap(); + flush_session("b", &socket, 5000).await.unwrap(); + + let a = std::fs::read_to_string(data_dir.join("journal").join("a.ndjson")).unwrap(); + let b = std::fs::read_to_string(data_dir.join("journal").join("b.ndjson")).unwrap(); + assert_eq!(a.lines().filter(|l| !l.trim().is_empty()).count(), 2); + assert_eq!(b.lines().filter(|l| !l.trim().is_empty()).count(), 1); + + handle.abort(); +} + +#[tokio::test] +async fn status_reports_sessions_and_filters_by_session_id() { + let (_data_dir, socket, handle, _tmp) = start_daemon().await; + let host = dummy_host(); + forward_envelope( + &envelope("visible", "SessionStart", 1), + &socket, + &host, + false, + ) + .await + .unwrap(); + forward_envelope(&envelope("other", "SessionStart", 1), &socket, &host, false) + .await + .unwrap(); + + let all = run_status(StatusArgs { + socket: Some(socket.clone()), + session_id: None, + }) + .await + .unwrap() + .unwrap(); + assert_eq!(all.daemon_version, "test"); + assert_eq!(all.sessions.len(), 2); + + let filtered = run_status(StatusArgs { + socket: Some(socket.clone()), + session_id: Some("visible".into()), + }) + .await + .unwrap() + .unwrap(); + assert_eq!(filtered.sessions.len(), 1); + assert_eq!(filtered.sessions[0].session_id, "visible"); + + shutdown(&socket).await; + handle.await.unwrap(); + wait_until_gone(&socket).await; +} + +#[tokio::test] +async fn a_second_server_detects_the_existing_daemon() { + let (data_dir, socket, first, _tmp) = start_daemon().await; + let args = ServeArgs { + socket: Some(socket.clone()), + data_dir: Some(data_dir.clone()), + idle_timeout_secs: 0, + }; + let result = tokio::time::timeout( + Duration::from_secs(2), + run_serve(args, debug_serve_options("rival", &data_dir)), + ) + .await + .expect("rival server should resolve ownership promptly"); + result.unwrap(); + + let status = run_status(StatusArgs { + socket: Some(socket.clone()), + session_id: None, + }) + .await + .unwrap() + .unwrap(); + assert_eq!(status.daemon_version, "test"); + + shutdown(&socket).await; + first.await.unwrap(); +} + +#[tokio::test] +async fn no_spawn_errors_when_daemon_absent() { + let tmp = tempfile::tempdir().unwrap(); + let socket = test_endpoint(tmp.path()); + let host = dummy_host(); + let err = forward_envelope(&envelope("x", "y", 1), &socket, &host, true).await; + assert!(err.is_err(), "expected error with --no-spawn and no daemon"); +} + +#[tokio::test] +async fn no_spawn_rejects_a_mismatched_daemon_version() { + let (_data_dir, socket, handle, _tmp) = start_daemon().await; + let host = HostInfo { + serve_argv: vec![OsString::from("unused")], + version: "newer-client".into(), + }; + let err = forward_envelope(&envelope("x", "y", 1), &socket, &host, true) + .await + .unwrap_err(); + assert!(err.to_string().contains("does not match client")); + shutdown(&socket).await; + handle.await.unwrap(); +} + +#[cfg(feature = "cli")] +#[tokio::test] +async fn spawn_on_demand_runs_the_real_standalone_daemon() { + let tmp = tempfile::tempdir().unwrap(); + let data_dir = tmp.path().join("spawned-data"); + let socket = test_endpoint(tmp.path()); + let host = HostInfo { + serve_argv: vec![ + OsString::from(env!("CARGO_BIN_EXE_bt-daemon")), + OsString::from("serve"), + OsString::from("--debug-sink"), + OsString::from("--data-dir"), + data_dir.as_os_str().to_owned(), + OsString::from("--idle-timeout-secs"), + OsString::from("5"), + ], + version: env!("CARGO_PKG_VERSION").into(), + }; + + forward_envelope( + &envelope("spawned", "SessionStart", 1), + &socket, + &host, + false, + ) + .await + .unwrap(); + flush_session("spawned", &socket, 5000).await.unwrap(); + let spans = std::fs::read_to_string(data_dir.join("spans/spawned.ndjson")).unwrap(); + assert_eq!(spans.lines().count(), 2); + + shutdown(&socket).await; + wait_until_gone(&socket).await; +} + +#[tokio::test] +async fn restart_replays_journal_with_stable_span_ids_before_new_events() { + let (data_dir, socket, first, _tmp) = start_daemon().await; + let host = dummy_host(); + forward_envelope( + &envelope("resume", "SessionStart", 1), + &socket, + &host, + false, + ) + .await + .unwrap(); + flush_session("resume", &socket, 5000).await.unwrap(); + shutdown(&socket).await; + first.await.unwrap(); + + let second = start_daemon_at(data_dir.clone(), socket.clone()).await; + forward_envelope(&envelope("resume", "Stop", 2), &socket, &host, false) + .await + .unwrap(); + flush_session("resume", &socket, 5000).await.unwrap(); + + let journal = std::fs::read_to_string(data_dir.join("journal/resume.ndjson")).unwrap(); + assert_eq!(journal.lines().count(), 2); + let spans = std::fs::read_to_string(data_dir.join("spans/resume.ndjson")).unwrap(); + let rows: Vec = spans + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + rows.len(), + 5, + "first delivery (2) + recovery replay (2) + resumed event (1)" + ); + let span_id = |row: &serde_json::Value| { + row.get("Insert") + .or_else(|| row.get("Merge")) + .and_then(|body| body.get("span_id")) + .and_then(serde_json::Value::as_str) + .unwrap() + .to_owned() + }; + assert_eq!(span_id(&rows[2]), span_id(&rows[0])); + assert_eq!(span_id(&rows[3]), span_id(&rows[1])); + assert_ne!(span_id(&rows[4]), span_id(&rows[1])); + let unique_ids = rows + .iter() + .map(span_id) + .collect::>(); + assert_eq!( + unique_ids.len(), + 3, + "recovery must reuse both historical ids; only the new event gets a new id" + ); + + shutdown(&socket).await; + second.await.unwrap(); +} + +#[tokio::test] +async fn claude_boundary_journal_contains_a_self_contained_transcript_snapshot() { + let (data_dir, socket, handle, tmp) = start_daemon().await; + let transcript = tmp.path().join("claude.jsonl"); + std::fs::write( + &transcript, + r#"{"type":"assistant","timestamp":"2026-07-29T00:00:00Z","message":{"id":"m1","model":"claude","content":[{"type":"text","text":"durable"}]}}"#, + ) + .unwrap(); + let mut env = envelope("claude-journal", "Stop", 1_775_000_000_000); + env.source = "claude-code".into(); + env.payload = serde_json::json!({ + "session_id":"claude-journal", + "hook_event_name":"Stop", + "transcript_path":transcript + }); + forward_envelope(&env, &socket, &dummy_host(), false) + .await + .unwrap(); + flush_session("claude-journal", &socket, 5000) + .await + .unwrap(); + + let journal = std::fs::read_to_string(data_dir.join("journal/claude-journal.ndjson")).unwrap(); + assert!(journal.contains("_bt_transcript_snapshot")); + assert!(journal.contains("durable")); + assert!(!journal.contains("sk-TOP-SECRET-abc123")); + handle.abort(); +} diff --git a/bt-daemon/tests/replay.rs b/bt-daemon/tests/replay.rs new file mode 100644 index 0000000..26e7972 --- /dev/null +++ b/bt-daemon/tests/replay.rs @@ -0,0 +1,203 @@ +use bt_daemon::{import_transcript, DebugSinkFactory, ImportSource, Registry, ServeOptions}; +use serde_json::{json, Value}; +use std::io::Write; +use std::sync::Arc; + +fn write_jsonl(path: &std::path::Path, records: &[Value]) { + let mut file = std::fs::File::create(path).unwrap(); + for record in records { + writeln!(file, "{}", serde_json::to_string(record).unwrap()).unwrap(); + } +} + +fn options(output: &std::path::Path) -> ServeOptions { + ServeOptions { + version: "test".into(), + translators: Arc::new(Registry::default_agents()), + sink_factory: Arc::new(DebugSinkFactory { dir: output.into() }), + } +} + +fn rows(path: &std::path::Path) -> Vec { + std::fs::read_to_string(path) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect() +} + +fn inserted(rows: &[Value], span_type: &str) -> usize { + rows.iter() + .filter(|row| row.pointer("/Insert/span_type").and_then(Value::as_str) == Some(span_type)) + .count() +} + +#[tokio::test] +async fn imports_native_codex_rollout_through_codex_translator() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("rollout.jsonl"); + write_jsonl( + &transcript, + &[ + json!({"timestamp":"2026-01-01T00:00:01Z","type":"session_meta","payload":{"id":"codex-past","cwd":"/tmp/demo","cli_version":"1.2.3"}}), + json!({"timestamp":"2026-01-01T00:00:02Z","type":"turn_context","payload":{"model":"gpt-test"}}), + json!({"timestamp":"2026-01-01T00:00:03Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}), + json!({"timestamp":"2026-01-01T00:00:04Z","type":"event_msg","payload":{"type":"user_message","message":"list files"}}), + json!({"timestamp":"2026-01-01T00:00:05Z","type":"response_item","payload":{"type":"function_call","call_id":"call-1","name":"shell","arguments":"{\"command\":\"ls\"}"}}), + json!({"timestamp":"2026-01-01T00:00:06Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call-1","output":"README.md"}}), + json!({"timestamp":"2026-01-01T00:00:07Z","type":"event_msg","payload":{"type":"task_complete","last_agent_message":"Done"}}), + json!({"timestamp":"2026-01-01T00:00:08Z","type":"turn_context","payload":{"model":"gpt-test"}}), + json!({"timestamp":"2026-01-01T00:00:09Z","type":"event_msg","payload":{"type":"task_started","turn_id":"turn-2"}}), + json!({"timestamp":"2026-01-01T00:00:10Z","type":"event_msg","payload":{"type":"user_message","message":"show status"}}), + json!({"timestamp":"2026-01-01T00:00:11Z","type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-2","last_agent_message":"Clean"}}), + ], + ); + + let output = tmp.path().join("spans"); + import_transcript(&transcript, ImportSource::Codex, options(&output), None) + .await + .unwrap(); + + let rows = rows(&output.join("codex-past.ndjson")); + assert_eq!(inserted(&rows, "task"), 3, "session and two turns"); + assert_eq!(inserted(&rows, "tool"), 1); + assert!(rows.iter().any(|row| row + .pointer("/Insert/metadata/source") + .and_then(Value::as_str) + == Some("import"))); + let turn_ids = rows + .iter() + .filter_map(|row| { + row.pointer("/Insert/metadata/turn_id") + .and_then(Value::as_str) + }) + .collect::>(); + assert!(turn_ids.contains(&"turn-1")); + assert!(turn_ids.contains(&"turn-2")); +} + +#[tokio::test] +async fn imports_native_claude_transcript_with_multiple_turns_and_tools() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("claude.jsonl"); + write_jsonl( + &transcript, + &[ + json!({"type":"user","timestamp":"2026-01-01T00:00:01Z","sessionId":"claude-past","cwd":"/tmp/demo","version":"2.0.0","message":{"role":"user","content":"run it"}}), + json!({"type":"assistant","timestamp":"2026-01-01T00:00:02Z","sessionId":"claude-past","requestId":"req-1","message":{"id":"msg-1","model":"claude-test","role":"assistant","content":[{"type":"tool_use","id":"tool-1","name":"Bash","input":{"command":"true"}}],"usage":{"input_tokens":10,"output_tokens":3}}}), + json!({"type":"user","timestamp":"2026-01-01T00:00:03Z","sessionId":"claude-past","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tool-1","content":"ok","is_error":false}]}}), + json!({"type":"assistant","timestamp":"2026-01-01T00:00:04Z","sessionId":"claude-past","requestId":"req-2","message":{"id":"msg-2","model":"claude-test","role":"assistant","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":12,"output_tokens":2}}}), + json!({"type":"user","timestamp":"2026-01-01T00:00:05Z","sessionId":"claude-past","message":{"role":"user","content":"again"}}), + json!({"type":"assistant","timestamp":"2026-01-01T00:00:06Z","sessionId":"claude-past","requestId":"req-3","message":{"id":"msg-3","model":"claude-test","role":"assistant","content":[{"type":"text","text":"again done"}],"usage":{"input_tokens":14,"output_tokens":2}}}), + ], + ); + + let output = tmp.path().join("spans"); + import_transcript(&transcript, ImportSource::Claude, options(&output), None) + .await + .unwrap(); + + let rows = rows(&output.join("claude-past.ndjson")); + assert_eq!(inserted(&rows, "task"), 3, "session and two turns"); + assert_eq!(inserted(&rows, "llm"), 3); + assert_eq!(inserted(&rows, "tool"), 1); + assert!(rows.iter().any(|row| { + row.pointer("/Insert/metadata/recovered_from_transcript") + .and_then(Value::as_bool) + == Some(true) + })); +} + +#[tokio::test] +async fn imports_non_monotonic_claude_records_into_their_native_turns() { + let tmp = tempfile::tempdir().unwrap(); + let transcript = tmp.path().join("claude.jsonl"); + write_jsonl( + &transcript, + &[ + json!({"type":"user","uuid":"user-1","timestamp":"2026-01-01T00:00:01Z","sessionId":"claude-non-monotonic","cwd":"/tmp/demo","version":"2.0.0","message":{"role":"user","content":"first"}}), + json!({"type":"assistant","uuid":"assistant-1","parentUuid":"user-1","timestamp":"2026-01-01T00:00:02Z","sessionId":"claude-non-monotonic","isApiErrorMessage":true,"apiErrorStatus":404,"error":"model_not_found","message":{"id":"msg-1","model":"","role":"assistant","content":[{"type":"text","text":"model unavailable"}],"usage":{"input_tokens":0,"output_tokens":0}}}), + // Claude may append queue bookkeeping before conversation records + // whose native timestamps are earlier. + json!({"type":"queue-operation","operation":"enqueue","timestamp":"2026-01-01T00:00:10Z","sessionId":"claude-non-monotonic","content":"third"}), + json!({"type":"queue-operation","operation":"dequeue","timestamp":"2026-01-01T00:00:10Z","sessionId":"claude-non-monotonic"}), + json!({"type":"user","uuid":"user-2","parentUuid":"assistant-1","timestamp":"2026-01-01T00:00:05Z","sessionId":"claude-non-monotonic","message":{"role":"user","content":[{"type":"text","text":"second"}]}}), + json!({"type":"assistant","uuid":"assistant-2","parentUuid":"user-2","timestamp":"2026-01-01T00:00:06Z","sessionId":"claude-non-monotonic","isApiErrorMessage":false,"message":{"id":"msg-2","model":"claude-test","role":"assistant","content":[{"type":"text","text":"second response"}],"usage":{"input_tokens":4,"output_tokens":2}}}), + json!({"type":"user","uuid":"user-3","parentUuid":"assistant-2","timestamp":"2026-01-01T00:00:11Z","sessionId":"claude-non-monotonic","message":{"role":"user","content":"third"}}), + json!({"type":"assistant","uuid":"assistant-3","parentUuid":"user-3","timestamp":"2026-01-01T00:00:12Z","sessionId":"claude-non-monotonic","isApiErrorMessage":true,"apiErrorStatus":429,"error":"rate_limit_error","message":{"id":"msg-3","model":"","role":"assistant","content":[{"type":"text","text":"rate limited"}],"usage":{"input_tokens":0,"output_tokens":0}}}), + ], + ); + + let output = tmp.path().join("spans"); + import_transcript(&transcript, ImportSource::Claude, options(&output), None) + .await + .unwrap(); + + let rows = rows(&output.join("claude-non-monotonic.ndjson")); + assert_eq!(inserted(&rows, "task"), 4, "session and three turns"); + assert_eq!(inserted(&rows, "llm"), 3); + + let insert_named = |name: &str| { + rows.iter() + .filter_map(|op| op.get("Insert")) + .find(|row| row.get("name").and_then(Value::as_str) == Some(name)) + .unwrap() + }; + let turn_1 = insert_named("Turn 1"); + let turn_2 = insert_named("Turn 2"); + let turn_3 = insert_named("Turn 3"); + let merged_turn = |turn: &Value| { + let span_id = turn.get("span_id").and_then(Value::as_str).unwrap(); + rows.iter() + .filter_map(|op| op.get("Merge")) + .find(|row| row.get("span_id").and_then(Value::as_str) == Some(span_id)) + .unwrap() + }; + assert_eq!( + merged_turn(turn_1).get("end_ms").and_then(Value::as_i64), + Some(1_767_225_602_000) + ); + assert_eq!( + merged_turn(turn_1).get("error").and_then(Value::as_str), + Some("model_not_found") + ); + assert_eq!( + merged_turn(turn_2).get("end_ms").and_then(Value::as_i64), + Some(1_767_225_606_000) + ); + assert!(merged_turn(turn_2).get("error").is_none()); + assert_eq!( + merged_turn(turn_3).get("error").and_then(Value::as_str), + Some("rate_limit_error") + ); + let llm = |request_id: &str| { + rows.iter() + .filter_map(|op| op.get("Insert")) + .find(|row| { + row.pointer("/metadata/request_id").and_then(Value::as_str) == Some(request_id) + }) + .unwrap() + }; + for (request_id, turn) in [("msg-1", turn_1), ("msg-2", turn_2), ("msg-3", turn_3)] { + assert_eq!( + llm(request_id) + .pointer("/parent_span_ids/0") + .and_then(Value::as_str), + turn.get("span_id").and_then(Value::as_str) + ); + } + assert_eq!( + llm("msg-1").get("error").and_then(Value::as_str), + Some("model_not_found") + ); + assert_eq!( + llm("msg-1") + .pointer("/metadata/api_error_status") + .and_then(Value::as_u64), + Some(404) + ); + assert_eq!( + llm("msg-3").get("error").and_then(Value::as_str), + Some("rate_limit_error") + ); +} diff --git a/bt-daemon/tests/support/README.md b/bt-daemon/tests/support/README.md new file mode 100644 index 0000000..744c1ff --- /dev/null +++ b/bt-daemon/tests/support/README.md @@ -0,0 +1,50 @@ +# Agent integration test architecture + +The test infrastructure has three independent layers: + +- `server` is a generic container that binds any Axum `Router` to an + ephemeral address and owns its lifecycle. +- `inference` contains OpenAI Responses and Anthropic Messages protocol logic, + programmable scenarios, and captured inference requests. Each mock exports + an Axum router and can be hosted or embedded by any caller. +- `ingest` contains the mock Braintrust API and captured trace rows. It also + exports an Axum router and has no dependency on the server container. Its + scenario builder matches named row shapes as an ordered subsequence, + independent of HTTP batching and unrelated SDK update rows. + +`agent_process` is the Braintrust-specific orchestration layer. It hosts the +ingest router, starts the daemon, and provides the environment shared by agent +processes. + +`agents` contains reusable adapters for real coding-agent CLIs. Each adapter +owns only agent installation and isolated configuration state. The daemon +world is passed to each run as its execution context, avoiding any lifetime or +ownership coupling between the two layers. Adapters provide standard +invocation flags, mock-inference routing, and process output. Runs remain +configurable with additional arguments and environment variables so scenarios +can add inputs such as attachment paths without duplicating CLI setup. + +The integration test composes those pieces: it hosts an inference router, +starts the daemon world, runs an agent, and evaluates the ingest scenario. This +keeps both protocol mocks usable without coding agents, keeps the generic +server unaware of either protocol, and lets new end-to-end scenarios focus on +model behavior and expected trace shapes. + +The world controls inference and ingest independently: + +- `BT_AGENT_INFERENCE_MODE=mock|live` selects deterministic mock inference or + the agent's normal provider. +- `BT_AGENT_INGEST_MODE=mock|live` selects captured local ingest or the normal + Braintrust backend. + +This allows deterministic inference to drive real Braintrust ingest without +paying for model inference. Every test uses ordinary assertions for stable +process behavior and trace delivery regardless of mode. When ingest is mocked, +the captured rows are also available for ordinary assertions over stable +metadata. With live ingest, the daemon must report emitted spans and no sink +errors. + +`IngestScenario` is exclusively for the additional deterministic expectations +when both inference and ingest are mocked. Provider request sequences, exact +model output, injected provider failures, and ordered trace shapes are layered +on top of the always-run assertions. diff --git a/bt-daemon/tests/support/agent_process.rs b/bt-daemon/tests/support/agent_process.rs new file mode 100644 index 0000000..820c25d --- /dev/null +++ b/bt-daemon/tests/support/agent_process.rs @@ -0,0 +1,357 @@ +use crate::support::ingest::{IngestMock, IngestScenario}; +use crate::support::server::TestServer; +use bt_daemon::{run_status, StatusArgs}; +use serde_json::{json, Value}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::Duration; +use tempfile::TempDir; +use tokio::process::{Child, Command}; +#[cfg(windows)] +use uuid::Uuid; + +const INFERENCE_MODE_ENV: &str = "BT_AGENT_INFERENCE_MODE"; +const INGEST_MODE_ENV: &str = "BT_AGENT_INGEST_MODE"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TestBackendMode { + Mock, + Live, +} + +impl TestBackendMode { + fn from_env(name: &str) -> Self { + match std::env::var(name).as_deref() { + Ok("live") => Self::Live, + Ok("mock" | "deterministic") | Err(std::env::VarError::NotPresent) => Self::Mock, + Ok(value) => panic!("{name} must be `mock` or `live`, got {value:?}"), + Err(error) => panic!("could not read {name}: {error}"), + } + } +} + +pub struct AgentTestWorld { + inference_mode: TestBackendMode, + ingest_mode: TestBackendMode, + root: TempDir, + collector: IngestMock, + collector_server: TestServer, + daemon: Child, + wrapper_dir: PathBuf, + socket: PathBuf, + data_dir: PathBuf, + config_path: PathBuf, +} + +impl AgentTestWorld { + pub async fn start() -> Self { + let inference_mode = TestBackendMode::from_env(INFERENCE_MODE_ENV); + let ingest_mode = TestBackendMode::from_env(INGEST_MODE_ENV); + let root = tempfile::tempdir().expect("create agent test root"); + let collector = IngestMock::new(); + let collector_server = TestServer::start(collector.router()).await; + let wrapper_dir = root.path().join("bin"); + let data_dir = root.path().join("daemon"); + let socket = test_endpoint(root.path()); + let config_path = data_dir.join("config.json"); + std::fs::create_dir_all(&wrapper_dir).expect("create wrapper directory"); + std::fs::create_dir_all(&data_dir).expect("create daemon data directory"); + std::fs::write( + &config_path, + serde_json::to_vec_pretty(&json!({ + "traceToBraintrust": true, + "project": "agent-e2e", + "flushOnTurnEnd": true, + "additionalMetadata": {"test_harness": true} + })) + .unwrap(), + ) + .expect("write daemon config"); + + let daemon_binary = Path::new(env!("CARGO_BIN_EXE_bt-daemon")); + write_bt_wrapper(&wrapper_dir, daemon_binary); + + let mut command = Command::new(daemon_binary); + command + .arg("serve") + .arg("--socket") + .arg(&socket) + .arg("--data-dir") + .arg(&data_dir) + .arg("--idle-timeout-secs") + .arg("0") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + if ingest_mode == TestBackendMode::Mock { + command + .env("BRAINTRUST_API_URL", collector_server.uri()) + .env("BRAINTRUST_APP_URL", collector_server.uri()); + } + let daemon = command.spawn().expect("start daemon"); + + wait_for_daemon(daemon_binary, &socket).await; + Self { + inference_mode, + ingest_mode, + root, + collector, + collector_server, + daemon, + wrapper_dir, + socket, + data_dir, + config_path, + } + } + + pub fn uses_mock_inference(&self) -> bool { + self.inference_mode == TestBackendMode::Mock + } + + pub fn uses_live_inference(&self) -> bool { + self.inference_mode == TestBackendMode::Live + } + + pub fn uses_mock_ingest(&self) -> bool { + self.ingest_mode == TestBackendMode::Mock + } + + pub fn workspace(&self) -> PathBuf { + let workspace = self.root.path().join("workspace"); + std::fs::create_dir_all(&workspace).expect("create agent workspace"); + workspace + } + + pub fn temp_path(&self, name: &str) -> PathBuf { + self.root.path().join(name) + } + + pub fn configure(&self, command: &mut Command) { + let path = std::env::var_os("PATH").unwrap_or_default(); + let mut entries = vec![self.wrapper_dir.clone()]; + entries.extend(std::env::split_paths(&path)); + let combined = std::env::join_paths(entries).expect("construct test PATH"); + command + .env("PATH", combined) + .env("BT_DAEMON_SOCKET", &self.socket) + .env("BT_DAEMON_DATA_DIR", &self.data_dir) + .env("BT_DAEMON_CONFIG", &self.config_path) + .env("BRAINTRUST_FLUSH_ON_TURN_END", "true") + .stdin(Stdio::null()); + if self.uses_mock_ingest() { + command + .env("BRAINTRUST_API_KEY", "test-key") + .env("BRAINTRUST_API_URL", self.collector_server.uri()) + .env("BRAINTRUST_APP_URL", self.collector_server.uri()) + .env("BRAINTRUST_PROJECT", "agent-e2e"); + } + } + + pub async fn output(&self, command: &mut Command) -> std::process::Output { + command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let child = command.spawn().expect("spawn agent command"); + tokio::time::timeout(Duration::from_secs(90), child.wait_with_output()) + .await + .expect("agent command timed out") + .expect("wait for agent command") + } + + pub async fn wait_for_trace_rows(&self) -> Vec { + self.wait_for_trace_rows_matching(|rows| !rows.is_empty()) + .await + } + + pub async fn wait_for_trace_rows_matching( + &self, + predicate: impl Fn(&[Value]) -> bool, + ) -> Vec { + for _ in 0..100 { + let rows = self.collector.rows(); + if predicate(&rows) { + return rows; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!( + "daemon delivered no trace rows; {}; daemon files:\n{}", + self.collector.diagnostics(), + directory_contents(&self.data_dir) + ); + } + + /// Wait for the stable trace-delivery invariant in every backend mode. + /// Mock ingest returns captured rows for ordinary assertions; live ingest + /// verifies daemon emission and sink health. + pub async fn wait_for_trace_delivery(&self) -> Vec { + if self.uses_mock_ingest() { + self.wait_for_trace_rows().await + } else { + self.wait_for_live_ingest().await + } + } + + pub async fn wait_for_mock_ingest_scenario(&self, scenario: &IngestScenario) -> Vec { + assert!( + self.uses_mock_ingest(), + "ingest scenarios require mock ingest" + ); + let mut last_error = String::new(); + for _ in 0..100 { + match self.collector.evaluate(scenario) { + Ok(rows) => return rows, + Err(error) => last_error = error, + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!( + "ingest scenario did not complete: {last_error}; {}; daemon files:\n{}", + self.collector.diagnostics(), + directory_contents(&self.data_dir) + ); + } + + async fn wait_for_live_ingest(&self) -> Vec { + let mut last_status = String::new(); + for _ in 0..100 { + match run_status(StatusArgs { + socket: Some(self.socket.clone()), + session_id: None, + }) + .await + { + Ok(Some(status)) => { + last_status = format!("{:?}", status.sessions); + let emitted = status + .sessions + .iter() + .any(|session| session.spans_emitted > 0); + let errors = status + .sessions + .iter() + .filter_map(|session| session.last_error.as_deref()) + .collect::>(); + assert!( + errors.is_empty(), + "live ingest reported daemon sink errors: {errors:?}" + ); + if emitted { + return Vec::new(); + } + } + Ok(None) => last_status = "daemon not running".into(), + Err(error) => last_status = error.to_string(), + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + panic!("live ingest emitted no spans; last daemon status: {last_status}"); + } +} + +impl Drop for AgentTestWorld { + fn drop(&mut self) { + let _ = self.daemon.start_kill(); + } +} + +#[cfg(unix)] +fn write_bt_wrapper(directory: &Path, daemon_binary: &Path) { + use std::os::unix::fs::PermissionsExt; + + let path = directory.join("bt"); + let script = format!( + "#!/bin/sh\ncase \"$1\" in trace) shift;; esac\nexec '{}' \"$@\"\n", + daemon_binary.display() + ); + std::fs::write(&path, script).expect("write bt test wrapper"); + let mut permissions = std::fs::metadata(&path).unwrap().permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions).expect("make bt wrapper executable"); +} + +#[cfg(windows)] +fn write_bt_wrapper(directory: &Path, daemon_binary: &Path) { + let powershell = directory.join("bt-wrapper.ps1"); + let script = format!( + "$forward = @($args)\n\ + if ($forward.Count -gt 0 -and $forward[0] -eq 'trace') {{\n\ + if ($forward.Count -eq 1) {{ $forward = @() }} else {{ $forward = @($forward[1..($forward.Count - 1)]) }}\n\ + }}\n\ + & '{}' @forward\n\ + exit $LASTEXITCODE\n", + daemon_binary.display() + ); + std::fs::write(&powershell, script).expect("write bt PowerShell wrapper"); + std::fs::write( + directory.join("bt.cmd"), + "@echo off\r\npowershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File \"%~dp0bt-wrapper.ps1\" %*\r\n", + ) + .expect("write bt command wrapper"); + + // Claude Code, and some Codex releases, launch the portable `command` + // hook through Git Bash even on Windows. Git Bash does not resolve + // PATHEXT, so expose an extensionless shim in addition to bt.cmd. + let shell_binary = daemon_binary.to_string_lossy().replace('\\', "/"); + let shell = format!( + "#!/bin/sh\ncase \"$1\" in trace) shift;; esac\nexec '{}' \"$@\"\n", + shell_binary + ); + std::fs::write(directory.join("bt"), shell).expect("write bt Git Bash wrapper"); +} + +#[cfg(unix)] +fn test_endpoint(root: &Path) -> PathBuf { + root.join("daemon.sock") +} + +#[cfg(windows)] +fn test_endpoint(_root: &Path) -> PathBuf { + PathBuf::from(format!( + r"\\.\pipe\braintrust-bt-daemon-test-{}", + Uuid::new_v4() + )) +} + +async fn wait_for_daemon(daemon_binary: &Path, endpoint: &Path) { + for _ in 0..100 { + let output = Command::new(daemon_binary) + .arg("status") + .arg("--socket") + .arg(endpoint) + .output() + .await; + if let Ok(output) = output { + if output.status.success() + && !String::from_utf8_lossy(&output.stdout).contains("not running") + { + return; + } + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("daemon endpoint was not ready at {}", endpoint.display()); +} + +fn directory_contents(root: &Path) -> String { + fn visit(path: &Path, output: &mut String) { + let Ok(entries) = std::fs::read_dir(path) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + visit(&path, output); + } else { + let body = std::fs::read_to_string(&path).unwrap_or_else(|_| "".into()); + output.push_str(&format!("{}:\n{}\n", path.display(), body)); + } + } + } + let mut output = String::new(); + visit(root, &mut output); + output +} diff --git a/bt-daemon/tests/support/agents/claude.rs b/bt-daemon/tests/support/agents/claude.rs new file mode 100644 index 0000000..261ae5b --- /dev/null +++ b/bt-daemon/tests/support/agents/claude.rs @@ -0,0 +1,107 @@ +use super::{command_from_env, test_plugin, AgentOutput, ProcessOptions}; +use crate::support::agent_process::AgentTestWorld; +use std::ffi::OsString; +use std::path::PathBuf; +use uuid::Uuid; + +pub struct ClaudeAgent { + isolated_home: PathBuf, + isolated_config: PathBuf, +} + +pub struct ClaudeRun { + prompt: OsString, + mock_inference: Option, + options: ProcessOptions, +} + +struct ClaudeInference { + base_url: String, + model: String, + api_key: String, +} + +impl ClaudeRun { + pub fn new(prompt: impl Into) -> Self { + Self { + prompt: prompt.into(), + mock_inference: None, + options: ProcessOptions::default(), + } + } + + pub fn mock_inference(mut self, base_url: impl Into) -> Self { + self.mock_inference = Some(ClaudeInference { + base_url: base_url.into(), + model: "mock-model".into(), + api_key: "test-key".into(), + }); + self + } + + pub fn arg(mut self, value: impl Into) -> Self { + self.options.arg(value); + self + } + + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.options.env(key, value); + self + } +} + +impl ClaudeAgent { + pub fn new(world: &AgentTestWorld) -> Self { + let isolated_home = world.temp_path("claude-home"); + let isolated_config = world.temp_path("claude-config"); + std::fs::create_dir_all(&isolated_home).expect("create Claude home"); + std::fs::create_dir_all(&isolated_config).expect("create Claude config"); + Self { + isolated_home, + isolated_config, + } + } + + pub async fn run(&self, world: &AgentTestWorld, run: ClaudeRun) -> AgentOutput { + let session_id = Uuid::new_v4().to_string(); + let plugin = test_plugin::claude_plugin(world); + let mut command = command_from_env("CLAUDE_BIN", "claude"); + command + .args([ + "-p", + "--output-format", + "json", + "--dangerously-skip-permissions", + "--session-id", + &session_id, + "--plugin-dir", + ]) + .arg(plugin) + .current_dir(world.workspace()) + .env("ANTHROPIC_MAX_RETRIES", "0") + .env("DISABLE_AUTOUPDATER", "1") + .env("DISABLE_TELEMETRY", "1") + .env("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1"); + world.configure(&mut command); + + if world.uses_mock_inference() { + let inference = run + .mock_inference + .as_ref() + .expect("mock Claude runs require a mock inference endpoint"); + command + .args(["--model", &inference.model]) + .env("HOME", &self.isolated_home) + .env("CLAUDE_CONFIG_DIR", &self.isolated_config) + .env("ANTHROPIC_BASE_URL", &inference.base_url) + .env("ANTHROPIC_API_KEY", &inference.api_key) + .env("ANTHROPIC_AUTH_TOKEN", &inference.api_key) + .env("ANTHROPIC_DEFAULT_OPUS_MODEL", &inference.model) + .env("ANTHROPIC_DEFAULT_SONNET_MODEL", &inference.model) + .env("ANTHROPIC_DEFAULT_HAIKU_MODEL", &inference.model); + } + run.options.apply(&mut command); + command.arg(run.prompt); + world.output(&mut command).await.into() + } +} diff --git a/bt-daemon/tests/support/agents/codex.rs b/bt-daemon/tests/support/agents/codex.rs new file mode 100644 index 0000000..cdedb26 --- /dev/null +++ b/bt-daemon/tests/support/agents/codex.rs @@ -0,0 +1,150 @@ +use super::{command_from_env, configured_home, test_plugin, AgentOutput, ProcessOptions}; +use crate::support::agent_process::AgentTestWorld; +use std::ffi::OsString; +use std::path::PathBuf; +use tokio::process::Command; + +const INFERENCE_MODE_ENV: &str = "BT_AGENT_INFERENCE_MODE"; + +pub struct CodexAgent { + home: PathBuf, +} + +pub struct CodexRun { + prompt: OsString, + mock_inference: Option, + options: ProcessOptions, +} + +struct CodexInference { + base_url: String, + model: String, + api_key: String, +} + +impl CodexRun { + pub fn new(prompt: impl Into) -> Self { + Self { + prompt: prompt.into(), + mock_inference: None, + options: ProcessOptions::default(), + } + } + + pub fn mock_inference(mut self, base_url: impl Into) -> Self { + self.mock_inference = Some(CodexInference { + base_url: base_url.into(), + model: "mock-model".into(), + api_key: "test-key".into(), + }); + self + } + + pub fn arg(mut self, value: impl Into) -> Self { + self.options.arg(value); + self + } + + pub fn env(mut self, key: impl Into, value: impl Into) -> Self { + self.options.env(key, value); + self + } +} + +impl CodexAgent { + pub async fn install(world: &AgentTestWorld) -> Self { + let home = world.temp_path("codex-home"); + std::fs::create_dir_all(&home).expect("create Codex home"); + + let marketplace = test_plugin::codex_marketplace(world); + let mut add_marketplace = command_from_env("CODEX_BIN", "codex"); + add_marketplace + .arg("plugin") + .arg("marketplace") + .arg("add") + .arg(&marketplace) + .env("CODEX_HOME", &home); + world.configure(&mut add_marketplace); + AgentOutput::from(world.output(&mut add_marketplace).await).assert_success(); + + let mut add_plugin = command_from_env("CODEX_BIN", "codex"); + add_plugin + .args(["plugin", "add", "trace-codex-test@braintrust-daemon-tests"]) + .env("CODEX_HOME", &home); + world.configure(&mut add_plugin); + AgentOutput::from(world.output(&mut add_plugin).await).assert_success(); + + let agent = Self { home }; + if world.uses_live_inference() { + agent.seed_live_auth(); + } + agent + } + + pub fn seed_live_auth(&self) { + if std::env::var_os("OPENAI_API_KEY").is_some() { + return; + } + let source = configured_home("CODEX_HOME", ".codex") + .map(|home| home.join("auth.json")) + .filter(|path| path.is_file()) + .unwrap_or_else(|| { + panic!( + "{INFERENCE_MODE_ENV}=live requires OPENAI_API_KEY or auth.json in the configured Codex home" + ) + }); + std::fs::copy(source, self.home.join("auth.json")).expect("copy Codex live credentials"); + } + + pub async fn run(&self, world: &AgentTestWorld, run: CodexRun) -> AgentOutput { + let mut command = self.command(world); + if world.uses_mock_inference() { + let inference = run + .mock_inference + .as_ref() + .expect("mock Codex runs require a mock inference endpoint"); + configure_mock_inference(&mut command, inference); + } + run.options.apply(&mut command); + command.arg(run.prompt); + world.output(&mut command).await.into() + } + + fn command(&self, world: &AgentTestWorld) -> Command { + let mut command = command_from_env("CODEX_BIN", "codex"); + command + .args([ + "exec", + "--skip-git-repo-check", + "--dangerously-bypass-hook-trust", + "--sandbox", + "read-only", + "-c", + r#"approval_policy="never""#, + ]) + .current_dir(world.workspace()) + .env("CODEX_HOME", &self.home); + world.configure(&mut command); + command + } +} + +fn configure_mock_inference(command: &mut Command, inference: &CodexInference) { + let provider = format!( + r#"model_providers.mock={{name="Mock",base_url="{}/v1",wire_api="responses",env_key="MOCK_API_KEY",request_max_retries=0,stream_max_retries=0,stream_idle_timeout_ms=5000}}"#, + inference.base_url + ); + let chatgpt_base_url = format!(r#"chatgpt_base_url="{}/backend-api""#, inference.base_url); + command + .args([ + "-c", + &format!(r#"model="{}""#, inference.model), + "-c", + r#"model_provider="mock""#, + "-c", + &provider, + "-c", + &chatgpt_base_url, + ]) + .env("MOCK_API_KEY", &inference.api_key); +} diff --git a/bt-daemon/tests/support/agents/mod.rs b/bt-daemon/tests/support/agents/mod.rs new file mode 100644 index 0000000..47447bd --- /dev/null +++ b/bt-daemon/tests/support/agents/mod.rs @@ -0,0 +1,92 @@ +mod claude; +mod codex; +mod test_plugin; + +#[allow(unused_imports)] +pub use claude::{ClaudeAgent, ClaudeRun}; +#[allow(unused_imports)] +pub use codex::{CodexAgent, CodexRun}; + +use std::ffi::OsString; +use std::path::PathBuf; +use tokio::process::Command; + +pub struct AgentOutput { + output: std::process::Output, +} + +impl AgentOutput { + pub fn success(&self) -> bool { + self.output.status.success() + } + + pub fn text(&self) -> String { + format!( + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&self.output.stdout), + String::from_utf8_lossy(&self.output.stderr) + ) + } + + pub fn assert_success(&self) { + assert!(self.success(), "{}", self.text()); + } + + pub fn assert_failure(&self) { + assert!(!self.success(), "{}", self.text()); + } + + pub fn assert_contains(&self, expected: &str) { + assert!( + self.text().contains(expected), + "agent output did not contain {expected:?}:\n{}", + self.text() + ); + } +} + +impl From for AgentOutput { + fn from(output: std::process::Output) -> Self { + Self { output } + } +} + +#[derive(Default)] +struct ProcessOptions { + args: Vec, + env: Vec<(OsString, OsString)>, +} + +impl ProcessOptions { + fn arg(&mut self, value: impl Into) { + self.args.push(value.into()); + } + + fn env(&mut self, key: impl Into, value: impl Into) { + self.env.push((key.into(), value.into())); + } + + fn apply(&self, command: &mut Command) { + command + .args(&self.args) + .envs(self.env.iter().map(|(k, v)| (k, v))); + } +} + +fn command_from_env(name: &str, fallback: &str) -> Command { + if let Some(command) = std::env::var_os(name) { + return Command::new(command); + } + #[cfg(windows)] + let fallback = format!("{fallback}.cmd"); + Command::new(fallback) +} + +fn configured_home(config_env: &str, directory: &str) -> Option { + std::env::var_os(config_env).map(PathBuf::from).or_else(|| { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .map(|home| home.join(directory)) + }) +} diff --git a/bt-daemon/tests/support/agents/test_plugin.rs b/bt-daemon/tests/support/agents/test_plugin.rs new file mode 100644 index 0000000..502b91d --- /dev/null +++ b/bt-daemon/tests/support/agents/test_plugin.rs @@ -0,0 +1,95 @@ +use crate::support::agent_process::AgentTestWorld; +use serde_json::{json, Map, Value}; +use std::path::{Path, PathBuf}; + +const HOOK_EVENTS: &[&str] = &[ + "SessionStart", + "UserPromptSubmit", + "PreToolUse", + "PermissionRequest", + "PermissionDenied", + "PostToolUse", + "PostToolUseFailure", + "PostToolBatch", + "PreCompact", + "PostCompact", + "SubagentStart", + "SubagentStop", + "Stop", + "StopFailure", + "SessionEnd", + "TaskCreated", + "TaskCompleted", +]; + +pub fn codex_marketplace(world: &AgentTestWorld) -> PathBuf { + let root = world.temp_path("codex-test-marketplace"); + write_json( + &root.join(".agents/plugins/marketplace.json"), + json!({ + "name": "braintrust-daemon-tests", + "plugins": [{ + "name": "trace-codex-test", + "source": { + "source": "local", + "path": "./plugins/trace-codex-test" + } + }] + }), + ); + + let plugin = root.join("plugins/trace-codex-test"); + write_json( + &plugin.join(".codex-plugin/plugin.json"), + json!({ + "name": "trace-codex-test", + "version": "0.0.0", + "description": "Direct bt daemon hook fixture", + "hooks": "./hooks/hooks.json" + }), + ); + write_json( + &plugin.join("hooks/hooks.json"), + hook_config("bt trace hook --source codex --source-version test"), + ); + root +} + +pub fn claude_plugin(world: &AgentTestWorld) -> PathBuf { + let root = world.temp_path("claude-test-plugin"); + write_json( + &root.join(".claude-plugin/plugin.json"), + json!({ + "name": "trace-claude-test", + "version": "0.0.0", + "description": "Direct bt daemon hook fixture" + }), + ); + write_json( + &root.join("hooks/hooks.json"), + hook_config("bt trace hook --source claude-code --source-version test"), + ); + root +} + +fn hook_config(command: &str) -> Value { + let hook = json!([{ + "hooks": [{ + "type": "command", + "command": command, + "async": false + }] + }]); + let hooks = HOOK_EVENTS + .iter() + .map(|event| ((*event).to_string(), hook.clone())) + .collect::>(); + json!({ "hooks": hooks }) +} + +fn write_json(path: &Path, value: Value) { + std::fs::create_dir_all(path.parent().expect("test plugin file has parent")) + .expect("create test plugin directory"); + std::fs::write(path, serde_json::to_vec_pretty(&value).unwrap()) + .expect("write test plugin file"); +} diff --git a/bt-daemon/tests/support/inference/README.md b/bt-daemon/tests/support/inference/README.md new file mode 100644 index 0000000..92567d3 --- /dev/null +++ b/bt-daemon/tests/support/inference/README.md @@ -0,0 +1,78 @@ +# Deterministic inference test support + +This directory is a self-contained mock-inference component with two +protocol-faithful servers: + +- `OpenAiMock` implements the OpenAI Responses API surface used by Codex. +- `AnthropicMock` implements the Anthropic Messages API surface used by + Claude Code. + +Each public mock owns its protocol routes, scenario closure, and captured +requests, and exports an Axum `Router`. Callers can bind that router with the +shared ephemeral test server or embed it in another Axum application. The two +providers share request indexing and transport outcomes. Request and response +types remain provider-specific so a test cannot accidentally hide a +wire-protocol incompatibility behind a common model abstraction. + +Both mocks accept a thread-safe closure: + +```rust,ignore +let mock = OpenAiMock::new(|context, request| { + match context.request_index { + 0 => MockReply::response(OpenAiTurn::tool_call( + "call-1", + "exec_command", + json!({"cmd":"printf hello"}), + )), + 1 if request.has_function_output("call-1") => { + MockReply::response(OpenAiTurn::text("done")) + } + index => panic!("unexpected request {index}: {}", request.body), + } +}); +let server = TestServer::start(mock.router()).await; +``` + +`MockReply` supports normal provider responses, arbitrary HTTP errors, and raw +response bodies for malformed or truncated stream tests. Typed turn builders +generate deterministic ids, token usage, and valid provider SSE sequences. +Every inference request is captured for later assertions. + +The component does not depend on `bt-daemon`, the coding-agent runner, the +ingest mock, or a particular listener implementation. The higher-level +`support::agent_process` harness composes with it only from the integration +test. This boundary is deliberate so the whole mock-inference component can +later move into a reusable crate and serve any client that can target an +OpenAI Responses or Anthropic Messages endpoint. + +`agent_integration.rs` runs real Codex and Claude Code processes against these +mocks. +The tests are ignored in a plain Rust run because they require agent +executables. The core cross-platform CI matrix installs the latest release of +each agent and runs them in the default `mock` mode on every host. This is +intentionally unpinned so upstream compatibility breaks are visible +immediately. + +The same agent tests can run without mock inference while continuing to use +captured local ingest: + +```console +BT_AGENT_INFERENCE_MODE=live BT_AGENT_INGEST_MODE=mock \ + cargo test --manifest-path bt-daemon/Cargo.toml \ + --all-features --test agent_integration -- --ignored --test-threads=1 +``` + +Live inference uses the normal provider endpoint/model and the agent's normal +login or provider credentials. It validates only stable integration invariants +such as trace delivery and origin metadata. Mock inference additionally +validates exact request sequences, tool results, output content, and injected +failures. + +Inference and ingest selection are independent. To drive deterministic model +behavior while reporting traces to the normal Braintrust backend: + +```console +BT_AGENT_INFERENCE_MODE=mock BT_AGENT_INGEST_MODE=live \ + cargo test --manifest-path bt-daemon/Cargo.toml \ + --all-features --test agent_integration -- --ignored --test-threads=1 +``` diff --git a/bt-daemon/tests/support/inference/anthropic.rs b/bt-daemon/tests/support/inference/anthropic.rs new file mode 100644 index 0000000..59662b1 --- /dev/null +++ b/bt-daemon/tests/support/inference/anthropic.rs @@ -0,0 +1,258 @@ +use super::{decode_json_body, json_response, raw_response, sse, MockReply, RequestContext}; +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{get, post}; +use axum::Router; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone)] +pub struct AnthropicRequest { + pub body: Value, +} + +impl AnthropicRequest { + pub fn model(&self) -> Option<&str> { + self.body["model"].as_str() + } + + pub fn contains_text(&self, text: &str) -> bool { + self.body.to_string().contains(text) + } + + pub fn has_tool_result(&self, tool_use_id: &str) -> bool { + self.body["messages"].as_array().is_some_and(|messages| { + messages.iter().any(|message| { + message["content"].as_array().is_some_and(|blocks| { + blocks.iter().any(|block| { + block["type"] == "tool_result" && block["tool_use_id"] == tool_use_id + }) + }) + }) + }) + } +} + +#[derive(Debug, Clone)] +pub enum AnthropicTurn { + Text { + text: String, + input_tokens: u64, + output_tokens: u64, + }, + ToolUse { + tool_use_id: String, + name: String, + input: Value, + input_tokens: u64, + output_tokens: u64, + }, + Events(Vec), +} + +impl AnthropicTurn { + pub fn text(text: impl Into) -> Self { + Self::Text { + text: text.into(), + input_tokens: 10, + output_tokens: 5, + } + } + + pub fn tool_use(tool_use_id: impl Into, name: impl Into, input: Value) -> Self { + Self::ToolUse { + tool_use_id: tool_use_id.into(), + name: name.into(), + input, + input_tokens: 10, + output_tokens: 5, + } + } + + fn events(self, response_index: usize) -> Vec { + let message_id = format!("msg_mock_{response_index}"); + match self { + Self::Text { + text, + input_tokens, + output_tokens, + } => { + let mut events = message_start(&message_id, input_tokens); + events.extend([ + json!({ + "type": "content_block_start", + "index": 0, + "content_block": {"type": "text", "text": ""} + }), + json!({ + "type": "content_block_delta", + "index": 0, + "delta": {"type": "text_delta", "text": text} + }), + json!({"type": "content_block_stop", "index": 0}), + message_delta("end_turn", output_tokens), + json!({"type": "message_stop"}), + ]); + events + } + Self::ToolUse { + tool_use_id, + name, + input, + input_tokens, + output_tokens, + } => { + let mut events = message_start(&message_id, input_tokens); + events.extend([ + json!({ + "type": "content_block_start", + "index": 0, + "content_block": { + "type": "tool_use", + "id": tool_use_id, + "name": name, + "input": {} + } + }), + json!({ + "type": "content_block_delta", + "index": 0, + "delta": { + "type": "input_json_delta", + "partial_json": input.to_string() + } + }), + json!({"type": "content_block_stop", "index": 0}), + message_delta("tool_use", output_tokens), + json!({"type": "message_stop"}), + ]); + events + } + Self::Events(events) => events, + } + } +} + +fn message_start(id: &str, input_tokens: u64) -> Vec { + vec![json!({ + "type": "message_start", + "message": { + "id": id, + "type": "message", + "role": "assistant", + "content": [], + "model": "mock-model", + "stop_reason": null, + "stop_sequence": null, + "usage": { + "input_tokens": input_tokens, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 1 + } + } + })] +} + +fn message_delta(stop_reason: &str, output_tokens: u64) -> Value { + json!({ + "type": "message_delta", + "delta": {"stop_reason": stop_reason, "stop_sequence": null}, + "usage": {"output_tokens": output_tokens} + }) +} + +type Handler = + dyn Fn(RequestContext, AnthropicRequest) -> MockReply + Send + Sync + 'static; + +struct MockState { + handler: Arc, + requests: Mutex>, + next_index: AtomicUsize, +} + +pub struct AnthropicMock { + state: Arc, +} + +impl AnthropicMock { + pub fn new(handler: H) -> Self + where + H: Fn(RequestContext, AnthropicRequest) -> MockReply + Send + Sync + 'static, + { + let state = Arc::new(MockState { + handler: Arc::new(handler), + requests: Mutex::new(Vec::new()), + next_index: AtomicUsize::new(0), + }); + Self { state } + } + + pub fn router(&self) -> Router { + Router::new() + .route("/v1/models", get(models)) + .route("/v1/messages", post(messages)) + .route("/v1/messages/count_tokens", post(count_tokens)) + .with_state(Arc::clone(&self.state)) + } + + pub fn requests(&self) -> Vec { + self.state.requests.lock().expect("request lock").clone() + } +} + +async fn models() -> axum::Json { + axum::Json(json!({ + "data": [{ + "type": "model", + "id": "mock-model", + "display_name": "Mock model", + "created_at": "2026-01-01T00:00:00Z" + }], + "has_more": false, + "first_id": "mock-model", + "last_id": "mock-model" + })) +} + +async fn count_tokens() -> axum::Json { + axum::Json(json!({"input_tokens": 10})) +} + +async fn messages( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> axum::response::Response { + let body = match decode_json_body(&headers, &body) { + Ok(body) => body, + Err(error) => return json_response(StatusCode::BAD_REQUEST, json!({"error": error})), + }; + let request = AnthropicRequest { body }; + state + .requests + .lock() + .expect("request lock") + .push(request.clone()); + let index = state.next_index.fetch_add(1, Ordering::SeqCst); + match (state.handler)( + RequestContext { + request_index: index, + }, + request, + ) { + MockReply::Response(turn) => raw_response( + StatusCode::OK, + "text/event-stream", + sse(&turn.events(index)), + ), + MockReply::HttpError { status, body } => json_response(status, body), + MockReply::Raw { + status, + content_type, + body, + } => raw_response(status, content_type, body), + } +} diff --git a/bt-daemon/tests/support/inference/mod.rs b/bt-daemon/tests/support/inference/mod.rs new file mode 100644 index 0000000..4339036 --- /dev/null +++ b/bt-daemon/tests/support/inference/mod.rs @@ -0,0 +1,94 @@ +mod anthropic; +mod openai; + +#[allow(unused_imports)] +pub use anthropic::{AnthropicMock, AnthropicRequest, AnthropicTurn}; +#[allow(unused_imports)] +pub use openai::{OpenAiMock, OpenAiRequest, OpenAiTurn}; + +use axum::http::StatusCode; +use serde_json::Value; + +#[derive(Debug, Clone, Copy)] +pub struct RequestContext { + pub request_index: usize, +} + +/// A provider-neutral transport outcome. Protocol response bodies remain +/// provider-specific and are rendered by the OpenAI/Anthropic adapters. +#[derive(Debug, Clone)] +pub enum MockReply { + Response(T), + HttpError { + status: StatusCode, + body: Value, + }, + Raw { + status: StatusCode, + content_type: &'static str, + body: Vec, + }, +} + +impl MockReply { + pub fn response(value: T) -> Self { + Self::Response(value) + } + + pub fn http_error(status: StatusCode, body: Value) -> Self { + Self::HttpError { status, body } + } + + pub fn raw_sse(body: impl Into>) -> Self { + Self::Raw { + status: StatusCode::OK, + content_type: "text/event-stream", + body: body.into(), + } + } +} + +fn decode_json_body(headers: &axum::http::HeaderMap, body: &[u8]) -> Result { + let decoded = match headers + .get(axum::http::header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()) + { + Some(value) if value.split(',').any(|part| part.trim() == "zstd") => { + zstd::stream::decode_all(std::io::Cursor::new(body)) + .map_err(|error| format!("decode zstd request: {error}"))? + } + _ => body.to_vec(), + }; + serde_json::from_slice(&decoded).map_err(|error| format!("decode JSON request: {error}")) +} + +fn json_response(status: StatusCode, body: Value) -> axum::response::Response { + use axum::response::IntoResponse; + (status, axum::Json(body)).into_response() +} + +fn raw_response( + status: StatusCode, + content_type: &'static str, + body: Vec, +) -> axum::response::Response { + use axum::response::IntoResponse; + ( + status, + [(axum::http::header::CONTENT_TYPE, content_type)], + body, + ) + .into_response() +} + +fn sse(events: &[Value]) -> Vec { + use std::fmt::Write; + + let mut body = String::new(); + for event in events { + let kind = event["type"].as_str().expect("SSE event type"); + writeln!(&mut body, "event: {kind}").expect("write SSE event"); + writeln!(&mut body, "data: {event}\n").expect("write SSE data"); + } + body.into_bytes() +} diff --git a/bt-daemon/tests/support/inference/openai.rs b/bt-daemon/tests/support/inference/openai.rs new file mode 100644 index 0000000..92e3ec4 --- /dev/null +++ b/bt-daemon/tests/support/inference/openai.rs @@ -0,0 +1,231 @@ +use super::{decode_json_body, json_response, raw_response, sse, MockReply, RequestContext}; +use axum::body::Bytes; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::{get, post}; +use axum::Router; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +#[derive(Debug, Clone)] +pub struct OpenAiRequest { + pub body: Value, +} + +impl OpenAiRequest { + pub fn model(&self) -> Option<&str> { + self.body["model"].as_str() + } + + pub fn contains_text(&self, text: &str) -> bool { + self.body.to_string().contains(text) + } + + pub fn has_function_output(&self, call_id: &str) -> bool { + self.body["input"].as_array().is_some_and(|items| { + items + .iter() + .any(|item| item["type"] == "function_call_output" && item["call_id"] == call_id) + }) + } + + pub fn tool_names(&self) -> Vec<&str> { + self.body["tools"] + .as_array() + .into_iter() + .flatten() + .filter_map(|tool| tool["name"].as_str()) + .collect() + } +} + +#[derive(Debug, Clone)] +pub enum OpenAiTurn { + Text { + text: String, + input_tokens: u64, + output_tokens: u64, + }, + ToolCall { + call_id: String, + name: String, + arguments: Value, + input_tokens: u64, + output_tokens: u64, + }, + Events(Vec), +} + +impl OpenAiTurn { + pub fn text(text: impl Into) -> Self { + Self::Text { + text: text.into(), + input_tokens: 10, + output_tokens: 5, + } + } + + pub fn tool_call( + call_id: impl Into, + name: impl Into, + arguments: Value, + ) -> Self { + Self::ToolCall { + call_id: call_id.into(), + name: name.into(), + arguments, + input_tokens: 10, + output_tokens: 5, + } + } + + fn events(self, response_index: usize) -> Vec { + let response_id = format!("resp_mock_{response_index}"); + let created = json!({ + "type": "response.created", + "response": {"id": response_id} + }); + match self { + Self::Text { + text, + input_tokens, + output_tokens, + } => vec![ + created, + json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "id": format!("msg_mock_{response_index}"), + "content": [{"type": "output_text", "text": text}] + } + }), + completed(&response_id, input_tokens, output_tokens), + ], + Self::ToolCall { + call_id, + name, + arguments, + input_tokens, + output_tokens, + } => vec![ + created, + json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": arguments.to_string() + } + }), + completed(&response_id, input_tokens, output_tokens), + ], + Self::Events(events) => events, + } + } +} + +fn completed(id: &str, input_tokens: u64, output_tokens: u64) -> Value { + json!({ + "type": "response.completed", + "response": { + "id": id, + "usage": { + "input_tokens": input_tokens, + "input_tokens_details": {"cached_tokens": 0}, + "output_tokens": output_tokens, + "output_tokens_details": {"reasoning_tokens": 0}, + "total_tokens": input_tokens + output_tokens + } + } + }) +} + +type Handler = + dyn Fn(RequestContext, OpenAiRequest) -> MockReply + Send + Sync + 'static; + +struct MockState { + handler: Arc, + requests: Mutex>, + next_index: AtomicUsize, +} + +pub struct OpenAiMock { + state: Arc, +} + +impl OpenAiMock { + pub fn new(handler: H) -> Self + where + H: Fn(RequestContext, OpenAiRequest) -> MockReply + Send + Sync + 'static, + { + let state = Arc::new(MockState { + handler: Arc::new(handler), + requests: Mutex::new(Vec::new()), + next_index: AtomicUsize::new(0), + }); + Self { state } + } + + pub fn router(&self) -> Router { + Router::new() + .route("/v1/models", get(models)) + .route("/v1/responses", post(responses)) + .route("/backend-api/plugins/featured", get(featured_plugins)) + .with_state(Arc::clone(&self.state)) + } + + pub fn requests(&self) -> Vec { + self.state.requests.lock().expect("request lock").clone() + } +} + +async fn models() -> axum::Json { + axum::Json(json!({ + "object": "list", + "data": [{"id": "mock-model", "object": "model", "owned_by": "mock"}] + })) +} + +async fn featured_plugins() -> axum::Json { + axum::Json(json!([])) +} + +async fn responses( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> axum::response::Response { + let body = match decode_json_body(&headers, &body) { + Ok(body) => body, + Err(error) => return json_response(StatusCode::BAD_REQUEST, json!({"error": error})), + }; + let request = OpenAiRequest { body }; + state + .requests + .lock() + .expect("request lock") + .push(request.clone()); + let index = state.next_index.fetch_add(1, Ordering::SeqCst); + match (state.handler)( + RequestContext { + request_index: index, + }, + request, + ) { + MockReply::Response(turn) => raw_response( + StatusCode::OK, + "text/event-stream", + sse(&turn.events(index)), + ), + MockReply::HttpError { status, body } => json_response(status, body), + MockReply::Raw { + status, + content_type, + body, + } => raw_response(status, content_type, body), + } +} diff --git a/bt-daemon/tests/support/ingest.rs b/bt-daemon/tests/support/ingest.rs new file mode 100644 index 0000000..d018c69 --- /dev/null +++ b/bt-daemon/tests/support/ingest.rs @@ -0,0 +1,160 @@ +use axum::body::Bytes; +use axum::extract::State; +use axum::http::HeaderMap; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde_json::{json, Value}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +type RowMatcher = dyn Fn(&Value) -> bool + Send + Sync + 'static; + +struct ExpectedRow { + name: String, + matcher: Arc, +} + +#[derive(Default)] +pub struct IngestScenario { + expected: Vec, +} + +impl IngestScenario { + pub fn new() -> Self { + Self::default() + } + + /// Require a row shape after all previously declared shapes. Unrelated + /// rows are ignored, so matching is independent of HTTP batching and + /// SDK-generated update rows. + pub fn expect( + mut self, + name: impl Into, + matcher: impl Fn(&Value) -> bool + Send + Sync + 'static, + ) -> Self { + self.expected.push(ExpectedRow { + name: name.into(), + matcher: Arc::new(matcher), + }); + self + } + + pub fn evaluate(&self, rows: &[Value]) -> Result<(), String> { + let mut cursor = 0; + for (matched, expected) in self.expected.iter().enumerate() { + let Some(offset) = rows[cursor..] + .iter() + .position(|row| (expected.matcher)(row)) + else { + return Err(format!( + "missing ingest shape {:?} after matching {} of {} shapes", + expected.name, + matched, + self.expected.len() + )); + }; + cursor += offset + 1; + } + Ok(()) + } +} + +#[derive(Default)] +struct CollectorState { + rows: Mutex>, + registrations: AtomicUsize, + log_requests: AtomicUsize, +} + +pub struct IngestMock { + state: Arc, +} + +impl IngestMock { + pub fn new() -> Self { + let state = Arc::new(CollectorState::default()); + Self { state } + } + + pub fn router(&self) -> Router { + Router::new() + .route("/version", get(version)) + .route("/api/apikey/login", post(login)) + .route("/api/project/register", post(register_project)) + .route("/logs3", post(logs)) + .route("/logs3/overflow", post(logs)) + .with_state(Arc::clone(&self.state)) + } + + pub fn rows(&self) -> Vec { + self.state.rows.lock().expect("trace row lock").clone() + } + + pub fn diagnostics(&self) -> String { + format!( + "project registrations: {}; log requests: {}; rows: {}", + self.state.registrations.load(Ordering::SeqCst), + self.state.log_requests.load(Ordering::SeqCst), + self.rows().len() + ) + } + + pub fn evaluate(&self, scenario: &IngestScenario) -> Result, String> { + let rows = self.rows(); + scenario.evaluate(&rows)?; + Ok(rows) + } +} + +async fn version() -> Json { + Json(json!({"logs3_payload_max_bytes": null})) +} + +async fn login() -> Json { + Json(json!({ + "org_info": [{ + "id": "mock-org", + "name": "mock", + "api_url": "unused", + "proxy_url": "unused" + }] + })) +} + +async fn register_project(State(state): State>) -> Json { + state.registrations.fetch_add(1, Ordering::SeqCst); + Json(json!({ + "project": { + "id": "00000000-0000-0000-0000-000000000001", + "name": "agent-e2e" + } + })) +} + +async fn logs( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> Json { + state.log_requests.fetch_add(1, Ordering::SeqCst); + let decoded = match headers + .get(axum::http::header::CONTENT_ENCODING) + .and_then(|value| value.to_str().ok()) + { + Some(value) if value.split(',').any(|part| part.trim() == "gzip") => { + // The SDK currently sends uncompressed bodies in this path. Keep a + // clear failure if that changes so the collector can add decoding. + panic!("gzip-compressed Braintrust rows are not yet supported") + } + _ => body.to_vec(), + }; + let payload: Value = serde_json::from_slice(&decoded).expect("decode /logs3 body"); + if let Some(rows) = payload["rows"].as_array() { + state + .rows + .lock() + .expect("trace row lock") + .extend(rows.iter().cloned()); + } + Json(json!({})) +} diff --git a/bt-daemon/tests/support/mod.rs b/bt-daemon/tests/support/mod.rs new file mode 100644 index 0000000..12d793e --- /dev/null +++ b/bt-daemon/tests/support/mod.rs @@ -0,0 +1,7 @@ +#![allow(dead_code)] + +pub mod agent_process; +pub mod agents; +pub mod inference; +pub mod ingest; +pub mod server; diff --git a/bt-daemon/tests/support/server.rs b/bt-daemon/tests/support/server.rs new file mode 100644 index 0000000..fec25d9 --- /dev/null +++ b/bt-daemon/tests/support/server.rs @@ -0,0 +1,56 @@ +use axum::Router; +use tokio::net::TcpListener; +use tokio::sync::oneshot; + +/// Lifecycle wrapper for any ephemeral Axum test service. +pub struct TestServer { + uri: String, + shutdown: Option>, + task: Option>>, +} + +impl TestServer { + pub async fn start(router: Router) -> Self { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral test server"); + let address = listener.local_addr().expect("read test server address"); + let (shutdown, shutdown_rx) = oneshot::channel(); + let task = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await + }); + Self { + uri: format!("http://{address}"), + shutdown: Some(shutdown), + task: Some(task), + } + } + + pub fn uri(&self) -> &str { + &self.uri + } + + pub async fn shutdown(mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(task) = self.task.take() { + let _ = task.await; + } + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + if let Some(shutdown) = self.shutdown.take() { + let _ = shutdown.send(()); + } + if let Some(task) = self.task.take() { + task.abort(); + } + } +}