From 1bae2bad9e6bd5b02d1ac81d348d2eb9926772b4 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 21 May 2026 10:45:38 -0400 Subject: [PATCH] feat(sdk): add writeStamp for exact session-id stamping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Launchers that know the session id up front — for example, a Claude launcher that preallocates `--session-id ` before spawn — can now call `@relayburn/sdk` `writeStamp({ sessionId, enrichment })` to fold enrichment straight onto the ledger by selector, skipping the `writePendingStamp` sidecar manifest matching path entirely. The companion `writePendingStamp` flow still serves harnesses that don't expose a pre-spawn session id (Codex, OpenCode). `writeStamp` is the more reliable path when the id is available: no cwd/pid race, no orphan manifest if the spawn fails, and the stamp resolves immediately at ingest time rather than after the launcher first writes a turn. The verb is exposed as a free function and a `LedgerHandle` method in the Rust SDK (`relayburn_sdk::write_stamp`, `LedgerHandle::write_stamp`) plus a napi-bound `writeStamp` in `@relayburn/sdk`. Selectors are either `sessionId` or `messageId`; empty selectors reject via `StampError::EmptySelector` to keep `writePendingStamp`'s "no matched-everything stamps" guarantee. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 6 + crates/relayburn-sdk-node/src/lib.rs | 44 ++++++ crates/relayburn-sdk/src/lib.rs | 4 + crates/relayburn-sdk/src/stamp_verb.rs | 150 +++++++++++++++++++++ packages/sdk-node/CHANGELOG.md | 6 + packages/sdk-node/src/index.d.ts | 21 +++ packages/sdk-node/src/index.js | 4 + packages/sdk-node/test/conformance.test.js | 52 +++++++ 8 files changed, 287 insertions(+) create mode 100644 crates/relayburn-sdk/src/stamp_verb.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 2039c16a..9fad9141 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Cross-package release notes for relayburn. Package changelogs contain package-le ## [Unreleased] +### Added + +- `@relayburn/sdk`: `writeStamp({ sessionId | messageId, enrichment })` for + launchers that know the session id up front (e.g. preallocated Claude + `--session-id`), bypassing the sidecar `writePendingStamp` matching path. + ### Changed - `relayburn-sdk`: dedupe ingest filesystem walks (`list_dirs`, diff --git a/crates/relayburn-sdk-node/src/lib.rs b/crates/relayburn-sdk-node/src/lib.rs index 8c72013c..1b8ea82c 100644 --- a/crates/relayburn-sdk-node/src/lib.rs +++ b/crates/relayburn-sdk-node/src/lib.rs @@ -492,6 +492,50 @@ pub fn write_pending_stamp( .map_err(io_err) } +// --------------------------------------------------------------------------- +// writeStamp — direct exact-selector stamp write +// --------------------------------------------------------------------------- + +#[napi(object)] +pub struct WriteStampOptions { + pub session_id: Option, + pub message_id: Option, + pub enrichment: HashMap, + /// ISO-8601 timestamp the caller observed. Defaults to "now" when omitted. + pub ts: Option, + pub ledger_home: Option, +} + +/// Write a stamp targeting an exact session id or message id. Use when the +/// launcher knows the session id up front (e.g. preallocated a Claude +/// `--session-id` UUID before spawn). Companion to `writePendingStamp`; +/// skips the sidecar manifest path entirely. At least one of `sessionId` +/// or `messageId` must be set. +#[napi] +pub fn write_stamp(opts: WriteStampOptions) -> Result<(), BurnError> { + if opts.session_id.is_none() && opts.message_id.is_none() { + return Err(invalid_arg( + "writeStamp requires at least one of sessionId or messageId", + )); + } + if opts.enrichment.is_empty() { + return Err(invalid_arg("enrichment must contain at least one tag")); + } + for key in opts.enrichment.keys() { + if key.is_empty() { + return Err(invalid_arg("enrichment keys must be non-empty")); + } + } + let raw = sdk::WriteStampOptions { + session_id: opts.session_id, + message_id: opts.message_id, + enrichment: opts.enrichment.into_iter().collect::>(), + ts: opts.ts, + ledger_home: maybe_path(opts.ledger_home), + }; + sdk::write_stamp(raw).map_err(sdk_err) +} + fn parse_iso_system_time(s: &str) -> std::result::Result { let Some(raw) = s.strip_suffix('Z') else { return Err(invalid_arg("spawnStartTs must be an ISO-8601 Z timestamp")); diff --git a/crates/relayburn-sdk/src/lib.rs b/crates/relayburn-sdk/src/lib.rs index 45f866f4..2e5f1192 100644 --- a/crates/relayburn-sdk/src/lib.rs +++ b/crates/relayburn-sdk/src/lib.rs @@ -54,6 +54,8 @@ mod export_verbs; mod ingest_verb; #[allow(unused_imports)] mod query_verbs; +#[allow(unused_imports)] +mod stamp_verb; #[allow(unused_imports)] pub use export_verbs::*; @@ -61,6 +63,8 @@ pub use export_verbs::*; pub use ingest_verb::*; #[allow(unused_imports)] pub use query_verbs::*; +#[allow(unused_imports)] +pub use stamp_verb::*; // --- Re-exports ------------------------------------------------------------ // diff --git a/crates/relayburn-sdk/src/stamp_verb.rs b/crates/relayburn-sdk/src/stamp_verb.rs new file mode 100644 index 00000000..3f6a8cc4 --- /dev/null +++ b/crates/relayburn-sdk/src/stamp_verb.rs @@ -0,0 +1,150 @@ +//! `write_stamp` — direct stamp write by exact session id or message id. +//! +//! Companion to `write_pending_stamp` (sidecar manifest, matched at ingest +//! time by cwd + spawnerPid + spawnStartTs). When a launcher knows the +//! session id up front — e.g. it preallocated a Claude `--session-id` UUID +//! before spawn — calling [`write_stamp`] folds the enrichment straight +//! onto the ledger by selector, skipping the manifest dance entirely. This +//! is the more reliable path: no path-matching race, no orphan manifest if +//! the spawn fails. +//! +//! The verb is exposed as a free function and as a [`LedgerHandle`] method, +//! mirroring the rest of the SDK surface. +//! +//! Empty selectors (neither `session_id` nor `message_id` set) are +//! rejected via [`crate::StampError::EmptySelector`] — a stamp with no +//! selector would label every turn, which is never what the caller wants. + +use std::path::PathBuf; + +use anyhow::Result; + +use crate::{Enrichment, Ledger, LedgerHandle, LedgerOpenOptions, Stamp, StampSelector}; + +/// Options for [`write_stamp`]. At least one of `session_id` or +/// `message_id` must be set. +#[derive(Debug, Clone, Default)] +pub struct WriteStampOptions { + pub session_id: Option, + pub message_id: Option, + pub enrichment: Enrichment, + /// ISO-8601 timestamp the caller observed. Defaults to "now" formatted + /// `YYYY-MM-DDTHH:MM:SSZ` when omitted. + pub ts: Option, + pub ledger_home: Option, +} + +impl LedgerHandle { + /// Append a stamp targeting the given session / message selector. + pub fn write_stamp(&mut self, opts: WriteStampOptions) -> Result<()> { + let stamp = build_stamp(&opts)?; + self.inner.append_stamp(&stamp)?; + Ok(()) + } +} + +/// Open the ledger, write the stamp, drop the handle. +pub fn write_stamp(opts: WriteStampOptions) -> Result<()> { + let stamp = build_stamp(&opts)?; + let lo = match opts.ledger_home.as_deref() { + Some(h) => LedgerOpenOptions::with_home(h), + None => LedgerOpenOptions::default(), + }; + let mut handle = Ledger::open(lo)?; + handle.inner.append_stamp(&stamp)?; + Ok(()) +} + +fn build_stamp(opts: &WriteStampOptions) -> Result { + let selector = StampSelector { + session_id: opts.session_id.clone(), + message_id: opts.message_id.clone(), + range: None, + }; + let ts = opts + .ts + .clone() + .unwrap_or_else(|| now_iso(&std::time::SystemTime::now())); + Stamp::new(ts, selector, opts.enrichment.clone()).map_err(Into::into) +} + +fn now_iso(now: &std::time::SystemTime) -> String { + let secs = now + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let dt = time::OffsetDateTime::from_unix_timestamp(secs as i64) + .unwrap_or(time::OffsetDateTime::UNIX_EPOCH); + let fmt = time::macros::format_description!( + "[year]-[month]-[day]T[hour]:[minute]:[second]Z" + ); + dt.format(&fmt).expect("format z iso") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn empty_selector_is_rejected() { + let err = write_stamp(WriteStampOptions { + session_id: None, + message_id: None, + enrichment: BTreeMap::new(), + ts: None, + ledger_home: Some(std::env::temp_dir()), + }) + .unwrap_err(); + assert!( + err.to_string().contains("selector"), + "expected empty-selector error, got: {err}" + ); + } + + #[test] + fn stamp_round_trips_session_selector() { + let dir = tempfile::tempdir().unwrap(); + let mut enrichment = Enrichment::new(); + enrichment.insert("spawner".into(), "pear".into()); + enrichment.insert("on_relay".into(), "true".into()); + write_stamp(WriteStampOptions { + session_id: Some("abc-123".into()), + message_id: None, + enrichment: enrichment.clone(), + ts: Some("2026-05-21T12:00:00Z".into()), + ledger_home: Some(dir.path().to_path_buf()), + }) + .unwrap(); + + let opts = LedgerOpenOptions::with_home(dir.path()); + let handle = Ledger::open(opts).unwrap(); + let stamps = handle.inner.list_stamps().unwrap(); + assert_eq!(stamps.len(), 1, "expected exactly one stamp"); + assert_eq!(stamps[0].selector.session_id.as_deref(), Some("abc-123")); + assert_eq!(stamps[0].enrichment, enrichment); + } + + #[test] + fn default_ts_is_iso_z() { + let dir = tempfile::tempdir().unwrap(); + let mut enrichment = Enrichment::new(); + enrichment.insert("k".into(), "v".into()); + write_stamp(WriteStampOptions { + session_id: Some("s".into()), + message_id: None, + enrichment, + ts: None, + ledger_home: Some(dir.path().to_path_buf()), + }) + .unwrap(); + let handle = + Ledger::open(LedgerOpenOptions::with_home(dir.path())).unwrap(); + let stamps = handle.inner.list_stamps().unwrap(); + let ts = &stamps[0].ts; + assert!( + ts.ends_with('Z') && ts.contains('T') && ts.len() == 20, + "ts should be YYYY-MM-DDTHH:MM:SSZ, got {ts}" + ); + } +} diff --git a/packages/sdk-node/CHANGELOG.md b/packages/sdk-node/CHANGELOG.md index d9022e35..005de8e4 100644 --- a/packages/sdk-node/CHANGELOG.md +++ b/packages/sdk-node/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Added + +- `writeStamp({ sessionId | messageId, enrichment, ts?, ledgerHome? })` writes + a stamp by exact selector — companion to `writePendingStamp` for launchers + that preallocate the session id (Claude `--session-id`). + ## [2.5.0] - 2026-05-08 ### Added diff --git a/packages/sdk-node/src/index.d.ts b/packages/sdk-node/src/index.d.ts index df93b589..41b39405 100644 --- a/packages/sdk-node/src/index.d.ts +++ b/packages/sdk-node/src/index.d.ts @@ -58,6 +58,27 @@ export declare function writePendingStamp( opts: WritePendingStampOptions, ): Promise +export interface WriteStampOptions { + /** Target a session by exact id. At least one of `sessionId` or `messageId` must be set. */ + sessionId?: string; + /** Target a single turn by exact message id. At least one of `sessionId` or `messageId` must be set. */ + messageId?: string; + /** Enrichment key/value pairs to fold onto matched turns. Must be non-empty. */ + enrichment: Record; + /** ISO timestamp the caller observed, e.g. `2026-05-21T12:00:00Z`. Defaults to now when omitted. */ + ts?: string; + ledgerHome?: string; +} + +/** + * Write a stamp targeting an exact session id or message id. Use when the + * launcher knows the session id up front — for example, a Claude launcher + * that preallocates `--session-id ` before spawn — so the + * enrichment lands by selector without going through the sidecar + * `writePendingStamp` manifest matching path. + */ +export declare function writeStamp(opts: WriteStampOptions): Promise + export interface SummaryOptions { session?: string; project?: string; diff --git a/packages/sdk-node/src/index.js b/packages/sdk-node/src/index.js index 8ba9d300..e0d41a23 100644 --- a/packages/sdk-node/src/index.js +++ b/packages/sdk-node/src/index.js @@ -124,6 +124,10 @@ export async function writePendingStamp(opts) { return coerceBigInts(await binding.writePendingStamp(opts)); } +export async function writeStamp(opts) { + await binding.writeStamp(opts); +} + export function computeCompareExcluded(summary, minimum) { const out = { total: 0, aggregateOnly: 0, costOnly: 0, partial: 0, usageOnly: 0 }; if (minimum === 'partial') return out; diff --git a/packages/sdk-node/test/conformance.test.js b/packages/sdk-node/test/conformance.test.js index 6423922e..78722917 100644 --- a/packages/sdk-node/test/conformance.test.js +++ b/packages/sdk-node/test/conformance.test.js @@ -68,6 +68,7 @@ test('sdk facade exposes the expected verb set', async (t) => { 'hotspots', 'compare', 'writePendingStamp', + 'writeStamp', 'computeCompareExcluded', 'search', 'exportLedger', @@ -205,6 +206,57 @@ test('writePendingStamp writes a launcher-safe manifest', async (t) => { } }); +test('writeStamp folds enrichment onto an exact session id', async (t) => { + const sdk = await loadNapiSdk(t); + if (!sdk) return; + + const ledgerHome = mkdtempSync(join(tmpdir(), 'relayburn-sdk-stamp-')); + try { + await sdk.writeStamp({ + ledgerHome, + sessionId: 'pear-session-7f3b9b4c', + enrichment: { spawner: 'pear', on_relay: 'true', spawned_by: 'direct' }, + }); + const stamps = await sdk.exportStamps({ ledgerHome }); + assert.equal(stamps.length, 1, 'expected one stamp row'); + const record = stamps[0].record ?? stamps[0]; + assert.equal(record.selector.sessionId, 'pear-session-7f3b9b4c'); + assert.equal(record.enrichment.spawner, 'pear'); + assert.equal(record.enrichment.spawned_by, 'direct'); + assert.equal(record.enrichment.on_relay, 'true'); + } finally { + rmSync(ledgerHome, { recursive: true, force: true }); + } +}); + +test('writeStamp rejects empty selector', async (t) => { + const sdk = await loadNapiSdk(t); + if (!sdk) return; + const ledgerHome = mkdtempSync(join(tmpdir(), 'relayburn-sdk-stamp-empty-')); + try { + await assert.rejects( + sdk.writeStamp({ ledgerHome, enrichment: { k: 'v' } }), + /sessionId or messageId/, + ); + } finally { + rmSync(ledgerHome, { recursive: true, force: true }); + } +}); + +test('writeStamp rejects empty enrichment', async (t) => { + const sdk = await loadNapiSdk(t); + if (!sdk) return; + const ledgerHome = mkdtempSync(join(tmpdir(), 'relayburn-sdk-stamp-noenrich-')); + try { + await assert.rejects( + sdk.writeStamp({ ledgerHome, sessionId: 's', enrichment: {} }), + /enrichment must contain at least one tag/, + ); + } finally { + rmSync(ledgerHome, { recursive: true, force: true }); + } +}); + test('ingest scans an isolated empty home', async (t) => { const sdk = await loadNapiSdk(t); if (!sdk) return;