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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
# Build output
dist/

# Local credentials and environment overrides
.env
72 changes: 71 additions & 1 deletion bt-daemon/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ pub struct HookArgs {
/// Explicit event name (overrides `--event-field` lookup).
#[arg(long)]
pub event: Option<String>,
/// JSON field holding a transcript path. When present, capture the file
/// length observed by this hook so deterministic journal replay cannot
/// read transcript records written by later lifecycle events.
#[arg(long)]
pub transcript_path_field: Option<String>,
/// Fail instead of spawning a daemon if none is running.
#[arg(long)]
pub no_spawn: bool,
Expand Down Expand Up @@ -190,7 +195,11 @@ pub async fn run_hook(
if !settings.tracing_enabled() {
return Ok(());
}
let payload = read_stdin_json()?;
let mut payload = read_stdin_json()?;

if let Some(field) = &args.transcript_path_field {
add_transcript_observation(&mut payload, field);
}

let session_id = json_str_field(&payload, &args.session_id_field)
.ok_or_else(|| anyhow::anyhow!("no `{}` field in hook payload", args.session_id_field))?;
Expand Down Expand Up @@ -771,6 +780,47 @@ fn json_str_field(payload: &serde_json::Value, field: &str) -> Option<String> {
}
}

/// Stamp the transcript boundary visible when a blocking hook runs. Agent
/// transcripts are append-only, while daemon journal replay may happen after
/// the session has advanced. Recording byte lengths keeps translation causally
/// aligned with each native hook without copying transcript contents into the
/// journal.
fn add_transcript_observation(payload: &mut serde_json::Value, field: &str) {
let Some(path) = json_str_field(payload, field) else {
return;
};
let transcript = std::path::Path::new(&path);
let mut observation = serde_json::Map::new();
observation.insert("path".into(), serde_json::Value::String(path.clone()));
if let Ok(metadata) = std::fs::metadata(transcript) {
observation.insert(
"observed_bytes".into(),
serde_json::Value::Number(metadata.len().into()),
);
}

if transcript.file_name().and_then(|name| name.to_str()) == Some("transcript.jsonl") {
let full = transcript.with_file_name("transcript_full.jsonl");
if let Ok(metadata) = std::fs::metadata(&full) {
observation.insert(
"full_path".into(),
serde_json::Value::String(full.to_string_lossy().into_owned()),
);
observation.insert(
"full_observed_bytes".into(),
serde_json::Value::Number(metadata.len().into()),
);
}
}

if let Some(object) = payload.as_object_mut() {
object.insert(
"_bt_transcript_observation".into(),
serde_json::Value::Object(observation),
);
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -798,6 +848,26 @@ mod tests {
assert!(now_ms() > 0);
}

#[test]
fn transcript_observation_captures_compact_and_full_boundaries() {
let dir = tempfile::tempdir().unwrap();
let compact = dir.path().join("transcript.jsonl");
let full = dir.path().join("transcript_full.jsonl");
std::fs::write(&compact, b"compact\n").unwrap();
std::fs::write(&full, b"complete record\n").unwrap();
let mut payload = serde_json::json!({
"transcriptPath": compact.to_string_lossy()
});

add_transcript_observation(&mut payload, "transcriptPath");

let observed = &payload["_bt_transcript_observation"];
assert_eq!(observed["path"], compact.to_string_lossy().as_ref());
assert_eq!(observed["observed_bytes"], 8);
assert_eq!(observed["full_path"], full.to_string_lossy().as_ref());
assert_eq!(observed["full_observed_bytes"], 16);
}

#[test]
fn import_destination_without_session_config_fails_fast() {
let mut config = None;
Expand Down
24 changes: 14 additions & 10 deletions bt-daemon/src/sink/braintrust.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ impl BraintrustSink {
.span_type(map_span_type(row.span_type))
.span_id(row.span_id.clone())
.row_id(row.span_id.clone())
.project_name(project)
.parent_info(parent)
.span_origin(
SpanOrigin::new()
Expand All @@ -231,9 +230,6 @@ impl BraintrustSink {
self.ensure_handle(client, row)?;
let handle = self.open.get(&row.span_id).expect("just inserted");
handle.log(build_log(row)?);
if let Some(end) = row.end_ms {
handle.end_with_time(ms_to_secs(end));
}
Ok(())
}
}
Expand Down Expand Up @@ -455,14 +451,22 @@ fn build_log(row: &SpanRow) -> anyhow::Result<SpanLog> {
if let Some(Value::Object(md)) = &row.metadata {
lb = lb.metadata(md.clone());
}
if let Some(Value::Object(metrics)) = &row.metrics {
let hm: HashMap<String, f64> = metrics
let mut metrics = match &row.metrics {
Some(Value::Object(metrics)) => metrics
.iter()
.filter_map(|(k, v)| v.as_f64().map(|f| (k.clone(), f)))
.collect();
if !hm.is_empty() {
lb = lb.metrics(hm);
}
.collect(),
_ => HashMap::new(),
};
if let Some(end) = row.end_ms {
// `SpanHandle::end_with_time` intentionally keeps the first end time.
// Coding-agent sessions can be resumed after an idle stop, so encode
// end as a regular mergeable metric and allow a later lifecycle event
// to extend the same deterministic span.
metrics.insert("end".to_string(), ms_to_secs(end));
}
if !metrics.is_empty() {
lb = lb.metrics(metrics);
}
if let Some(err) = &row.error {
lb = lb.error(Value::String(err.clone()));
Expand Down
Loading
Loading