Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,37 @@ jobs:
- name: Build npm wrappers
run: pnpm -r --if-present run build

- name: Setup Rust toolchain
# Keep the napi build on the same pinned toolchain as the Rust job;
# upgrade a stale preinstalled stable before cargo resolves it.
run: rustup toolchain install

- name: Restore cargo registry + target cache
uses: actions/cache/restore@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml', 'rust-toolchain.toml') }}
restore-keys: |
cargo-${{ runner.os }}-

- name: Build native Node SDK binding
# The release/cross-platform napi matrix is path-filtered and does not
# run for changes such as MCP-only PRs. Build one native debug binding
# here so the always-on Node test job exercises the SDK facade without
# adding an uncached release build to its critical path.
run: pnpm run build:napi:debug

- name: Run npm tests
run: pnpm run test
env:
HOME: ${{ runner.temp }}/relayburn-node-tests/home
RELAYBURN_HOME: ${{ runner.temp }}/relayburn-node-tests/ledger
RELAYBURN_SDK_NAPI_BUILT: '1'
run: |
mkdir -p "$HOME" "$RELAYBURN_HOME"
pnpm run test

cargo-build-and-test:
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/napi-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ jobs:
with:
name: relayburn-sdk-${{ matrix.short }}
path: packages/sdk-node/src/*.node
if-no-files-found: warn
if-no-files-found: error
retention-days: 7

- name: Bundle smoke test (esbuild)
Expand Down
41 changes: 33 additions & 8 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,40 @@ jobs:
- name: Build workspace
run: pnpm -r run build

# Reuse the napi matrix outputs for both conformance tests and package
# staging. The x64 Linux artifact is native to this publish runner.
- name: Download SDK napi artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/sdk-artifacts
pattern: relayburn-sdk-*
merge-multiple: false

- name: Stage native SDK binding for tests
run: |
set -euo pipefail
sdk_test_dir="/tmp/sdk-artifacts/relayburn-sdk-linux-x64-gnu"
if [ ! -d "$sdk_test_dir" ]; then
echo "::error title=Missing SDK test artifact dir::expected $sdk_test_dir" >&2
ls -la /tmp/sdk-artifacts/ 2>/dev/null || true
exit 1
fi
sdk_test_node=$(find "$sdk_test_dir" -maxdepth 2 -type f -name '*.node' -print -quit)
if [ -z "$sdk_test_node" ]; then
echo "::error title=Missing SDK test artifact::expected a .node file under $sdk_test_dir" >&2
find "$sdk_test_dir" -maxdepth 2 -type f -print >&2 || true
exit 1
fi
cp "$sdk_test_node" packages/sdk-node/src/index.linux-x64-gnu.node

- name: Run tests
run: pnpm run test
env:
HOME: ${{ runner.temp }}/relayburn-node-tests/home
RELAYBURN_HOME: ${{ runner.temp }}/relayburn-node-tests/ledger
RELAYBURN_SDK_NAPI_BUILT: '1'
run: |
mkdir -p "$HOME" "$RELAYBURN_HOME"
pnpm run test

# Rust port build + test gates the whole publish job. If the Rust
# tree is red at this commit, abort before npm ships anything so the
Expand Down Expand Up @@ -916,13 +948,6 @@ jobs:
pattern: relayburn-cli-*
merge-multiple: false

- name: Download SDK napi artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/sdk-artifacts
pattern: relayburn-sdk-*
merge-multiple: false

- name: Stage prebuilt binaries into platform packages
run: |
set -euo pipefail
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-node/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

- Local napi builds load ahead of installed platform packages, so development and conformance tests use the current checkout's native binding.
- Cost calculations recognize Claude 5 and GPT-5.6 models, prefer first-party tariffs, and apply long-context price tiers.
- `hotspots()` findings identify unknown pricing and rank unpriced sessions by token volume instead of $0.00.

Expand Down
50 changes: 22 additions & 28 deletions packages/sdk-node/src/binding.cjs
Original file line number Diff line number Diff line change
@@ -1,28 +1,24 @@
// Native-binding loader. At publish time, `napi build` (via `@napi-rs/cli`)
// regenerates this file to dispatch to the right per-platform package
// Native-binding loader. Dispatches to a local napi build when present, then
// falls back to the matching 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.
// on `process.platform` + `process.arch` + libc detection. Published installs
// pull the prebuilt `.node` file out of `optionalDependencies`, so consumers
// 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')`.
// 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
// #247-a is in flight, we throw a clear "binding not built" error instead of
// requiring `*.node` artifacts that don't exist yet.
// This hand-written dispatcher matches the napi-rs loader shape while keeping
// a clear error for fresh checkouts where neither a local build nor a
// platform package is available.
//
// Once `napi build` runs in CI for the first time, this file is overwritten;
// see `.github/workflows/napi-build.yml`.
// `napi build ... src` emits `src/index.<target>.node` next to this loader;
// see the package scripts and `.github/workflows/napi-build.yml`.

const { existsSync, readFileSync } = require('node:fs');
const { existsSync } = require('node:fs');
const { join } = require('node:path');
const { platform, arch } = process;

Expand All @@ -44,9 +40,9 @@ 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;
// Prefer the sibling .node emitted by a local build; published installs
// fall back to the optional-dependency platform package.
const localPath = localFile ? join(__dirname, localFile) : null;
if (localPath && existsSync(localPath)) {
try {
return require(localPath);
Expand All @@ -63,20 +59,18 @@ function tryRequire(specifier, localFile) {
}

if (platform === 'darwin' && arch === 'arm64') {
nativeBinding = tryRequire('@relayburn/sdk-darwin-arm64', 'relayburn-sdk.darwin-arm64.node');
nativeBinding = tryRequire('@relayburn/sdk-darwin-arm64', 'index.darwin-arm64.node');
} else if (platform === 'darwin' && arch === 'x64') {
nativeBinding = tryRequire('@relayburn/sdk-darwin-x64', 'relayburn-sdk.darwin-x64.node');
nativeBinding = tryRequire('@relayburn/sdk-darwin-x64', 'index.darwin-x64.node');
} else if (platform === 'linux' && arch === 'arm64' && !isMusl()) {
nativeBinding = tryRequire('@relayburn/sdk-linux-arm64-gnu', 'relayburn-sdk.linux-arm64-gnu.node');
nativeBinding = tryRequire('@relayburn/sdk-linux-arm64-gnu', 'index.linux-arm64-gnu.node');
} else if (platform === 'linux' && arch === 'x64' && !isMusl()) {
nativeBinding = tryRequire('@relayburn/sdk-linux-x64-gnu', 'relayburn-sdk.linux-x64-gnu.node');
nativeBinding = tryRequire('@relayburn/sdk-linux-x64-gnu', 'index.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.
// Surface a clear actionable error for fresh local checkouts and broken
// optional-dependency installs.
const detail = loadError
? `\nUnderlying error: ${loadError.message}`
: '';
Expand Down
26 changes: 3 additions & 23 deletions packages/sdk-node/test/conformance.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,40 +3,20 @@
// These tests run the napi-rs facade against the committed cli-golden ledger.
// They are intentionally shape-level checks now that the old TypeScript SDK
// package has been removed from the workspace. Set RELAYBURN_SDK_NAPI_BUILT=1
// after `pnpm run build:napi` to execute them; without a native binding they
// skip cleanly so package-level JS tests still work on a fresh checkout.
// after `pnpm run build:napi` to execute them. A fresh local checkout skips
// cleanly, while CI fails if the gate or native binding is missing.

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, cpSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadNapiSdk } from './helpers/napi.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = resolve(__dirname, '../../..');
const FIXTURE_LEDGER = join(REPO_ROOT, 'tests', 'fixtures', 'cli-golden', 'ledger');
const NAPI_READY = process.env.RELAYBURN_SDK_NAPI_BUILT === '1';

function bindingMissing(err) {
return /native binding not found/i.test(String(err && err.message));
}

async function loadNapiSdk(t) {
if (!NAPI_READY) {
t.skip('napi-rs binding not built; set RELAYBURN_SDK_NAPI_BUILT=1');
return null;
}
try {
return await import(join(__dirname, '..', 'src', 'index.js'));
} catch (err) {
if (bindingMissing(err)) {
t.skip('napi-rs binding load failed; build artifact missing');
return null;
}
throw err;
}
}

function makeLedgerHome() {
const home = mkdtempSync(join(tmpdir(), 'relayburn-sdk-ledger-'));
Expand Down
3 changes: 2 additions & 1 deletion packages/sdk-node/test/esbuild-smoke.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ test('esbuild bundles the @relayburn/sdk umbrella facade cleanly', async (t) =>
let esbuild;
try {
esbuild = await import('esbuild');
} catch (_) {
} catch (err) {
if (process.env.CI) throw err;
t.skip('esbuild not installed — run `pnpm install` first');
return;
}
Expand Down
34 changes: 34 additions & 0 deletions packages/sdk-node/test/helpers/napi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { createRequire } from 'node:module';
import { dirname, join, sep } from 'node:path';
import { fileURLToPath } from 'node:url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const NAPI_READY = process.env.RELAYBURN_SDK_NAPI_BUILT === '1';
const IN_CI = Boolean(process.env.CI);
const SDK_SRC = join(__dirname, '..', '..', 'src');

export async function loadNapiSdk(t) {
if (!NAPI_READY) {
if (IN_CI) {
throw new Error(
'RELAYBURN_SDK_NAPI_BUILT=1 is required in CI; run pnpm run build:napi first',
);
}
t.skip('napi-rs binding not built; set RELAYBURN_SDK_NAPI_BUILT=1');
return null;
}

// When the caller claims the binding is ready, a missing or unloadable
// artifact is a test failure rather than another silent skip.
const sdk = await import(join(SDK_SRC, 'index.js'));
const loadedNodeModules = Object.keys(require.cache).filter((path) => path.endsWith('.node'));
const localPrefix = `${SDK_SRC}${sep}`;
if (!loadedNodeModules.some((path) => path.startsWith(localPrefix))) {
throw new Error(
`Node SDK conformance must load the locally built binding under ${SDK_SRC}; ` +
`loaded native modules: ${loadedNodeModules.join(', ') || '(none)'}`,
);
}
return sdk;
}
34 changes: 15 additions & 19 deletions packages/sdk-node/test/no-onlog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadNapiSdk } from './helpers/napi.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const DTS_PATH = resolve(__dirname, '..', 'src', 'index.d.ts');
Expand All @@ -33,24 +35,18 @@ test('public option types no longer declare onLog (#374)', () => {
});

test('verbs tolerate a stray onLog property at runtime', async (t) => {
if (process.env.RELAYBURN_SDK_NAPI_BUILT !== '1') {
t.skip('napi-rs binding not built — set RELAYBURN_SDK_NAPI_BUILT=1');
return;
}
let sdk;
try {
sdk = await import(join(__dirname, '..', 'src', 'index.js'));
} catch (err) {
if (/native binding not found/i.test(String(err && err.message))) {
t.skip('napi-rs binding load failed — build artifact missing');
return;
}
throw err;
}
const sdk = await loadNapiSdk(t);
if (!sdk) return;

// Cast through any-shape to bypass the now-stricter option types: the
// contract under test is the runtime forgiveness, not the TS shape.
const stray = { onLog: () => {} };
await assert.doesNotReject(() => sdk.summary(stray));
await assert.doesNotReject(() => sdk.sessionCost(stray));
await assert.doesNotReject(() => sdk.hotspots(stray));
const ledgerHome = mkdtempSync(join(tmpdir(), 'relayburn-sdk-onlog-'));
const stray = { ledgerHome, onLog: () => {} };
try {
await assert.doesNotReject(() => sdk.summary(stray));
await assert.doesNotReject(() => sdk.sessionCost(stray));
await assert.doesNotReject(() => sdk.hotspots(stray));
} finally {
rmSync(ledgerHome, { recursive: true, force: true });
}
});
Loading