Skip to content

Commit 0a8f598

Browse files
willwashburnclaude
andauthored
feat(sdk): add writeStamp for exact session-id stamping (#426)
Launchers that know the session id up front — for example, a Claude launcher that preallocates `--session-id <uuid>` 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) <noreply@anthropic.com>
1 parent 34f2e3b commit 0a8f598

8 files changed

Lines changed: 287 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ Cross-package release notes for relayburn. Package changelogs contain package-le
44

55
## [Unreleased]
66

7+
### Added
8+
9+
- `@relayburn/sdk`: `writeStamp({ sessionId | messageId, enrichment })` for
10+
launchers that know the session id up front (e.g. preallocated Claude
11+
`--session-id`), bypassing the sidecar `writePendingStamp` matching path.
12+
713
### Changed
814

915
- `relayburn-sdk`: dedupe ingest filesystem walks (`list_dirs`,

crates/relayburn-sdk-node/src/lib.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,50 @@ pub fn write_pending_stamp(
492492
.map_err(io_err)
493493
}
494494

495+
// ---------------------------------------------------------------------------
496+
// writeStamp — direct exact-selector stamp write
497+
// ---------------------------------------------------------------------------
498+
499+
#[napi(object)]
500+
pub struct WriteStampOptions {
501+
pub session_id: Option<String>,
502+
pub message_id: Option<String>,
503+
pub enrichment: HashMap<String, String>,
504+
/// ISO-8601 timestamp the caller observed. Defaults to "now" when omitted.
505+
pub ts: Option<String>,
506+
pub ledger_home: Option<String>,
507+
}
508+
509+
/// Write a stamp targeting an exact session id or message id. Use when the
510+
/// launcher knows the session id up front (e.g. preallocated a Claude
511+
/// `--session-id` UUID before spawn). Companion to `writePendingStamp`;
512+
/// skips the sidecar manifest path entirely. At least one of `sessionId`
513+
/// or `messageId` must be set.
514+
#[napi]
515+
pub fn write_stamp(opts: WriteStampOptions) -> Result<(), BurnError> {
516+
if opts.session_id.is_none() && opts.message_id.is_none() {
517+
return Err(invalid_arg(
518+
"writeStamp requires at least one of sessionId or messageId",
519+
));
520+
}
521+
if opts.enrichment.is_empty() {
522+
return Err(invalid_arg("enrichment must contain at least one tag"));
523+
}
524+
for key in opts.enrichment.keys() {
525+
if key.is_empty() {
526+
return Err(invalid_arg("enrichment keys must be non-empty"));
527+
}
528+
}
529+
let raw = sdk::WriteStampOptions {
530+
session_id: opts.session_id,
531+
message_id: opts.message_id,
532+
enrichment: opts.enrichment.into_iter().collect::<BTreeMap<_, _>>(),
533+
ts: opts.ts,
534+
ledger_home: maybe_path(opts.ledger_home),
535+
};
536+
sdk::write_stamp(raw).map_err(sdk_err)
537+
}
538+
495539
fn parse_iso_system_time(s: &str) -> std::result::Result<SystemTime, BurnError> {
496540
let Some(raw) = s.strip_suffix('Z') else {
497541
return Err(invalid_arg("spawnStartTs must be an ISO-8601 Z timestamp"));

crates/relayburn-sdk/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,17 @@ mod export_verbs;
5454
mod ingest_verb;
5555
#[allow(unused_imports)]
5656
mod query_verbs;
57+
#[allow(unused_imports)]
58+
mod stamp_verb;
5759

5860
#[allow(unused_imports)]
5961
pub use export_verbs::*;
6062
#[allow(unused_imports)]
6163
pub use ingest_verb::*;
6264
#[allow(unused_imports)]
6365
pub use query_verbs::*;
66+
#[allow(unused_imports)]
67+
pub use stamp_verb::*;
6468

6569
// --- Re-exports ------------------------------------------------------------
6670
//
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
//! `write_stamp` — direct stamp write by exact session id or message id.
2+
//!
3+
//! Companion to `write_pending_stamp` (sidecar manifest, matched at ingest
4+
//! time by cwd + spawnerPid + spawnStartTs). When a launcher knows the
5+
//! session id up front — e.g. it preallocated a Claude `--session-id` UUID
6+
//! before spawn — calling [`write_stamp`] folds the enrichment straight
7+
//! onto the ledger by selector, skipping the manifest dance entirely. This
8+
//! is the more reliable path: no path-matching race, no orphan manifest if
9+
//! the spawn fails.
10+
//!
11+
//! The verb is exposed as a free function and as a [`LedgerHandle`] method,
12+
//! mirroring the rest of the SDK surface.
13+
//!
14+
//! Empty selectors (neither `session_id` nor `message_id` set) are
15+
//! rejected via [`crate::StampError::EmptySelector`] — a stamp with no
16+
//! selector would label every turn, which is never what the caller wants.
17+
18+
use std::path::PathBuf;
19+
20+
use anyhow::Result;
21+
22+
use crate::{Enrichment, Ledger, LedgerHandle, LedgerOpenOptions, Stamp, StampSelector};
23+
24+
/// Options for [`write_stamp`]. At least one of `session_id` or
25+
/// `message_id` must be set.
26+
#[derive(Debug, Clone, Default)]
27+
pub struct WriteStampOptions {
28+
pub session_id: Option<String>,
29+
pub message_id: Option<String>,
30+
pub enrichment: Enrichment,
31+
/// ISO-8601 timestamp the caller observed. Defaults to "now" formatted
32+
/// `YYYY-MM-DDTHH:MM:SSZ` when omitted.
33+
pub ts: Option<String>,
34+
pub ledger_home: Option<PathBuf>,
35+
}
36+
37+
impl LedgerHandle {
38+
/// Append a stamp targeting the given session / message selector.
39+
pub fn write_stamp(&mut self, opts: WriteStampOptions) -> Result<()> {
40+
let stamp = build_stamp(&opts)?;
41+
self.inner.append_stamp(&stamp)?;
42+
Ok(())
43+
}
44+
}
45+
46+
/// Open the ledger, write the stamp, drop the handle.
47+
pub fn write_stamp(opts: WriteStampOptions) -> Result<()> {
48+
let stamp = build_stamp(&opts)?;
49+
let lo = match opts.ledger_home.as_deref() {
50+
Some(h) => LedgerOpenOptions::with_home(h),
51+
None => LedgerOpenOptions::default(),
52+
};
53+
let mut handle = Ledger::open(lo)?;
54+
handle.inner.append_stamp(&stamp)?;
55+
Ok(())
56+
}
57+
58+
fn build_stamp(opts: &WriteStampOptions) -> Result<Stamp> {
59+
let selector = StampSelector {
60+
session_id: opts.session_id.clone(),
61+
message_id: opts.message_id.clone(),
62+
range: None,
63+
};
64+
let ts = opts
65+
.ts
66+
.clone()
67+
.unwrap_or_else(|| now_iso(&std::time::SystemTime::now()));
68+
Stamp::new(ts, selector, opts.enrichment.clone()).map_err(Into::into)
69+
}
70+
71+
fn now_iso(now: &std::time::SystemTime) -> String {
72+
let secs = now
73+
.duration_since(std::time::UNIX_EPOCH)
74+
.map(|d| d.as_secs())
75+
.unwrap_or(0);
76+
let dt = time::OffsetDateTime::from_unix_timestamp(secs as i64)
77+
.unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
78+
let fmt = time::macros::format_description!(
79+
"[year]-[month]-[day]T[hour]:[minute]:[second]Z"
80+
);
81+
dt.format(&fmt).expect("format z iso")
82+
}
83+
84+
#[cfg(test)]
85+
mod tests {
86+
use super::*;
87+
use std::collections::BTreeMap;
88+
89+
#[test]
90+
fn empty_selector_is_rejected() {
91+
let err = write_stamp(WriteStampOptions {
92+
session_id: None,
93+
message_id: None,
94+
enrichment: BTreeMap::new(),
95+
ts: None,
96+
ledger_home: Some(std::env::temp_dir()),
97+
})
98+
.unwrap_err();
99+
assert!(
100+
err.to_string().contains("selector"),
101+
"expected empty-selector error, got: {err}"
102+
);
103+
}
104+
105+
#[test]
106+
fn stamp_round_trips_session_selector() {
107+
let dir = tempfile::tempdir().unwrap();
108+
let mut enrichment = Enrichment::new();
109+
enrichment.insert("spawner".into(), "pear".into());
110+
enrichment.insert("on_relay".into(), "true".into());
111+
write_stamp(WriteStampOptions {
112+
session_id: Some("abc-123".into()),
113+
message_id: None,
114+
enrichment: enrichment.clone(),
115+
ts: Some("2026-05-21T12:00:00Z".into()),
116+
ledger_home: Some(dir.path().to_path_buf()),
117+
})
118+
.unwrap();
119+
120+
let opts = LedgerOpenOptions::with_home(dir.path());
121+
let handle = Ledger::open(opts).unwrap();
122+
let stamps = handle.inner.list_stamps().unwrap();
123+
assert_eq!(stamps.len(), 1, "expected exactly one stamp");
124+
assert_eq!(stamps[0].selector.session_id.as_deref(), Some("abc-123"));
125+
assert_eq!(stamps[0].enrichment, enrichment);
126+
}
127+
128+
#[test]
129+
fn default_ts_is_iso_z() {
130+
let dir = tempfile::tempdir().unwrap();
131+
let mut enrichment = Enrichment::new();
132+
enrichment.insert("k".into(), "v".into());
133+
write_stamp(WriteStampOptions {
134+
session_id: Some("s".into()),
135+
message_id: None,
136+
enrichment,
137+
ts: None,
138+
ledger_home: Some(dir.path().to_path_buf()),
139+
})
140+
.unwrap();
141+
let handle =
142+
Ledger::open(LedgerOpenOptions::with_home(dir.path())).unwrap();
143+
let stamps = handle.inner.list_stamps().unwrap();
144+
let ts = &stamps[0].ts;
145+
assert!(
146+
ts.ends_with('Z') && ts.contains('T') && ts.len() == 20,
147+
"ts should be YYYY-MM-DDTHH:MM:SSZ, got {ts}"
148+
);
149+
}
150+
}

packages/sdk-node/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- `writeStamp({ sessionId | messageId, enrichment, ts?, ledgerHome? })` writes
8+
a stamp by exact selector — companion to `writePendingStamp` for launchers
9+
that preallocate the session id (Claude `--session-id`).
10+
511
## [2.5.0] - 2026-05-08
612

713
### Added

packages/sdk-node/src/index.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,27 @@ export declare function writePendingStamp(
5858
opts: WritePendingStampOptions,
5959
): Promise<PendingStampWriteResult>
6060

61+
export interface WriteStampOptions {
62+
/** Target a session by exact id. At least one of `sessionId` or `messageId` must be set. */
63+
sessionId?: string;
64+
/** Target a single turn by exact message id. At least one of `sessionId` or `messageId` must be set. */
65+
messageId?: string;
66+
/** Enrichment key/value pairs to fold onto matched turns. Must be non-empty. */
67+
enrichment: Record<string, string>;
68+
/** ISO timestamp the caller observed, e.g. `2026-05-21T12:00:00Z`. Defaults to now when omitted. */
69+
ts?: string;
70+
ledgerHome?: string;
71+
}
72+
73+
/**
74+
* Write a stamp targeting an exact session id or message id. Use when the
75+
* launcher knows the session id up front — for example, a Claude launcher
76+
* that preallocates `--session-id <uuid>` before spawn — so the
77+
* enrichment lands by selector without going through the sidecar
78+
* `writePendingStamp` manifest matching path.
79+
*/
80+
export declare function writeStamp(opts: WriteStampOptions): Promise<void>
81+
6182
export interface SummaryOptions {
6283
session?: string;
6384
project?: string;

packages/sdk-node/src/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ export async function writePendingStamp(opts) {
124124
return coerceBigInts(await binding.writePendingStamp(opts));
125125
}
126126

127+
export async function writeStamp(opts) {
128+
await binding.writeStamp(opts);
129+
}
130+
127131
export function computeCompareExcluded(summary, minimum) {
128132
const out = { total: 0, aggregateOnly: 0, costOnly: 0, partial: 0, usageOnly: 0 };
129133
if (minimum === 'partial') return out;

packages/sdk-node/test/conformance.test.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ test('sdk facade exposes the expected verb set', async (t) => {
6868
'hotspots',
6969
'compare',
7070
'writePendingStamp',
71+
'writeStamp',
7172
'computeCompareExcluded',
7273
'search',
7374
'exportLedger',
@@ -205,6 +206,57 @@ test('writePendingStamp writes a launcher-safe manifest', async (t) => {
205206
}
206207
});
207208

209+
test('writeStamp folds enrichment onto an exact session id', async (t) => {
210+
const sdk = await loadNapiSdk(t);
211+
if (!sdk) return;
212+
213+
const ledgerHome = mkdtempSync(join(tmpdir(), 'relayburn-sdk-stamp-'));
214+
try {
215+
await sdk.writeStamp({
216+
ledgerHome,
217+
sessionId: 'pear-session-7f3b9b4c',
218+
enrichment: { spawner: 'pear', on_relay: 'true', spawned_by: 'direct' },
219+
});
220+
const stamps = await sdk.exportStamps({ ledgerHome });
221+
assert.equal(stamps.length, 1, 'expected one stamp row');
222+
const record = stamps[0].record ?? stamps[0];
223+
assert.equal(record.selector.sessionId, 'pear-session-7f3b9b4c');
224+
assert.equal(record.enrichment.spawner, 'pear');
225+
assert.equal(record.enrichment.spawned_by, 'direct');
226+
assert.equal(record.enrichment.on_relay, 'true');
227+
} finally {
228+
rmSync(ledgerHome, { recursive: true, force: true });
229+
}
230+
});
231+
232+
test('writeStamp rejects empty selector', async (t) => {
233+
const sdk = await loadNapiSdk(t);
234+
if (!sdk) return;
235+
const ledgerHome = mkdtempSync(join(tmpdir(), 'relayburn-sdk-stamp-empty-'));
236+
try {
237+
await assert.rejects(
238+
sdk.writeStamp({ ledgerHome, enrichment: { k: 'v' } }),
239+
/sessionId or messageId/,
240+
);
241+
} finally {
242+
rmSync(ledgerHome, { recursive: true, force: true });
243+
}
244+
});
245+
246+
test('writeStamp rejects empty enrichment', async (t) => {
247+
const sdk = await loadNapiSdk(t);
248+
if (!sdk) return;
249+
const ledgerHome = mkdtempSync(join(tmpdir(), 'relayburn-sdk-stamp-noenrich-'));
250+
try {
251+
await assert.rejects(
252+
sdk.writeStamp({ ledgerHome, sessionId: 's', enrichment: {} }),
253+
/enrichment must contain at least one tag/,
254+
);
255+
} finally {
256+
rmSync(ledgerHome, { recursive: true, force: true });
257+
}
258+
});
259+
208260
test('ingest scans an isolated empty home', async (t) => {
209261
const sdk = await loadNapiSdk(t);
210262
if (!sdk) return;

0 commit comments

Comments
 (0)