Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment on lines +9 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Changelog entry should mention both Rust and Node SDK surfaces.

The entry documents @relayburn/sdk (Node package) but omits the Rust SDK surface. Per the PR objectives, this feature adds relayburn_sdk::write_stamp (Rust) and @relayburn/sdk.writeStamp (Node). Looking at other entries in this changelog (e.g., lines 15-20, 26-29), Rust SDK changes are documented with the relayburn-sdk: prefix.

Consider splitting or expanding the entry:

- `relayburn-sdk` (Rust): `write_stamp({ sessionId | messageId, enrichment })` 
  for direct ledger writes by exact selector.
- `@relayburn/sdk` (Node): `writeStamp` export wraps the Rust SDK verb.

Or combine them:

- `writeStamp({ sessionId | messageId, enrichment })` in both Rust SDK 
  and `@relayburn/sdk` (Node) for launchers that know session id up front 
  (e.g. preallocated Claude `--session-id`).

As per coding guidelines, the phrase "bypassing the sidecar writePendingStamp matching path" leans toward implementation detail. Consider tightening to: "skipping manifest matching" or focusing on the practical benefit: "writes enrichment immediately using the known session id."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@CHANGELOG.md` around lines 9 - 11, Update the changelog entry to mention both
SDK surfaces: add a line for the Rust symbol relayburn_sdk::write_stamp and the
Node export `@relayburn/sdk.writeStamp` (or combine into one line stating the
feature exists in both), and replace the implementation-detail phrase "bypassing
the sidecar `writePendingStamp` matching path" with a concise user-facing
description such as "skipping manifest matching" or "writes enrichment
immediately using the known session id" to align with existing entries like the
`relayburn-sdk:` prefix style.


### Changed

- `relayburn-sdk`: dedupe ingest filesystem walks (`list_dirs`,
Expand Down
44 changes: 44 additions & 0 deletions crates/relayburn-sdk-node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
pub message_id: Option<String>,
pub enrichment: HashMap<String, String>,
/// ISO-8601 timestamp the caller observed. Defaults to "now" when omitted.
pub ts: Option<String>,
pub ledger_home: Option<String>,
}

/// 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",
));
Comment on lines +516 to +519
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject empty selector strings for writeStamp

The argument guard only checks whether sessionId/messageId are None, so "" passes validation and is written as a selector. Because matching is exact, an empty id will usually match no turns, producing a successful-but-noop stamp row that is hard for callers to detect. This is especially easy when values come from env vars/flags that may be present but empty.

Useful? React with 👍 / 👎.

}
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::<BTreeMap<_, _>>(),
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<SystemTime, BurnError> {
let Some(raw) = s.strip_suffix('Z') else {
return Err(invalid_arg("spawnStartTs must be an ISO-8601 Z timestamp"));
Expand Down
4 changes: 4 additions & 0 deletions crates/relayburn-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,17 @@ 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::*;
#[allow(unused_imports)]
pub use ingest_verb::*;
#[allow(unused_imports)]
pub use query_verbs::*;
#[allow(unused_imports)]
pub use stamp_verb::*;

// --- Re-exports ------------------------------------------------------------
//
Expand Down
150 changes: 150 additions & 0 deletions crates/relayburn-sdk/src/stamp_verb.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
pub message_id: Option<String>,
pub enrichment: Enrichment,
/// ISO-8601 timestamp the caller observed. Defaults to "now" formatted
/// `YYYY-MM-DDTHH:MM:SSZ` when omitted.
pub ts: Option<String>,
pub ledger_home: Option<PathBuf>,
}

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<Stamp> {
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"
);
Comment on lines +78 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep default stamp timestamps in ms ISO format

write_stamp now defaults ts to YYYY-MM-DDTHH:MM:SSZ, but existing stamp writers use millisecond ISO strings (...SS.mmmZ). Stamp application order is read from SQLite as text-ordered ORDER BY ts, written_at (collect_stamps in crates/relayburn-sdk/src/ledger/reader.rs), so mixing second-only and millisecond formats can invert precedence for stamps in the same second (e.g. .500Z sorts before Z). That can cause later enrichment to be overwritten by older data.

Useful? React with 👍 / 👎.

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}"
);
}
}
6 changes: 6 additions & 0 deletions packages/sdk-node/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions packages/sdk-node/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,27 @@ export declare function writePendingStamp(
opts: WritePendingStampOptions,
): Promise<PendingStampWriteResult>

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<string, string>;
/** 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 <uuid>` before spawn — so the
* enrichment lands by selector without going through the sidecar
* `writePendingStamp` manifest matching path.
*/
export declare function writeStamp(opts: WriteStampOptions): Promise<void>

export interface SummaryOptions {
session?: string;
project?: string;
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk-node/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
52 changes: 52 additions & 0 deletions packages/sdk-node/test/conformance.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ test('sdk facade exposes the expected verb set', async (t) => {
'hotspots',
'compare',
'writePendingStamp',
'writeStamp',
'computeCompareExcluded',
'search',
'exportLedger',
Expand Down Expand Up @@ -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;
Expand Down
Loading