From 54d0f7fdf1f2f712da7cb0eb4108f0c9ecf03668 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 6 May 2026 08:14:19 -0400 Subject: [PATCH 1/5] @relayburn/sdk: scaffold npm umbrella + napi-rs CI matrix + conformance gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 1 D2 of the Rust port (#247 part b). Stands up the npm packaging side of the napi-rs deliverable while #247-a (parallel agent) fills in crates/relayburn-sdk-node. - packages/sdk-node/: umbrella @relayburn/sdk@2.0.0-pre.0 with thin TS facade re-exporting the napi-rs verbs (ingest, summary, sessionCost, overhead, overheadTrim, hotspots, compare). Resolves the right native binary via optionalDependencies on @relayburn/sdk-{darwin-arm64, darwin-x64,linux-arm64-gnu,linux-x64-gnu}. win32-x64-msvc deferred to a #247 follow-up. - pnpm-workspace.yaml: exclude packages/sdk-node so the 2.x umbrella doesn't collide with the 1.x @relayburn/sdk during workspace:* resolution. CI installs sdk-node deps with --ignore-workspace. - .github/workflows/napi-build.yml: napi-rs build matrix (darwin-arm64, darwin-x64, linux-arm64-gnu, linux-x64-gnu) on PRs / main / dispatch. Caches Cargo + pnpm. Publish step is stubbed; full wiring lands in #249 (lockstep release workflow). - packages/sdk-node/test/conformance.test.js: deep-equal scaffold comparing TS @relayburn/sdk@1.x vs the napi-rs umbrella across the six verbs. Skips while #247-a is in flight; flip RELAYBURN_SDK_NAPI_BUILT=1 in CI once bindings land. - packages/sdk-node/test/esbuild-smoke.test.js: bundles the umbrella facade through esbuild with native-binding externals — catches resolution / bundling regressions for downstream embedders. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/napi-build.yml | 198 +++++++++++ CHANGELOG.md | 1 + packages/sdk-node/CHANGELOG.md | 9 + packages/sdk-node/README.md | 36 ++ packages/sdk-node/npm/darwin-arm64/README.md | 7 + .../sdk-node/npm/darwin-arm64/package.json | 29 ++ packages/sdk-node/npm/darwin-x64/README.md | 7 + packages/sdk-node/npm/darwin-x64/package.json | 29 ++ .../sdk-node/npm/linux-arm64-gnu/README.md | 7 + .../sdk-node/npm/linux-arm64-gnu/package.json | 32 ++ packages/sdk-node/npm/linux-x64-gnu/README.md | 7 + .../sdk-node/npm/linux-x64-gnu/package.json | 32 ++ packages/sdk-node/package.json | 64 ++++ packages/sdk-node/pnpm-lock.yaml | 295 ++++++++++++++++ packages/sdk-node/src/binding.d.ts | 21 ++ packages/sdk-node/src/binding.js | 83 +++++ packages/sdk-node/src/index.cjs | 19 + packages/sdk-node/src/index.d.ts | 329 ++++++++++++++++++ packages/sdk-node/src/index.js | 44 +++ packages/sdk-node/test/conformance.test.js | 132 +++++++ packages/sdk-node/test/esbuild-smoke.test.js | 96 +++++ pnpm-workspace.yaml | 9 + 22 files changed, 1486 insertions(+) create mode 100644 .github/workflows/napi-build.yml create mode 100644 packages/sdk-node/CHANGELOG.md create mode 100644 packages/sdk-node/README.md create mode 100644 packages/sdk-node/npm/darwin-arm64/README.md create mode 100644 packages/sdk-node/npm/darwin-arm64/package.json create mode 100644 packages/sdk-node/npm/darwin-x64/README.md create mode 100644 packages/sdk-node/npm/darwin-x64/package.json create mode 100644 packages/sdk-node/npm/linux-arm64-gnu/README.md create mode 100644 packages/sdk-node/npm/linux-arm64-gnu/package.json create mode 100644 packages/sdk-node/npm/linux-x64-gnu/README.md create mode 100644 packages/sdk-node/npm/linux-x64-gnu/package.json create mode 100644 packages/sdk-node/package.json create mode 100644 packages/sdk-node/pnpm-lock.yaml create mode 100644 packages/sdk-node/src/binding.d.ts create mode 100644 packages/sdk-node/src/binding.js create mode 100644 packages/sdk-node/src/index.cjs create mode 100644 packages/sdk-node/src/index.d.ts create mode 100644 packages/sdk-node/src/index.js create mode 100644 packages/sdk-node/test/conformance.test.js create mode 100644 packages/sdk-node/test/esbuild-smoke.test.js diff --git a/.github/workflows/napi-build.yml b/.github/workflows/napi-build.yml new file mode 100644 index 00000000..087a7b1f --- /dev/null +++ b/.github/workflows/napi-build.yml @@ -0,0 +1,198 @@ +name: napi build + +# Build prebuilt napi-rs `.node` artifacts for every platform we publish to +# npm under `@relayburn/sdk-*`. PRs validate the matrix; tags / manual +# dispatch *would* publish, but the publish step is stubbed off for now — +# wired up properly in #249 (cutover release workflow). +# +# Caches Cargo registry + git + target dir, plus pnpm store, so the matrix +# stays fast on the hot path. + +on: + pull_request: + paths: + - 'crates/relayburn-sdk/**' + - 'crates/relayburn-sdk-node/**' + - 'packages/sdk-node/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - '.github/workflows/napi-build.yml' + push: + branches: [main] + paths: + - 'crates/relayburn-sdk/**' + - 'crates/relayburn-sdk-node/**' + - 'packages/sdk-node/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - '.github/workflows/napi-build.yml' + workflow_dispatch: + inputs: + publish: + description: 'Publish prebuilt artifacts to npm under the `next` tag (stubbed; full wiring in #249)' + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: napi-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: build ${{ matrix.target }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-apple-darwin + os: macos-13 + short: darwin-x64 + - target: aarch64-apple-darwin + os: macos-14 + short: darwin-arm64 + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + short: linux-x64-gnu + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + short: linux-arm64-gnu + runs-on: ${{ matrix.os }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22.14.0' + cache: 'pnpm' + + - name: Setup Rust toolchain + # rust-toolchain.toml at the repo root pins channel + components. + # `cross` handles the aarch64 linux target via container, so we + # only register the host target by default and add the matrix + # target explicitly when it's a supported native cross. + run: | + rustup toolchain install + rustup target add ${{ matrix.target }} + + - name: Install cross (linux-arm64 only) + if: matrix.target == 'aarch64-unknown-linux-gnu' + # `cross` provides the aarch64 sysroot via Docker on the linux runner. + # napi-rs's CLI auto-detects `cross` for arm64 linux, so installing + # it is the only setup step we need. + run: cargo install cross --locked --version 0.2.5 + + - name: Cache cargo registry + target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml', 'rust-toolchain.toml') }} + restore-keys: | + cargo-${{ runner.os }}-${{ matrix.target }}- + cargo-${{ runner.os }}- + + - name: Install workspace deps + run: pnpm install --frozen-lockfile + + - name: Install packages/sdk-node deps + # `packages/sdk-node` is intentionally excluded from the pnpm + # workspace until the 2.0 cutover (see pnpm-workspace.yaml), so + # `pnpm install` at the root won't pull its devDependencies + # (`@napi-rs/cli`, `esbuild`). `--ignore-workspace` makes pnpm + # treat this directory as a standalone project and install into + # `packages/sdk-node/node_modules/` directly. + working-directory: packages/sdk-node + run: pnpm install --ignore-workspace --no-frozen-lockfile + + - name: Build napi binding for ${{ matrix.target }} + # `napi build` reads `packages/sdk-node/package.json`'s `napi` + # block to learn the binary name, then dispatches to `cargo build` + # under the hood. The output is a per-target `.node` file and a + # generated `binding.js` / `binding.d.ts` co-located with it. + # While #247-a is in flight the binding crate has no `#[napi]` + # exports yet, so this step may emit an empty addon — that's fine + # for the matrix-validation use case, and once the bindings land + # the same step produces a real binary without changes here. + working-directory: packages/sdk-node + run: | + pnpm exec napi build \ + --platform \ + --release \ + --target ${{ matrix.target }} \ + --js src/binding.js \ + --dts src/binding.d.ts + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: relayburn-sdk-${{ matrix.short }} + path: packages/sdk-node/*.node + if-no-files-found: warn + retention-days: 7 + + - name: Bundle smoke test (esbuild) + # Confirms the umbrella facade resolves + bundles cleanly. Runs on + # every matrix leg so we catch platform-specific resolution issues + # (path casing, native-binding aliases) close to where they'd + # surface for an embedder. + working-directory: packages/sdk-node + run: node --test 'test/esbuild-smoke.test.js' + + - name: Conformance test (skipped until #247-a binding lands) + # While #247-a is in flight the binding crate has no exports, so + # the conformance suite skips itself. Once those bindings land, + # flipping `RELAYBURN_SDK_NAPI_BUILT=1` here turns the skip into a + # real deep-equal gate against TS @relayburn/sdk@1.x. + working-directory: packages/sdk-node + env: + RELAYBURN_SDK_NAPI_BUILT: '0' + run: node --test 'test/conformance.test.js' + + publish: + # Stub. Real publish wiring lands in #249 (Wave 3 cutover) — at that + # point this job downloads the matrix artifacts, drops each into the + # right `npm//` directory, and runs `npm publish --tag=next` + # for the umbrella + each per-platform package via OIDC trusted + # publisher. Until then, calling `workflow_dispatch` with + # `publish: true` exercises the artifact download path so we catch + # wiring errors early without actually pushing to npm. + needs: build + if: github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup pnpm + uses: pnpm/action-setup@v5 + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22.14.0' + cache: 'pnpm' + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: packages/sdk-node/artifacts + + - name: Inspect artifacts (publish stub) + run: | + echo 'Publish step is currently a stub. Full wiring lands in #249.' + ls -lah packages/sdk-node/artifacts || true diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f180615..bcb125c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] +- `@relayburn/sdk` (npm 2.x): scaffold the `packages/sdk-node/` umbrella + four per-platform packages (`@relayburn/sdk-{darwin-arm64,darwin-x64,linux-arm64-gnu,linux-x64-gnu}`) resolved via `optionalDependencies`. Adds the napi-rs build matrix in `.github/workflows/napi-build.yml`, an esbuild bundle smoke test, and a deep-equal conformance test gate against the TS 1.x SDK across the six verbs (`ingest`, `summary`, `sessionCost`, `overhead`, `overheadTrim`, `hotspots`). Conformance test is skipped until `crates/relayburn-sdk-node` lands its bindings (#247 part a). (#247 part b) - `relayburn-ingest` (Rust): port the per-process gap-warning state machine (`gap` module — `record_session_gap`, `emit_gap_warning`, `count_tool_call_gaps`, `reset_ingest_gap_warnings`, `set_ingest_gap_writer`) and `reingest_missing_content` (`reingest` module). Suppression mirrors the TS surface: one warning per fresh affected session, silent on steady-state, re-fires after the affected set decays back to empty. `relayburn-ledger` adds `Ledger::list_user_turn_session_ids` to power the `reingest_missing_content` skip filter alongside `list_content_session_ids`. (#278) - `relayburn-analyze` (Rust): port the behavioral-pattern detectors (`patterns` module). `detect_patterns` runs retry-loop, failure-run, cancellation-run, compaction-loss, edit-revert, OpenCode skill-recall-dup, OpenCode skill-pruning-protection, OpenCode system-prompt-tax, and edit-heavy detectors against an ordered turn stream, with optional content-sidecar / tool-result-event / user-turn enrichment. Public surface: `detect_patterns`, `DetectPatternsOptions`; per-pattern result structs are re-exported from `findings` (`RetryLoop`, `FailureRun`, `CancellationRun`, `CompactionLoss`, `EditRevertCycle`, `SkillRecallDup`, `SkillPruningProtection`, `SystemPromptTax`, `EditHeavySession`, `SessionPatternSummary`, `PatternsResult`, `PatternEventSource`). (#275) - `relayburn-analyze` (Rust): port the tool-output-bloat detector — Signal A's `BASH_MAX_OUTPUT_LENGTH` static-config check (with `~/.claude/settings.json` + `/.claude/settings.json` loader) and Signal B's cross-harness observed-bloat aggregation, plus the `WasteFinding` adapter. Public surface mirrors `@relayburn/analyze`: `BASH_MAX_OUTPUT_ENV_KEY`, `DEFAULT_BLOAT_TOKEN_THRESHOLD`, `detect_observed_bloat`, `detect_static_config_bloat`, `detect_tool_output_bloat`, `load_claude_settings`, `project_claude_settings_path`, `user_claude_settings_path`, `tool_output_bloat_to_finding`. (#271) diff --git a/packages/sdk-node/CHANGELOG.md b/packages/sdk-node/CHANGELOG.md new file mode 100644 index 00000000..5edded4e --- /dev/null +++ b/packages/sdk-node/CHANGELOG.md @@ -0,0 +1,9 @@ +# @relayburn/sdk (2.x) + +## [Unreleased] + +- Initial scaffolding: umbrella package layout (`@relayburn/sdk`) + + per-platform packages (`@relayburn/sdk-{darwin-arm64,darwin-x64,linux-arm64-gnu,linux-x64-gnu}`) + resolved via `optionalDependencies`, TS facade re-exporting the napi-rs + binding, conformance scaffold against the TS 1.x SDK, esbuild bundle + smoke test. (#247 part b) diff --git a/packages/sdk-node/README.md b/packages/sdk-node/README.md new file mode 100644 index 00000000..a7d5c5df --- /dev/null +++ b/packages/sdk-node/README.md @@ -0,0 +1,36 @@ +# @relayburn/sdk (2.x) + +Embeddable Relayburn SDK — napi-rs bindings over the Rust `relayburn-sdk` +crate. Drop-in replacement for the TS `@relayburn/sdk@1.x` published from +`packages/sdk/`. + +The 2.x umbrella resolves the right native binary for your platform via +`optionalDependencies`: + +| Platform | Package | +|---|---| +| darwin-arm64 (Apple Silicon) | `@relayburn/sdk-darwin-arm64` | +| darwin-x64 (Intel Mac) | `@relayburn/sdk-darwin-x64` | +| linux-arm64-gnu | `@relayburn/sdk-linux-arm64-gnu` | +| linux-x64-gnu | `@relayburn/sdk-linux-x64-gnu` | + +Windows (`win32-x64-msvc`) is not yet shipped — see #247 follow-up. + +## Migration from 1.x + +Same imports, same option shapes, same return shapes — except: + +- **u64 token counts are `bigint`.** napi-rs maps Rust `u64` to JavaScript + `BigInt`. Code that does arithmetic on `summary().totalTokens` (and + similar fields on `hotspots`, `overhead`, `sessionCost`) needs to either + use `BigInt` literals (`100n`) or coerce with `Number(x)`. The TS + declarations widen these fields to `number | bigint` to keep existing + callers compiling. +- Otherwise byte-for-byte compatible. Run your test suite — the conformance + test in `test/conformance.test.js` is what we use to validate. + +## Status + +This is a `2.0.0-pre` build published to npm under the `next` tag while +the rest of the Rust port lands. Until the lockstep 2.0 cutover ships, the +1.x TS SDK at `packages/sdk/` is still the source of truth. diff --git a/packages/sdk-node/npm/darwin-arm64/README.md b/packages/sdk-node/npm/darwin-arm64/README.md new file mode 100644 index 00000000..fecb3588 --- /dev/null +++ b/packages/sdk-node/npm/darwin-arm64/README.md @@ -0,0 +1,7 @@ +# @relayburn/sdk-darwin-arm64 + +Prebuilt native binding for `@relayburn/sdk` on `darwin-arm64`. You should +not depend on this package directly — install `@relayburn/sdk` and the right +platform binding is resolved via `optionalDependencies`. + +See . diff --git a/packages/sdk-node/npm/darwin-arm64/package.json b/packages/sdk-node/npm/darwin-arm64/package.json new file mode 100644 index 00000000..5ac27f63 --- /dev/null +++ b/packages/sdk-node/npm/darwin-arm64/package.json @@ -0,0 +1,29 @@ +{ + "name": "@relayburn/sdk-darwin-arm64", + "version": "2.0.0-pre.0", + "description": "Prebuilt napi-rs binding for @relayburn/sdk on darwin-arm64.", + "main": "relayburn-sdk.darwin-arm64.node", + "files": [ + "relayburn-sdk.darwin-arm64.node", + "package.json", + "README.md" + ], + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "engines": { + "node": ">=22" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/burn", + "directory": "packages/sdk-node/npm/darwin-arm64" + }, + "publishConfig": { + "access": "public", + "tag": "next" + } +} diff --git a/packages/sdk-node/npm/darwin-x64/README.md b/packages/sdk-node/npm/darwin-x64/README.md new file mode 100644 index 00000000..9b937cf4 --- /dev/null +++ b/packages/sdk-node/npm/darwin-x64/README.md @@ -0,0 +1,7 @@ +# @relayburn/sdk-darwin-x64 + +Prebuilt native binding for `@relayburn/sdk` on `darwin-x64`. You should +not depend on this package directly — install `@relayburn/sdk` and the right +platform binding is resolved via `optionalDependencies`. + +See . diff --git a/packages/sdk-node/npm/darwin-x64/package.json b/packages/sdk-node/npm/darwin-x64/package.json new file mode 100644 index 00000000..f6d92532 --- /dev/null +++ b/packages/sdk-node/npm/darwin-x64/package.json @@ -0,0 +1,29 @@ +{ + "name": "@relayburn/sdk-darwin-x64", + "version": "2.0.0-pre.0", + "description": "Prebuilt napi-rs binding for @relayburn/sdk on darwin-x64.", + "main": "relayburn-sdk.darwin-x64.node", + "files": [ + "relayburn-sdk.darwin-x64.node", + "package.json", + "README.md" + ], + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "engines": { + "node": ">=22" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/burn", + "directory": "packages/sdk-node/npm/darwin-x64" + }, + "publishConfig": { + "access": "public", + "tag": "next" + } +} diff --git a/packages/sdk-node/npm/linux-arm64-gnu/README.md b/packages/sdk-node/npm/linux-arm64-gnu/README.md new file mode 100644 index 00000000..e770fe67 --- /dev/null +++ b/packages/sdk-node/npm/linux-arm64-gnu/README.md @@ -0,0 +1,7 @@ +# @relayburn/sdk-linux-arm64-gnu + +Prebuilt native binding for `@relayburn/sdk` on `linux-arm64-gnu` (glibc). +You should not depend on this package directly — install `@relayburn/sdk` +and the right platform binding is resolved via `optionalDependencies`. + +See . diff --git a/packages/sdk-node/npm/linux-arm64-gnu/package.json b/packages/sdk-node/npm/linux-arm64-gnu/package.json new file mode 100644 index 00000000..6260cc89 --- /dev/null +++ b/packages/sdk-node/npm/linux-arm64-gnu/package.json @@ -0,0 +1,32 @@ +{ + "name": "@relayburn/sdk-linux-arm64-gnu", + "version": "2.0.0-pre.0", + "description": "Prebuilt napi-rs binding for @relayburn/sdk on linux-arm64-gnu (glibc).", + "main": "relayburn-sdk.linux-arm64-gnu.node", + "files": [ + "relayburn-sdk.linux-arm64-gnu.node", + "package.json", + "README.md" + ], + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "engines": { + "node": ">=22" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/burn", + "directory": "packages/sdk-node/npm/linux-arm64-gnu" + }, + "publishConfig": { + "access": "public", + "tag": "next" + } +} diff --git a/packages/sdk-node/npm/linux-x64-gnu/README.md b/packages/sdk-node/npm/linux-x64-gnu/README.md new file mode 100644 index 00000000..4b35ff8d --- /dev/null +++ b/packages/sdk-node/npm/linux-x64-gnu/README.md @@ -0,0 +1,7 @@ +# @relayburn/sdk-linux-x64-gnu + +Prebuilt native binding for `@relayburn/sdk` on `linux-x64-gnu` (glibc). +You should not depend on this package directly — install `@relayburn/sdk` +and the right platform binding is resolved via `optionalDependencies`. + +See . diff --git a/packages/sdk-node/npm/linux-x64-gnu/package.json b/packages/sdk-node/npm/linux-x64-gnu/package.json new file mode 100644 index 00000000..3b3a2e68 --- /dev/null +++ b/packages/sdk-node/npm/linux-x64-gnu/package.json @@ -0,0 +1,32 @@ +{ + "name": "@relayburn/sdk-linux-x64-gnu", + "version": "2.0.0-pre.0", + "description": "Prebuilt napi-rs binding for @relayburn/sdk on linux-x64-gnu (glibc).", + "main": "relayburn-sdk.linux-x64-gnu.node", + "files": [ + "relayburn-sdk.linux-x64-gnu.node", + "package.json", + "README.md" + ], + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "engines": { + "node": ">=22" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/burn", + "directory": "packages/sdk-node/npm/linux-x64-gnu" + }, + "publishConfig": { + "access": "public", + "tag": "next" + } +} diff --git a/packages/sdk-node/package.json b/packages/sdk-node/package.json new file mode 100644 index 00000000..0a585e66 --- /dev/null +++ b/packages/sdk-node/package.json @@ -0,0 +1,64 @@ +{ + "name": "@relayburn/sdk", + "version": "2.0.0-pre.0", + "description": "Embeddable Relayburn SDK — napi-rs bindings over the Rust relayburn-sdk crate (2.x). Drop-in replacement for the TS @relayburn/sdk@1.x.", + "type": "module", + "main": "./src/index.js", + "types": "./src/index.d.ts", + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./src/index.js", + "require": "./src/index.cjs" + } + }, + "files": [ + "src/index.js", + "src/index.cjs", + "src/index.d.ts", + "src/binding.js", + "src/binding.d.ts", + "README.md", + "CHANGELOG.md", + "package.json" + ], + "scripts": { + "build": "node -e \"process.exit(0)\"", + "build:napi": "napi build --platform --release --js src/binding.js --dts src/binding.d.ts", + "build:napi:debug": "napi build --platform --js src/binding.js --dts src/binding.d.ts", + "test": "node --test 'test/**/*.test.js'", + "test:bundle": "node test/esbuild-smoke.test.js" + }, + "engines": { + "node": ">=22" + }, + "napi": { + "binaryName": "relayburn-sdk", + "packageName": "@relayburn/sdk", + "targets": [ + "x86_64-apple-darwin", + "aarch64-apple-darwin", + "x86_64-unknown-linux-gnu", + "aarch64-unknown-linux-gnu" + ] + }, + "optionalDependencies": { + "@relayburn/sdk-darwin-arm64": "2.0.0-pre.0", + "@relayburn/sdk-darwin-x64": "2.0.0-pre.0", + "@relayburn/sdk-linux-arm64-gnu": "2.0.0-pre.0", + "@relayburn/sdk-linux-x64-gnu": "2.0.0-pre.0" + }, + "devDependencies": { + "@napi-rs/cli": "^2.18.4", + "esbuild": "^0.25.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/AgentWorkforce/burn", + "directory": "packages/sdk-node" + }, + "publishConfig": { + "access": "public", + "tag": "next" + } +} diff --git a/packages/sdk-node/pnpm-lock.yaml b/packages/sdk-node/pnpm-lock.yaml new file mode 100644 index 00000000..335c04a4 --- /dev/null +++ b/packages/sdk-node/pnpm-lock.yaml @@ -0,0 +1,295 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@napi-rs/cli': + specifier: ^2.18.4 + version: 2.18.4 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@napi-rs/cli@2.18.4': + resolution: {integrity: sha512-SgJeA4df9DE2iAEpr3M2H0OKl/yjtg1BnRI5/JyowS71tUWhrfSu2LT0V3vlHET+g1hBVlrO60PmEXwUEKp8Mg==} + engines: {node: '>= 10'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@napi-rs/cli@2.18.4': {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 diff --git a/packages/sdk-node/src/binding.d.ts b/packages/sdk-node/src/binding.d.ts new file mode 100644 index 00000000..7c9b02e3 --- /dev/null +++ b/packages/sdk-node/src/binding.d.ts @@ -0,0 +1,21 @@ +// Generated by `napi build --dts` at publish time. This stub matches the +// expected exported function names from `crates/relayburn-sdk-node` (#247-a). +// Once the bindings land, `napi build` overwrites this file with the real +// generated declarations. +// +// The umbrella's `src/index.js` re-exports these directly. The runtime +// shape is the discriminated unions / option objects defined in +// `src/index.d.ts`; this file just declares "the binding exposes these +// names as functions." + +export declare class Ledger { + static open(opts?: { home?: string }): Promise; +} + +export declare function ingest(opts?: unknown): Promise; +export declare function summary(opts?: unknown): Promise; +export declare function sessionCost(opts?: unknown): Promise; +export declare function overhead(opts?: unknown): Promise; +export declare function overheadTrim(opts?: unknown): Promise; +export declare function hotspots(opts?: unknown): Promise; +export declare function compare(opts: unknown): Promise; diff --git a/packages/sdk-node/src/binding.js b/packages/sdk-node/src/binding.js new file mode 100644 index 00000000..83e7bad9 --- /dev/null +++ b/packages/sdk-node/src/binding.js @@ -0,0 +1,83 @@ +// Native-binding loader. At publish time, `napi build` (via `@napi-rs/cli`) +// regenerates this file to dispatch to the right per-platform package +// (`@relayburn/sdk-darwin-arm64`, `@relayburn/sdk-linux-x64-gnu`, etc.) based +// on `process.platform` + `process.arch` + libc detection. The generated +// version pulls the prebuilt `.node` file out of `optionalDependencies` so +// installs don't need a Rust toolchain. +// +// This stub matches the napi-rs-generated dispatcher *shape* so the umbrella +// package's TS facade (`src/index.js`) can import from it during local dev / +// CI conformance scaffolding before the prebuilt binaries exist. While +// #247-a is in flight, we throw a clear "binding not built" error instead of +// requiring `*.node` artifacts that don't exist yet. +// +// Once `napi build` runs in CI for the first time, this file is overwritten; +// see `.github/workflows/napi-build.yml`. + +const { existsSync, readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { platform, arch } = process; + +// Detect glibc vs musl on Linux. napi-rs generates this with `detect-libc` +// at build time; we keep a minimal fallback so `require('./binding.js')` +// doesn't crash when run before the binary build. +function isMusl() { + if (!process.report) return false; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { glibcVersionRuntime } = (process.report.getReport() || {}).header || {}; + return !glibcVersionRuntime; + } catch (_) { + return false; + } +} + +let nativeBinding = null; +let loadError = null; + +function tryRequire(specifier, localFile) { + // Prefer the optional-dep platform package; fall back to a sibling .node + // that `napi build --release` drops next to this loader during local dev. + const localPath = localFile ? join(__dirname, '..', localFile) : null; + if (localPath && existsSync(localPath)) { + try { + return require(localPath); + } catch (e) { + loadError = e; + } + } + try { + return require(specifier); + } catch (e) { + loadError = e; + return null; + } +} + +if (platform === 'darwin' && arch === 'arm64') { + nativeBinding = tryRequire('@relayburn/sdk-darwin-arm64', 'relayburn-sdk.darwin-arm64.node'); +} else if (platform === 'darwin' && arch === 'x64') { + nativeBinding = tryRequire('@relayburn/sdk-darwin-x64', 'relayburn-sdk.darwin-x64.node'); +} else if (platform === 'linux' && arch === 'arm64' && !isMusl()) { + nativeBinding = tryRequire('@relayburn/sdk-linux-arm64-gnu', 'relayburn-sdk.linux-arm64-gnu.node'); +} else if (platform === 'linux' && arch === 'x64' && !isMusl()) { + nativeBinding = tryRequire('@relayburn/sdk-linux-x64-gnu', 'relayburn-sdk.linux-x64-gnu.node'); +} + +if (!nativeBinding) { + // Surface a clear actionable error. While #247-a is still merging, this is + // the failure mode CI / dev machines will hit; the conformance test + // `test/conformance.test.js` checks for it and skips so the suite stays + // green until bindings land. + const detail = loadError + ? `\nUnderlying error: ${loadError.message}` + : ''; + throw new Error( + `@relayburn/sdk: native binding not found for ${platform}-${arch}.\n` + + `Expected one of @relayburn/sdk-{darwin-arm64,darwin-x64,linux-arm64-gnu,linux-x64-gnu} ` + + `to be installed via optionalDependencies, or a sibling .node prebuilt by ` + + `\`pnpm --filter @relayburn/sdk run build:napi\`.${detail}`, + ); +} + +module.exports = nativeBinding; diff --git a/packages/sdk-node/src/index.cjs b/packages/sdk-node/src/index.cjs new file mode 100644 index 00000000..553d8c6b --- /dev/null +++ b/packages/sdk-node/src/index.cjs @@ -0,0 +1,19 @@ +// CommonJS variant of the umbrella facade. Required for tools that resolve +// `require('@relayburn/sdk')` through CJS — the ESM `src/index.js` is the +// canonical entry point but pnpm / Node falls back to this when a consumer's +// package is `"type": "commonjs"` and the `exports.require` map is honored. + +'use strict'; + +const binding = require('./binding.js'); + +module.exports = { + Ledger: binding.Ledger, + ingest: (opts) => binding.ingest(opts), + summary: (opts) => binding.summary(opts), + sessionCost: (opts) => binding.sessionCost(opts), + overhead: (opts) => binding.overhead(opts), + overheadTrim: (opts) => binding.overheadTrim(opts), + hotspots: (opts) => binding.hotspots(opts), + compare: (opts) => binding.compare(opts), +}; diff --git a/packages/sdk-node/src/index.d.ts b/packages/sdk-node/src/index.d.ts new file mode 100644 index 00000000..ea4a1ea8 --- /dev/null +++ b/packages/sdk-node/src/index.d.ts @@ -0,0 +1,329 @@ +// Type surface for `@relayburn/sdk@2.x`. +// +// Mirrors `packages/sdk/index.d.ts` (the TS 1.x SDK) byte-for-byte modulo: +// - `bigint` is allowed alongside `number` for u64-typed token counts (the +// napi-rs binding emits `BigInt` for `u64`; the TS shape is widened so +// existing callers that pass through `number` keep type-checking once +// bound to the Rust impl). +// - Async fns return `Promise` — the napi-rs binding uses `async fn` +// where the Rust SDK does, which is everywhere except the `Ledger.open` +// constructor. +// +// Source-of-truth comment: track `packages/sdk/index.d.ts`. Whenever a verb +// shape changes in TS, mirror it here AND in the Rust napi-rs binding (#247-a). + +export interface LedgerOpenOptions { home?: string } +export declare class Ledger { static open(opts?: LedgerOpenOptions): Promise } + +export interface IngestOptions { sessionId?: string; harness?: 'claude-code'|'codex'|'opencode'; ledgerHome?: string } +export declare function ingest(opts?: IngestOptions): Promise + +export interface SummaryOptions { + session?: string; + project?: string; + /** ISO timestamp (e.g. `2026-04-01T00:00:00Z`) or relative range (`24h`, `7d`, `4w`, `2m`). */ + since?: string; + ledgerHome?: string; + /** Optional logger invoked when the SQLite archive read fails and the SDK falls back to a full ledger walk. */ + onLog?: (msg: string) => void; +} +export declare function summary(opts?: SummaryOptions): Promise<{ + totalTokens: number | bigint; + totalCost: number; + turnCount: number; + byTool: Array<{ tool: string; tokens: number | bigint; cost: number; count: number }>; + byModel: Array<{ model: string; tokens: number | bigint; cost: number }>; +}> + +export interface SessionCostOptions { + /** Session id to total. Omit for `{ note: 'no session id provided' }`. */ + session?: string; + ledgerHome?: string; + onLog?: (msg: string) => void; +} +export interface SessionCostResult { + sessionId: string | null; + totalUSD: number; + totalTokens: number | bigint; + turnCount: number; + models: string[]; + note?: string; +} +/** Compact session-scoped cost shape; powers the MCP `burn__sessionCost` tool. */ +export declare function sessionCost(opts?: SessionCostOptions): Promise + +export type OverheadFileKind = 'claude-md' | 'agents-md'; +export type OverheadHarness = 'claude-code' | 'codex' | 'opencode'; + +export interface OverheadOptions { + project?: string; + since?: string; + kind?: OverheadFileKind; + ledgerHome?: string; + onLog?: (msg: string) => void; +} + +export interface OverheadSection { + heading: string; + startLine: number; + endLine: number; + tokens: number | bigint; +} + +export interface OverheadSectionCost { + filePath: string; + section: OverheadSection; + tokenShare: number; + costPerSession: number; + totalCost: number; +} + +export interface OverheadAttributionDetail { + sessionCount: number; + perSessionAvg: number; + perSessionP95: number; + totalCost: number; + sectionCosts: OverheadSectionCost[]; +} + +export interface OverheadFileSummary { + kind: OverheadFileKind; + path: string; + appliesTo: OverheadHarness[]; + totalLines: number; + bytes: number | bigint; + tokens: number | bigint; + sections: OverheadSection[]; + groupingLevel: number; +} + +export interface OverheadPerFileEntry { + path: string; + kind: OverheadFileKind; + appliesTo: OverheadHarness[]; + attribution: OverheadAttributionDetail; +} + +export interface OverheadResult { + project: string; + files: OverheadFileSummary[]; + perFile: OverheadPerFileEntry[]; + grandTotal: number; +} + +/** Per-file + per-section overhead cost attribution. Powers `burn overhead`. */ +export declare function overhead(opts?: OverheadOptions): Promise + +export interface OverheadTrimOptions extends OverheadOptions { + top?: number; + includeDiff?: boolean; +} + +export interface OverheadTrimRecommendation { + file: string; + kind: OverheadFileKind; + appliesTo: OverheadHarness[]; + section: { heading: string; startLine: number; endLine: number; tokens: number | bigint }; + projectedSavings: { + perSessionUsd: number; + acrossWindowUsd: number; + tokens: number | bigint; + tokenShare: number; + }; + diff?: string; +} + +export interface OverheadTrimResult { + project: string; + since: string; + recommendations: OverheadTrimRecommendation[]; + summary: { + filesAnalyzed: number; + filesWithRecommendations: number; + totalRecommendations: number; + totalProjectedSavingsPerSession: number; + totalProjectedSavingsAcrossWindow: number; + }; +} + +/** Trim recommendations for high-cost overhead-file sections. Powers `burn overhead trim`. */ +export declare function overheadTrim(opts?: OverheadTrimOptions): Promise + +export type HotspotsGroupBy = 'attribution' | 'bash' | 'bash-verb' | 'file' | 'subagent'; + +export interface HotspotsOptions { + session?: string; + project?: string; + since?: string; + groupBy?: HotspotsGroupBy; + patterns?: string[]; + ledgerHome?: string; + onLog?: (msg: string) => void; +} + +export interface HotspotsFileRow { + path: string; + firstEmitTurnIndex: number; + initialTokens: number | bigint; + persistenceTokens: number | bigint; + ridingTurns: number; + totalCost: number; +} + +export interface HotspotsBashRow { + command: string | undefined; + argsHash: string; + callCount: number; + initialTokens: number | bigint; + persistenceTokens: number | bigint; + totalCost: number; +} + +export interface HotspotsBashVerbRow { + verb: string; + callCount: number; + distinctCommands: number; + initialTokens: number | bigint; + persistenceTokens: number | bigint; + avgPersistenceTurns: number; + totalCost: number; + topExamples: string[]; +} + +export interface HotspotsSubagentRow { + subagentType: string; + callCount: number; + initialTokens: number | bigint; + persistenceTokens: number | bigint; + totalCost: number; +} + +export interface HotspotsSessionTotal { + sessionId: string; + grandCost: number; + attributedCost: number; + unattributedCost: number; + attributionMethod: 'sized' | 'even-split'; +} + +export interface HotspotsFidelityBlock { + analyzed: number; + excluded: number; + summary: unknown; + refused: boolean; +} + +export interface HotspotsAttributionResult { + kind: 'attribution'; + turnsAnalyzed: number; + grandTotal: number; + attributedTotal: number; + unattributedTotal: number; + attributionDegraded: boolean; + sessions: HotspotsSessionTotal[]; + files: HotspotsFileRow[]; + bashVerbs: HotspotsBashVerbRow[]; + bash: HotspotsBashRow[]; + subagents: HotspotsSubagentRow[]; + fidelity: HotspotsFidelityBlock; + refused?: boolean; + refusalReason?: string; +} + +export interface HotspotsBashResult { kind: 'bash'; rows: HotspotsBashRow[]; refused?: boolean; refusalReason?: string } +export interface HotspotsBashVerbResult { kind: 'bash-verb'; rows: HotspotsBashVerbRow[]; refused?: boolean; refusalReason?: string } +export interface HotspotsFileResult { kind: 'file'; rows: HotspotsFileRow[]; refused?: boolean; refusalReason?: string } +export interface HotspotsSubagentResult { kind: 'subagent'; rows: HotspotsSubagentRow[]; refused?: boolean; refusalReason?: string } + +export interface HotspotsFinding { + kind: string; + severity: string; + sessionId: string; + title: string; + estimatedSavings: { usdPerSession?: number; [k: string]: unknown }; + [k: string]: unknown; +} + +export interface HotspotsFindingsResult { + kind: 'findings'; + findings: HotspotsFinding[]; + summary: unknown; +} + +export type HotspotsResult = + | HotspotsAttributionResult + | HotspotsBashResult + | HotspotsBashVerbResult + | HotspotsFileResult + | HotspotsSubagentResult + | HotspotsFindingsResult; + +/** + * Per-axis hotspot attribution + pattern-finding queries. Returns a + * discriminated union — see `HotspotsResult`. + */ +export declare function hotspots(opts?: HotspotsOptions): Promise + +export type FidelityClass = 'full' | 'usage-only' | 'aggregate-only' | 'cost-only' | 'partial'; + +export interface FidelitySummaryShape { + total: number; + byClass: Record; + unknown: number; + missingCoverage: Record; +} + +export interface CompareExcludedBreakdown { + total: number; + aggregateOnly: number; + costOnly: number; + partial: number; + usageOnly: number; +} + +export interface CompareCellResult { + model: string; + category: string; + turns: number; + editTurns: number; + oneShotTurns: number; + pricedTurns: number; + totalCost: number; + costPerTurn: number | null; + oneShotRate: number | null; + cacheHitRate: number | null; + medianRetries: number | null; + noData: boolean; + insufficientSample: boolean; +} + +export interface CompareOptions { + models: string[]; + session?: string; + project?: string; + since?: string; + workflow?: string; + agent?: string; + provider?: string[]; + minSample?: number; + minFidelity?: FidelityClass; + ledgerHome?: string; + onLog?: (msg: string) => void; +} + +export interface CompareResult { + analyzedTurns: number; + minSample: number; + models: string[]; + categories: string[]; + totals: Record; + cells: CompareCellResult[]; + fidelity: { + minimum: FidelityClass; + excluded: CompareExcludedBreakdown; + summary: FidelitySummaryShape; + }; +} + +/** Per-(model, activity) comparison shape. Powers `burn compare`. */ +export declare function compare(opts: CompareOptions): Promise diff --git a/packages/sdk-node/src/index.js b/packages/sdk-node/src/index.js new file mode 100644 index 00000000..78c8bd6c --- /dev/null +++ b/packages/sdk-node/src/index.js @@ -0,0 +1,44 @@ +// Thin ESM facade over the napi-rs binding. No behavior — every function +// here re-exports the matching `#[napi]` export from the platform package +// resolved by `./binding.js`. +// +// All query / compute logic lives in the Rust SDK (`crates/relayburn-sdk`); +// the binding crate (`crates/relayburn-sdk-node`) wraps it for napi-rs. +// This file exists so the published TS surface stays identical to the +// 1.x SDK (`packages/sdk/index.js`) — same import names, same option shapes, +// same return types — while the runtime is Rust. + +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const binding = require('./binding.js'); + +export const Ledger = binding.Ledger; + +export function ingest(opts) { + return binding.ingest(opts); +} + +export function summary(opts) { + return binding.summary(opts); +} + +export function sessionCost(opts) { + return binding.sessionCost(opts); +} + +export function overhead(opts) { + return binding.overhead(opts); +} + +export function overheadTrim(opts) { + return binding.overheadTrim(opts); +} + +export function hotspots(opts) { + return binding.hotspots(opts); +} + +export function compare(opts) { + return binding.compare(opts); +} diff --git a/packages/sdk-node/test/conformance.test.js b/packages/sdk-node/test/conformance.test.js new file mode 100644 index 00000000..403bfb87 --- /dev/null +++ b/packages/sdk-node/test/conformance.test.js @@ -0,0 +1,132 @@ +// Conformance test: TS @relayburn/sdk@1.x vs napi-rs @relayburn/sdk@2.0.0-pre. +// +// For each of the 6 verbs (`ingest`, `summary`, `sessionCost`, `overhead`, +// `overheadTrim`, `hotspots`), run both implementations against the existing +// fixture ledger and assert `deepStrictEqual` on the return value. This is +// the gate that says "the Rust port is behavior-equivalent to the TS port" +// — it's the test #247 cites as the acceptance criterion. +// +// **Status (2026-05-06): scaffolded but skipped.** The napi-rs bindings +// land in #247-a (parallel agent worktree). Until that crate is published +// and the umbrella's `src/binding.js` resolves a real native package, the +// `loadNapiSdk()` helper below throws and the suite skips. Flip the +// `RELAYBURN_SDK_NAPI_BUILT=1` env var (CI sets this once #247-a's bindings +// produce a valid `*.node` artifact) to enable the comparison. +// +// What's stubbed and why: +// - The local `@relayburn/sdk` npm package in this directory is the 2.x +// umbrella; its facade resolves the binding lazily on import. So +// `await import('@relayburn/sdk')` succeeds, but the first verb call +// throws "binding not found" until #247-a ships. We catch that +// specific error and skip rather than fail. +// - We fix `RELAYBURN_HOME` to a tmp dir for both runs so they share +// state. The TS 1.x `@relayburn/sdk` is loaded via a relative file: +// reference (`packages/sdk`) to avoid name collision with the 2.x +// umbrella in this package's `node_modules`. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, cpSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, '../../..'); + +// Fixture ledger location. CI seeds `tests/fixtures/ledger/` from the +// reader corpus during the `prepare-fixture-ledger` step (kept simple here: +// the fixture ledger is built on demand if missing). +const FIXTURE_LEDGER_DIR = join(REPO_ROOT, 'tests', 'fixtures', 'ledger'); + +const NAPI_READY = process.env.RELAYBURN_SDK_NAPI_BUILT === '1'; + +async function loadTsSdk() { + // The 1.x SDK lives at `packages/sdk` — load it via a fully-resolved + // relative path so we don't accidentally resolve back into the 2.x + // umbrella we're testing from. + const tsSdkPath = join(REPO_ROOT, 'packages', 'sdk', 'index.js'); + return import(tsSdkPath); +} + +async function loadNapiSdk() { + // Resolve the in-tree umbrella facade. Throws if the binding is missing — + // the per-test guard below converts that to a skip so the suite stays + // green while #247-a is in flight. + const napiSdkPath = join(__dirname, '..', 'src', 'index.js'); + return import(napiSdkPath); +} + +function bindingMissing(err) { + return /native binding not found/i.test(String(err && err.message)); +} + +function makeHome() { + const home = mkdtempSync(join(tmpdir(), 'relayburn-conformance-')); + if (existsSync(FIXTURE_LEDGER_DIR)) { + cpSync(FIXTURE_LEDGER_DIR, home, { recursive: true }); + } + return home; +} + +async function callBoth(verb, opts) { + const ts = await loadTsSdk(); + const napi = await loadNapiSdk(); + const tsHome = makeHome(); + const napiHome = makeHome(); + try { + const tsResult = await ts[verb]({ ...opts, ledgerHome: tsHome }); + let napiResult; + try { + napiResult = await napi[verb]({ ...opts, ledgerHome: napiHome }); + } catch (err) { + if (bindingMissing(err)) return { skipped: true }; + throw err; + } + return { tsResult, napiResult }; + } finally { + rmSync(tsHome, { recursive: true, force: true }); + rmSync(napiHome, { recursive: true, force: true }); + } +} + +const VERBS = [ + { name: 'summary', opts: {} }, + { name: 'sessionCost', opts: { session: 'fixture-session-1' } }, + { name: 'overhead', opts: { project: REPO_ROOT } }, + { name: 'overheadTrim', opts: { project: REPO_ROOT, includeDiff: false } }, + { name: 'hotspots', opts: {} }, + // ingest is exercised separately because both impls mutate the ledger; + // we run it last in its own block. +]; + +for (const { name, opts } of VERBS) { + test(`conformance: ${name}() matches TS 1.x`, async (t) => { + if (!NAPI_READY) { + t.skip('napi-rs binding not built — set RELAYBURN_SDK_NAPI_BUILT=1 once #247-a lands'); + return; + } + const out = await callBoth(name, opts); + if (out.skipped) { + t.skip('napi-rs binding load failed — #247-a binding artifact missing'); + return; + } + assert.deepStrictEqual(out.napiResult, out.tsResult); + }); +} + +test('conformance: ingest() matches TS 1.x', async (t) => { + if (!NAPI_READY) { + t.skip('napi-rs binding not built — set RELAYBURN_SDK_NAPI_BUILT=1 once #247-a lands'); + return; + } + // ingest mutates the ledger, so we deep-equal the *returned report* + // and additionally compare a follow-up summary() to confirm both + // implementations wrote the same rows. + const out = await callBoth('ingest', {}); + if (out.skipped) { + t.skip('napi-rs binding load failed — #247-a binding artifact missing'); + return; + } + assert.deepStrictEqual(out.napiResult, out.tsResult); +}); diff --git a/packages/sdk-node/test/esbuild-smoke.test.js b/packages/sdk-node/test/esbuild-smoke.test.js new file mode 100644 index 00000000..dd76fce0 --- /dev/null +++ b/packages/sdk-node/test/esbuild-smoke.test.js @@ -0,0 +1,96 @@ +// esbuild bundle smoke test — confirms the umbrella's TS facade bundles +// cleanly when an embedder pulls `@relayburn/sdk` into a downstream build. +// +// Strategy: +// 1. Write a tiny fixture script that imports the umbrella (named +// exports — `summary`, `ingest`, etc.) and references each verb. +// 2. Run esbuild with the same options a real consumer would (Node ESM +// target, externals for native + Node builtins). +// 3. Assert exit code 0 and no error output. We do *not* execute the +// bundle — that requires a real native binding (#247-a). The success +// condition here is "esbuild can resolve and bundle the facade" +// since that's where 99 % of bundling regressions surface. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SDK_NODE_ROOT = resolve(__dirname, '..'); + +const FIXTURE_SOURCE = ` +import { + Ledger, + ingest, + summary, + sessionCost, + overhead, + overheadTrim, + hotspots, + compare, +} from '@relayburn/sdk'; + +// Reference each export so esbuild can't tree-shake them away — keeps the +// smoke test honest about what's actually reachable through the facade. +export const refs = { + Ledger, + ingest, + summary, + sessionCost, + overhead, + overheadTrim, + hotspots, + compare, +}; +`; + +test('esbuild bundles the @relayburn/sdk umbrella facade cleanly', async (t) => { + // Prefer the locally hoisted esbuild (devDep on this package) so the test + // works without a global install. Skip if it's not yet on disk — the + // workspace install during CI puts it there. + let esbuild; + try { + esbuild = await import('esbuild'); + } catch (_) { + t.skip('esbuild not installed — run `pnpm install` first'); + return; + } + + const work = mkdtempSync(join(tmpdir(), 'relayburn-sdk-bundle-')); + try { + const fixturePath = join(work, 'entry.js'); + writeFileSync(fixturePath, FIXTURE_SOURCE); + + const outFile = join(work, 'bundle.mjs'); + const result = await esbuild.build({ + entryPoints: [fixturePath], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node22', + outfile: outFile, + // The native .node addon and Node builtins should stay external — that + // mirrors what a downstream embedder's bundler config would do. + external: [ + '@relayburn/sdk-darwin-arm64', + '@relayburn/sdk-darwin-x64', + '@relayburn/sdk-linux-arm64-gnu', + '@relayburn/sdk-linux-x64-gnu', + ], + // Resolve `@relayburn/sdk` to the in-tree umbrella, bypassing the + // possible 1.x SDK in workspace `node_modules`. + alias: { + '@relayburn/sdk': resolve(SDK_NODE_ROOT, 'src/index.js'), + }, + logLevel: 'silent', + }); + + assert.equal(result.errors.length, 0, `esbuild errors: ${JSON.stringify(result.errors)}`); + assert.equal(result.warnings.length, 0, `esbuild warnings: ${JSON.stringify(result.warnings)}`); + } finally { + rmSync(work, { recursive: true, force: true }); + } +}); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 924b55f4..3a7c5b0f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,11 @@ packages: - packages/* + # Exclude `packages/sdk-node` until the 2.0 cutover (#249). It declares + # `@relayburn/sdk` at version `2.0.0-pre.x`, which pnpm would prefer + # over the 1.x package at `packages/sdk` when resolving `workspace:*` + # dependencies in `cli` / `mcp` — flipping every consumer onto the + # not-yet-built napi-rs umbrella mid-port. Keeping it out of the + # workspace means the 1.x SDK stays the source of truth for in-repo + # consumers; the napi-rs CI matrix and conformance test scaffolding + # operate on `packages/sdk-node` directly via path. + - '!packages/sdk-node' From 7801bd91e7dc6d036ed707a0725af4dc11245781 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 6 May 2026 08:26:24 -0400 Subject: [PATCH 2/5] sdk-node: tighten conformance gate + add compare() row (review fixes) Address review feedback on PR #308: - Fail conformance fast when `RELAYBURN_SDK_NAPI_BUILT=1` is set but the fixture ledger at `tests/fixtures/ledger/` is missing. Previously the test silently fell back to an empty temp home, which would let the deep-equality checks pass on near-empty outputs once the gate was enabled. - Add `compare()` to the conformance verb matrix so behavioral drift in the per-(model, activity) shape is caught alongside the other read verbs. --- packages/sdk-node/test/conformance.test.js | 53 ++++++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/packages/sdk-node/test/conformance.test.js b/packages/sdk-node/test/conformance.test.js index 403bfb87..5858172d 100644 --- a/packages/sdk-node/test/conformance.test.js +++ b/packages/sdk-node/test/conformance.test.js @@ -1,10 +1,10 @@ // Conformance test: TS @relayburn/sdk@1.x vs napi-rs @relayburn/sdk@2.0.0-pre. // -// For each of the 6 verbs (`ingest`, `summary`, `sessionCost`, `overhead`, -// `overheadTrim`, `hotspots`), run both implementations against the existing -// fixture ledger and assert `deepStrictEqual` on the return value. This is -// the gate that says "the Rust port is behavior-equivalent to the TS port" -// — it's the test #247 cites as the acceptance criterion. +// For each of the 7 verbs (`ingest`, `summary`, `sessionCost`, `overhead`, +// `overheadTrim`, `hotspots`, `compare`), run both implementations against +// the existing fixture ledger and assert `deepStrictEqual` on the return +// value. This is the gate that says "the Rust port is behavior-equivalent +// to the TS port" — it's the test #247 cites as the acceptance criterion. // // **Status (2026-05-06): scaffolded but skipped.** The napi-rs bindings // land in #247-a (parallel agent worktree). Until that crate is published @@ -35,8 +35,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '../../..'); // Fixture ledger location. CI seeds `tests/fixtures/ledger/` from the -// reader corpus during the `prepare-fixture-ledger` step (kept simple here: -// the fixture ledger is built on demand if missing). +// reader corpus during the `prepare-fixture-ledger` step. When +// `RELAYBURN_SDK_NAPI_BUILT=1` is set this directory MUST exist — +// `ensureFixtureLedger()` below throws if it's missing so the gate can't +// silently pass on empty homes. const FIXTURE_LEDGER_DIR = join(REPO_ROOT, 'tests', 'fixtures', 'ledger'); const NAPI_READY = process.env.RELAYBURN_SDK_NAPI_BUILT === '1'; @@ -61,15 +63,34 @@ function bindingMissing(err) { return /native binding not found/i.test(String(err && err.message)); } +// When the gate is on (`RELAYBURN_SDK_NAPI_BUILT=1`) the fixture ledger MUST +// exist — otherwise both implementations would compare against empty homes +// and the deep-equality checks would tautologically pass. Throwing here turns +// "fixture missing" into a loud failure instead of a silent green. +// +// The fixture lives at `tests/fixtures/ledger/` and should mirror the on-disk +// shape of `~/.relayburn/` (typically a `ledger.jsonl` plus `content/` +// sidecar). CI's `prepare-fixture-ledger` step seeds it from the reader +// corpus before flipping the gate. +function ensureFixtureLedger() { + if (!existsSync(FIXTURE_LEDGER_DIR)) { + throw new Error( + `conformance fixture ledger missing at ${FIXTURE_LEDGER_DIR}. ` + + `Seed it (e.g. cp -R ~/.relayburn tests/fixtures/ledger) before ` + + `running with RELAYBURN_SDK_NAPI_BUILT=1, or unset the env var to ` + + `skip the conformance suite.`, + ); + } +} + function makeHome() { const home = mkdtempSync(join(tmpdir(), 'relayburn-conformance-')); - if (existsSync(FIXTURE_LEDGER_DIR)) { - cpSync(FIXTURE_LEDGER_DIR, home, { recursive: true }); - } + cpSync(FIXTURE_LEDGER_DIR, home, { recursive: true }); return home; } async function callBoth(verb, opts) { + ensureFixtureLedger(); const ts = await loadTsSdk(); const napi = await loadNapiSdk(); const tsHome = makeHome(); @@ -96,6 +117,18 @@ const VERBS = [ { name: 'overhead', opts: { project: REPO_ROOT } }, { name: 'overheadTrim', opts: { project: REPO_ROOT, includeDiff: false } }, { name: 'hotspots', opts: {} }, + // compare() requires >=2 models; pick a stable pair that may or may not + // appear in the fixture — both implementations see the same input so + // missing models surface as identical empty/no-data rows on each side. + // The deep-equality check still catches drift in cell shape, fidelity + // accounting, and totals. + { + name: 'compare', + opts: { + models: ['claude-sonnet-4-5', 'claude-opus-4-7'], + minFidelity: 'partial', + }, + }, // ingest is exercised separately because both impls mutate the ledger; // we run it last in its own block. ]; From b1d29956429306f290542816275b46a60d0429df Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 6 May 2026 08:34:59 -0400 Subject: [PATCH 3/5] sdk-node: binding.cjs + macos-15-intel runner + accurate test comment (review fixes round 2) - Rename src/binding.js to src/binding.cjs so Node can require it under the package's "type": "module" setting (ESM would reject module.exports on a bare .js). Update src/index.{js,cjs} requires, the package.json files array + build:napi script, and the napi-build workflow's --js flag to match. Add a binding.cjs header note explaining the extension choice so a future napi build regen doesn't accidentally flip back to .js. - Bump the x86_64-apple-darwin matrix runner from macos-13 (retired Dec 2025) to macos-15-intel. - Fix the ingest() conformance test comment: the "follow-up summary() comparison" was aspirational, not implemented; reword as a forward-looking note instead of describing absent behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/napi-build.yml | 6 +++--- packages/sdk-node/package.json | 6 +++--- packages/sdk-node/src/{binding.js => binding.cjs} | 10 +++++++++- packages/sdk-node/src/index.cjs | 2 +- packages/sdk-node/src/index.js | 4 ++-- packages/sdk-node/test/conformance.test.js | 9 +++++---- 6 files changed, 23 insertions(+), 14 deletions(-) rename packages/sdk-node/src/{binding.js => binding.cjs} (87%) diff --git a/.github/workflows/napi-build.yml b/.github/workflows/napi-build.yml index 087a7b1f..003ca3c0 100644 --- a/.github/workflows/napi-build.yml +++ b/.github/workflows/napi-build.yml @@ -50,7 +50,7 @@ jobs: matrix: include: - target: x86_64-apple-darwin - os: macos-13 + os: macos-15-intel short: darwin-x64 - target: aarch64-apple-darwin os: macos-14 @@ -120,7 +120,7 @@ jobs: # `napi build` reads `packages/sdk-node/package.json`'s `napi` # block to learn the binary name, then dispatches to `cargo build` # under the hood. The output is a per-target `.node` file and a - # generated `binding.js` / `binding.d.ts` co-located with it. + # generated `binding.cjs` / `binding.d.ts` co-located with it. # While #247-a is in flight the binding crate has no `#[napi]` # exports yet, so this step may emit an empty addon — that's fine # for the matrix-validation use case, and once the bindings land @@ -131,7 +131,7 @@ jobs: --platform \ --release \ --target ${{ matrix.target }} \ - --js src/binding.js \ + --js src/binding.cjs \ --dts src/binding.d.ts - name: Upload artifact diff --git a/packages/sdk-node/package.json b/packages/sdk-node/package.json index 0a585e66..dd02da1d 100644 --- a/packages/sdk-node/package.json +++ b/packages/sdk-node/package.json @@ -16,7 +16,7 @@ "src/index.js", "src/index.cjs", "src/index.d.ts", - "src/binding.js", + "src/binding.cjs", "src/binding.d.ts", "README.md", "CHANGELOG.md", @@ -24,8 +24,8 @@ ], "scripts": { "build": "node -e \"process.exit(0)\"", - "build:napi": "napi build --platform --release --js src/binding.js --dts src/binding.d.ts", - "build:napi:debug": "napi build --platform --js src/binding.js --dts src/binding.d.ts", + "build:napi": "napi build --platform --release --js src/binding.cjs --dts src/binding.d.ts", + "build:napi:debug": "napi build --platform --js src/binding.cjs --dts src/binding.d.ts", "test": "node --test 'test/**/*.test.js'", "test:bundle": "node test/esbuild-smoke.test.js" }, diff --git a/packages/sdk-node/src/binding.js b/packages/sdk-node/src/binding.cjs similarity index 87% rename from packages/sdk-node/src/binding.js rename to packages/sdk-node/src/binding.cjs index 83e7bad9..61afac5d 100644 --- a/packages/sdk-node/src/binding.js +++ b/packages/sdk-node/src/binding.cjs @@ -5,6 +5,14 @@ // version pulls the prebuilt `.node` file out of `optionalDependencies` so // installs don't need a Rust toolchain. // +// **File extension note:** this file is `.cjs` (not `.js`) because the +// umbrella package is `"type": "module"`, which would make Node treat a +// bare `.js` as ESM and reject the `module.exports` below at load time. +// `napi build` is invoked with `--js src/binding.cjs` (see +// `package.json` scripts + `.github/workflows/napi-build.yml`) so the +// regeneration writes back to the `.cjs` path; both `src/index.js` +// (ESM facade) and `src/index.cjs` (CJS facade) `require('./binding.cjs')`. +// // This stub matches the napi-rs-generated dispatcher *shape* so the umbrella // package's TS facade (`src/index.js`) can import from it during local dev / // CI conformance scaffolding before the prebuilt binaries exist. While @@ -19,7 +27,7 @@ const { join } = require('node:path'); const { platform, arch } = process; // Detect glibc vs musl on Linux. napi-rs generates this with `detect-libc` -// at build time; we keep a minimal fallback so `require('./binding.js')` +// at build time; we keep a minimal fallback so `require('./binding.cjs')` // doesn't crash when run before the binary build. function isMusl() { if (!process.report) return false; diff --git a/packages/sdk-node/src/index.cjs b/packages/sdk-node/src/index.cjs index 553d8c6b..ecc01078 100644 --- a/packages/sdk-node/src/index.cjs +++ b/packages/sdk-node/src/index.cjs @@ -5,7 +5,7 @@ 'use strict'; -const binding = require('./binding.js'); +const binding = require('./binding.cjs'); module.exports = { Ledger: binding.Ledger, diff --git a/packages/sdk-node/src/index.js b/packages/sdk-node/src/index.js index 78c8bd6c..c54a92bb 100644 --- a/packages/sdk-node/src/index.js +++ b/packages/sdk-node/src/index.js @@ -1,6 +1,6 @@ // Thin ESM facade over the napi-rs binding. No behavior — every function // here re-exports the matching `#[napi]` export from the platform package -// resolved by `./binding.js`. +// resolved by `./binding.cjs`. // // All query / compute logic lives in the Rust SDK (`crates/relayburn-sdk`); // the binding crate (`crates/relayburn-sdk-node`) wraps it for napi-rs. @@ -11,7 +11,7 @@ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); -const binding = require('./binding.js'); +const binding = require('./binding.cjs'); export const Ledger = binding.Ledger; diff --git a/packages/sdk-node/test/conformance.test.js b/packages/sdk-node/test/conformance.test.js index 5858172d..4bbb5be5 100644 --- a/packages/sdk-node/test/conformance.test.js +++ b/packages/sdk-node/test/conformance.test.js @@ -8,7 +8,7 @@ // // **Status (2026-05-06): scaffolded but skipped.** The napi-rs bindings // land in #247-a (parallel agent worktree). Until that crate is published -// and the umbrella's `src/binding.js` resolves a real native package, the +// and the umbrella's `src/binding.cjs` resolves a real native package, the // `loadNapiSdk()` helper below throws and the suite skips. Flip the // `RELAYBURN_SDK_NAPI_BUILT=1` env var (CI sets this once #247-a's bindings // produce a valid `*.node` artifact) to enable the comparison. @@ -153,9 +153,10 @@ test('conformance: ingest() matches TS 1.x', async (t) => { t.skip('napi-rs binding not built — set RELAYBURN_SDK_NAPI_BUILT=1 once #247-a lands'); return; } - // ingest mutates the ledger, so we deep-equal the *returned report* - // and additionally compare a follow-up summary() to confirm both - // implementations wrote the same rows. + // ingest mutates the ledger, so we deep-equal the *returned report*. + // Once the napi binding is wired up we may also want to compare a + // follow-up summary() across both homes to confirm the two + // implementations wrote the same rows; that's tracked separately. const out = await callBoth('ingest', {}); if (out.skipped) { t.skip('napi-rs binding load failed — #247-a binding artifact missing'); From ecd1a1f7a1fd652fd09ddb737cdca4a43cfcb8f5 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 6 May 2026 08:41:54 -0400 Subject: [PATCH 4/5] sdk-node: tighten conformance fixture preconditions (review fixes round 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend ensureFixtureLedger() to fail closed on malformed fixtures, not just missing directories. The previous check (existsSync of the dir) let an empty or wrongly-copied fixture pass: both impls would then deep-equal identical empty outputs, tautologically green. New preconditions, all with seed-hint messages: 1. ledger.jsonl exists (canonical filename per packages/ledger/src/paths.ts). 2. ledger.jsonl is non-empty. 3. First line parses as JSON and matches LedgerLine shape (v:1 + known kind). 4. At least one kind:'turn' line is present so summary/hotspots/compare have non-trivial input on both sides. Manually exercised each failure path with RELAYBURN_SDK_NAPI_BUILT=1 against (a) empty dir, (b) empty file, (c) non-JSON content, and (d) stamp-only fixture — each produced a distinct, actionable error. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/sdk-node/test/conformance.test.js | 122 +++++++++++++++++++-- 1 file changed, 111 insertions(+), 11 deletions(-) diff --git a/packages/sdk-node/test/conformance.test.js b/packages/sdk-node/test/conformance.test.js index 4bbb5be5..042291d9 100644 --- a/packages/sdk-node/test/conformance.test.js +++ b/packages/sdk-node/test/conformance.test.js @@ -26,7 +26,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, cpSync, existsSync } from 'node:fs'; +import { mkdtempSync, rmSync, cpSync, existsSync, readFileSync, statSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -64,21 +64,121 @@ function bindingMissing(err) { } // When the gate is on (`RELAYBURN_SDK_NAPI_BUILT=1`) the fixture ledger MUST -// exist — otherwise both implementations would compare against empty homes -// and the deep-equality checks would tautologically pass. Throwing here turns -// "fixture missing" into a loud failure instead of a silent green. +// exist AND be well-formed enough to actually exercise the verbs — otherwise +// both implementations would compare against empty/garbage homes and the +// deep-equality checks would tautologically pass on noise (both sides see the +// same nothing). Throwing here turns "fixture missing or malformed" into a +// loud failure instead of a silent green. // -// The fixture lives at `tests/fixtures/ledger/` and should mirror the on-disk -// shape of `~/.relayburn/` (typically a `ledger.jsonl` plus `content/` -// sidecar). CI's `prepare-fixture-ledger` step seeds it from the reader -// corpus before flipping the gate. +// The fixture lives at `tests/fixtures/ledger/` and mirrors the on-disk shape +// of `~/.relayburn/`: a canonical `ledger.jsonl` (see +// `packages/ledger/src/paths.ts::ledgerPath`) plus a `content/` sidecar. CI's +// `prepare-fixture-ledger` step seeds it from the reader corpus before +// flipping the gate. +// +// Preconditions checked: +// 1. The fixture directory exists. +// 2. `ledger.jsonl` exists and is non-empty. +// 3. The first line of `ledger.jsonl` parses as JSON and matches the +// `LedgerLine` shape (`v: 1` + `kind: `) defined in +// `packages/ledger/src/schema.ts`. This catches the easy "I copied the +// wrong thing" failure mode (e.g. a stray text file) without doing a +// full schema sweep. +// 4. At least one `kind: 'turn'` line is present anywhere in the file — +// verbs like `summary` / `hotspots` / `compare` need turns to produce +// non-trivial output, so a stamp-only fixture would still let the +// conformance gate pass on empty rows. +const KNOWN_LEDGER_KINDS = new Set([ + 'turn', + 'stamp', + 'compaction', + 'relationship', + 'tool_result_event', + 'user_turn', +]); + +function fixtureSeedHint() { + return ( + `Seed it (e.g. cp -R ~/.relayburn tests/fixtures/ledger) before ` + + `running with RELAYBURN_SDK_NAPI_BUILT=1, or unset the env var to ` + + `skip the conformance suite.` + ); +} + function ensureFixtureLedger() { if (!existsSync(FIXTURE_LEDGER_DIR)) { throw new Error( `conformance fixture ledger missing at ${FIXTURE_LEDGER_DIR}. ` + - `Seed it (e.g. cp -R ~/.relayburn tests/fixtures/ledger) before ` + - `running with RELAYBURN_SDK_NAPI_BUILT=1, or unset the env var to ` + - `skip the conformance suite.`, + fixtureSeedHint(), + ); + } + const ledgerJsonl = join(FIXTURE_LEDGER_DIR, 'ledger.jsonl'); + if (!existsSync(ledgerJsonl)) { + throw new Error( + `conformance fixture ledger malformed: expected ${ledgerJsonl} ` + + `(canonical ledger filename per packages/ledger/src/paths.ts). ` + + fixtureSeedHint(), + ); + } + if (statSync(ledgerJsonl).size === 0) { + throw new Error( + `conformance fixture ledger malformed: ${ledgerJsonl} is empty. ` + + fixtureSeedHint(), + ); + } + // Cheap sanity sweep: confirm the file looks like a JSONL ledger and + // contains at least one turn line. We read the whole file because real + // fixtures are small (kilobytes); if that ever stops being true, switch to + // a streaming scan. + const lines = readFileSync(ledgerJsonl, 'utf8').split('\n').filter((l) => l.length > 0); + if (lines.length === 0) { + throw new Error( + `conformance fixture ledger malformed: ${ledgerJsonl} has no JSONL ` + + `lines. ` + + fixtureSeedHint(), + ); + } + let firstLine; + try { + firstLine = JSON.parse(lines[0]); + } catch (err) { + throw new Error( + `conformance fixture ledger malformed: ${ledgerJsonl} first line is ` + + `not valid JSON (${err && err.message}). ` + + fixtureSeedHint(), + ); + } + if ( + !firstLine || + typeof firstLine !== 'object' || + firstLine.v !== 1 || + typeof firstLine.kind !== 'string' || + !KNOWN_LEDGER_KINDS.has(firstLine.kind) + ) { + throw new Error( + `conformance fixture ledger malformed: ${ledgerJsonl} first line ` + + `does not match the LedgerLine shape (expected v:1 + kind:, ` + + `got ${JSON.stringify({ v: firstLine && firstLine.v, kind: firstLine && firstLine.kind })}). ` + + fixtureSeedHint(), + ); + } + // Require at least one turn so summary/hotspots/compare have something to + // diff against. A pure stamp/relationship-only fixture would still let + // both impls return empty rows and pass on noise. + const hasTurn = lines.some((line) => { + try { + const parsed = JSON.parse(line); + return parsed && parsed.v === 1 && parsed.kind === 'turn'; + } catch { + return false; + } + }); + if (!hasTurn) { + throw new Error( + `conformance fixture ledger malformed: ${ledgerJsonl} contains no ` + + `kind:'turn' lines, so verbs like summary/hotspots/compare would ` + + `compare empty results on both sides. ` + + fixtureSeedHint(), ); } } From f45da3e140f131ec93e6f517a901eed0c0c296f3 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 6 May 2026 10:05:32 -0400 Subject: [PATCH 5/5] napi-build: real aarch64-linux cross toolchain (gcc-aarch64-linux-gnu) instead of unused cross install --- .github/workflows/napi-build.yml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/.github/workflows/napi-build.yml b/.github/workflows/napi-build.yml index ad32bb7f..268e8d93 100644 --- a/.github/workflows/napi-build.yml +++ b/.github/workflows/napi-build.yml @@ -84,12 +84,16 @@ jobs: rustup toolchain install rustup target add ${{ matrix.target }} - - name: Install cross (linux-arm64 only) + - name: Install aarch64-linux cross toolchain (linux-arm64 only) if: matrix.target == 'aarch64-unknown-linux-gnu' - # `cross` provides the aarch64 sysroot via Docker on the linux runner. - # napi-rs's CLI auto-detects `cross` for arm64 linux, so installing - # it is the only setup step we need. - run: cargo install cross --locked --version 0.2.5 + # `napi build` calls `cargo build --target aarch64-unknown-linux-gnu` + # directly (it does not auto-route through `cross`), so we need a real + # aarch64 cross-compiler available on the runner. `libsqlite3-sys` (a + # transitive dep via `rusqlite`) compiles bundled SQLite C source via + # `cc-rs`, which probes for `aarch64-linux-gnu-gcc`. + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu - name: Cache cargo registry + target uses: actions/cache@v4 @@ -124,7 +128,14 @@ jobs: # `--cargo-cwd` points at the binding crate (which lives under # `crates/relayburn-sdk-node/`, not co-located with the npm package) # so the `cargo metadata` step that resolves the cdylib succeeds. + # The CC_/linker env vars only take effect for the aarch64-linux leg + # (cargo ignores per-target vars when the host matches the target); + # native legs are unaffected. working-directory: packages/sdk-node + env: + CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc + CXX_aarch64_unknown_linux_gnu: aarch64-linux-gnu-g++ + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc run: | pnpm exec napi build \ --platform \