-
Notifications
You must be signed in to change notification settings - Fork 2
feat(sdk): add writeStamp for exact session-id stamping #426
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The argument guard only checks whether 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")); | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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}" | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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 addsrelayburn_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 therelayburn-sdk:prefix.Consider splitting or expanding the entry:
Or combine them:
As per coding guidelines, the phrase "bypassing the sidecar
writePendingStampmatching 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