From 07f28dd9d5fab0f9fcde6ce64b7f4fbfbc02a0ef Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 10:03:01 -0400 Subject: [PATCH 01/13] feat(types): add RunEvent envelope fields + ActorRef (schema v2) Adds stage_id, parallel_group_id, parallel_branch_id, tool_call_id, and actor to RunEvent per the v2 concrete-shape proposal. Introduces ActorRef/ActorKind types. Serialization omits absent fields rather than writing null. Stubs StoredEventFields with matching defaults; population in stored_event_fields() follows in a later commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fabro-cli/src/commands/run/rewind.rs | 5 + lib/crates/fabro-cli/tests/it/cmd/pr_view.rs | 5 + lib/crates/fabro-store/src/run_state.rs | 5 + lib/crates/fabro-types/src/lib.rs | 2 +- lib/crates/fabro-types/src/run_event/mod.rs | 270 +++++++++++++++--- lib/crates/fabro-workflow/src/event.rs | 38 +-- .../fabro-workflow/src/runtime_store.rs | 5 + 7 files changed, 268 insertions(+), 62 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index f5d027abd..eff957b5e 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -198,8 +198,13 @@ fn run_event(run_id: fabro_types::RunId, node_id: Option, body: EventBod run_id, node_id, node_label: None, + stage_id: None, + parallel_group_id: None, + parallel_branch_id: None, session_id: None, parent_session_id: None, + tool_call_id: None, + actor: None, body, } } diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs index 771101196..49fe73cf4 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs @@ -90,8 +90,13 @@ fn pr_view_reads_pull_request_from_store_without_pull_request_json() { run_id, node_id: None, node_label: None, + stage_id: None, + parallel_group_id: None, + parallel_branch_id: None, session_id: None, parent_session_id: None, + tool_call_id: None, + actor: None, body: EventBody::PullRequestCreated(PullRequestCreatedProps { pr_url: "https://github.com/fabro-sh/fabro/pull/123".to_string(), pr_number: 123, diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 6b6660342..f7e39e556 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -611,8 +611,13 @@ mod tests { run_id: fixtures::RUN_1, node_id: node_id.map(ToOwned::to_owned), node_label: None, + stage_id: None, + parallel_group_id: None, + parallel_branch_id: None, session_id: None, parent_session_id: None, + tool_call_id: None, + actor: None, body, }; diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index 6749e5545..c6dda288f 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -49,7 +49,7 @@ pub use run::{ RunSubjectProvenance, }; pub use run_blob_id::RunBlobId; -pub use run_event::{EventBody, RunEvent, RunNoticeLevel}; +pub use run_event::{ActorKind, ActorRef, EventBody, RunEvent, RunNoticeLevel}; pub use run_id::RunId; pub use run_id::fixtures; pub use sandbox_record::SandboxRecord; diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index f4817e3ed..56f771698 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -27,6 +27,23 @@ pub enum RunNoticeLevel { Error, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ActorKind { + User, + Agent, + System, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActorRef { + pub kind: ActorKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display: Option, +} + #[derive(Debug, Clone, PartialEq)] pub struct RunEvent { pub id: String, @@ -34,8 +51,13 @@ pub struct RunEvent { pub run_id: RunId, pub node_id: Option, pub node_label: Option, + pub stage_id: Option, + pub parallel_group_id: Option, + pub parallel_branch_id: Option, pub session_id: Option, pub parent_session_id: Option, + pub tool_call_id: Option, + pub actor: Option, pub body: EventBody, } @@ -271,9 +293,19 @@ struct RunEventRaw { #[serde(default)] node_label: Option, #[serde(default)] + stage_id: Option, + #[serde(default)] + parallel_group_id: Option, + #[serde(default)] + parallel_branch_id: Option, + #[serde(default)] session_id: Option, #[serde(default)] parent_session_id: Option, + #[serde(default)] + tool_call_id: Option, + #[serde(default)] + actor: Option, event: String, #[serde(default = "default_properties")] properties: Value, @@ -283,6 +315,23 @@ fn default_properties() -> Value { Value::Object(Map::new()) } +struct RunEventParts<'a> { + id: String, + ts: DateTime, + run_id: RunId, + node_id: Option, + node_label: Option, + stage_id: Option, + parallel_group_id: Option, + parallel_branch_id: Option, + session_id: Option, + parent_session_id: Option, + tool_call_id: Option, + actor: Option, + event: &'a str, + properties: &'a Value, +} + impl EventBody { pub fn event_name(&self) -> &str { match self { @@ -524,17 +573,22 @@ fn is_known_event_name(event: &str) -> bool { impl RunEvent { pub fn from_value(value: Value) -> serde_json::Result { let raw: RunEventRaw = serde_json::from_value(value)?; - Self::from_parts( - raw.id, - raw.ts, - raw.run_id, - raw.node_id, - raw.node_label, - raw.session_id, - raw.parent_session_id, - &raw.event, - &raw.properties, - ) + Self::from_parts(RunEventParts { + id: raw.id, + ts: raw.ts, + run_id: raw.run_id, + node_id: raw.node_id, + node_label: raw.node_label, + stage_id: raw.stage_id, + parallel_group_id: raw.parallel_group_id, + parallel_branch_id: raw.parallel_branch_id, + session_id: raw.session_id, + parent_session_id: raw.parent_session_id, + tool_call_id: raw.tool_call_id, + actor: raw.actor, + event: &raw.event, + properties: &raw.properties, + }) } pub fn from_ref(value: &Value) -> serde_json::Result { @@ -559,58 +613,78 @@ impl RunEvent { .get("properties") .cloned() .unwrap_or_else(default_properties); - Self::from_parts( - id.to_string(), + let actor = match obj.get("actor") { + Some(value) if !value.is_null() => Some(ActorRef::deserialize(value)?), + _ => None, + }; + Self::from_parts(RunEventParts { + id: id.to_string(), ts, run_id, - obj.get("node_id") + node_id: obj + .get("node_id") .and_then(Value::as_str) .map(str::to_string), - obj.get("node_label") + node_label: obj + .get("node_label") .and_then(Value::as_str) .map(str::to_string), - obj.get("session_id") + stage_id: obj + .get("stage_id") .and_then(Value::as_str) .map(str::to_string), - obj.get("parent_session_id") + parallel_group_id: obj + .get("parallel_group_id") .and_then(Value::as_str) .map(str::to_string), + parallel_branch_id: obj + .get("parallel_branch_id") + .and_then(Value::as_str) + .map(str::to_string), + session_id: obj + .get("session_id") + .and_then(Value::as_str) + .map(str::to_string), + parent_session_id: obj + .get("parent_session_id") + .and_then(Value::as_str) + .map(str::to_string), + tool_call_id: obj + .get("tool_call_id") + .and_then(Value::as_str) + .map(str::to_string), + actor, event, - &properties, - ) + properties: &properties, + }) } - fn from_parts( - id: String, - ts: DateTime, - run_id: RunId, - node_id: Option, - node_label: Option, - session_id: Option, - parent_session_id: Option, - event: &str, - properties: &Value, - ) -> serde_json::Result { + fn from_parts(parts: RunEventParts<'_>) -> serde_json::Result { let body_payload = json!({ - "event": event, - "properties": properties, + "event": parts.event, + "properties": parts.properties, }); let body: EventBody = match serde_json::from_value(body_payload) { Ok(body) => body, - Err(err) if is_known_event_name(event) => return Err(err), + Err(err) if is_known_event_name(parts.event) => return Err(err), Err(_) => EventBody::Unknown { - name: event.to_string(), - properties: properties.clone(), + name: parts.event.to_string(), + properties: parts.properties.clone(), }, }; Ok(Self { - id, - ts, - run_id, - node_id, - node_label, - session_id, - parent_session_id, + id: parts.id, + ts: parts.ts, + run_id: parts.run_id, + node_id: parts.node_id, + node_label: parts.node_label, + stage_id: parts.stage_id, + parallel_group_id: parts.parallel_group_id, + parallel_branch_id: parts.parallel_branch_id, + session_id: parts.session_id, + parent_session_id: parts.parent_session_id, + tool_call_id: parts.tool_call_id, + actor: parts.actor, body, }) } @@ -643,6 +717,27 @@ impl RunEvent { if let Some(value) = &self.node_label { map.insert("node_label".to_string(), Value::String(value.clone())); } + if let Some(value) = &self.stage_id { + map.insert("stage_id".to_string(), Value::String(value.clone())); + } + if let Some(value) = &self.parallel_group_id { + map.insert( + "parallel_group_id".to_string(), + Value::String(value.clone()), + ); + } + if let Some(value) = &self.parallel_branch_id { + map.insert( + "parallel_branch_id".to_string(), + Value::String(value.clone()), + ); + } + if let Some(value) = &self.tool_call_id { + map.insert("tool_call_id".to_string(), Value::String(value.clone())); + } + if let Some(actor) = &self.actor { + map.insert("actor".to_string(), serde_json::to_value(actor)?); + } map.insert("properties".to_string(), self.body.properties_value()?); Ok(Value::Object(map)) } @@ -697,8 +792,13 @@ mod tests { run_id: fixtures::RUN_1, node_id: Some("build".to_string()), node_label: Some("Build".to_string()), + stage_id: None, + parallel_group_id: None, + parallel_branch_id: None, session_id: None, parent_session_id: None, + tool_call_id: None, + actor: None, body: EventBody::StageCompleted(StageCompletedProps { index: 1, duration_ms: 1234, @@ -873,4 +973,90 @@ mod tests { assert_eq!(serialized["event"], value["event"]); assert_eq!(serialized["properties"], value["properties"]); } + + #[test] + fn run_event_round_trips_new_envelope_fields() { + let value = json!({ + "id": "evt_envelope", + "ts": "2026-04-08T16:21:11.106Z", + "run_id": fixtures::RUN_1, + "event": "agent.tool.completed", + "stage_id": "code@1", + "node_id": "code", + "node_label": "Code", + "parallel_group_id": "code@1", + "parallel_branch_id": "code@1:0", + "session_id": "ses_child", + "parent_session_id": "ses_parent", + "tool_call_id": "call_1", + "actor": { + "kind": "agent", + "id": "ses_child", + "display": "claude-sonnet" + }, + "properties": { + "tool_name": "read_file", + "tool_call_id": "call_1", + "output": {"summary": "read"}, + "is_error": false, + "visit": 1 + } + }); + + let parsed = RunEvent::from_value(value.clone()).unwrap(); + assert_eq!(parsed.stage_id.as_deref(), Some("code@1")); + assert_eq!(parsed.parallel_group_id.as_deref(), Some("code@1")); + assert_eq!(parsed.parallel_branch_id.as_deref(), Some("code@1:0")); + assert_eq!(parsed.tool_call_id.as_deref(), Some("call_1")); + let actor = parsed.actor.as_ref().expect("actor present"); + assert_eq!(actor.kind, ActorKind::Agent); + assert_eq!(actor.id.as_deref(), Some("ses_child")); + assert_eq!(actor.display.as_deref(), Some("claude-sonnet")); + + let serialized = parsed.to_value().unwrap(); + assert_eq!(serialized["stage_id"], value["stage_id"]); + assert_eq!(serialized["parallel_group_id"], value["parallel_group_id"]); + assert_eq!( + serialized["parallel_branch_id"], + value["parallel_branch_id"] + ); + assert_eq!(serialized["tool_call_id"], value["tool_call_id"]); + assert_eq!(serialized["actor"], value["actor"]); + } + + #[test] + fn run_event_omits_absent_envelope_fields() { + let event = RunEvent { + id: "evt_bare".to_string(), + ts: DateTime::parse_from_rfc3339("2026-04-04T12:00:00.000Z") + .unwrap() + .with_timezone(&Utc), + run_id: fixtures::RUN_1, + node_id: None, + node_label: None, + stage_id: None, + parallel_group_id: None, + parallel_branch_id: None, + session_id: None, + parent_session_id: None, + tool_call_id: None, + actor: None, + body: EventBody::RunStarted(RunStartedProps { + name: "demo".to_string(), + base_branch: None, + base_sha: None, + run_branch: None, + worktree_dir: None, + goal: None, + }), + }; + + let serialized = event.to_value().unwrap(); + let obj = serialized.as_object().unwrap(); + assert!(!obj.contains_key("stage_id")); + assert!(!obj.contains_key("parallel_group_id")); + assert!(!obj.contains_key("parallel_branch_id")); + assert!(!obj.contains_key("tool_call_id")); + assert!(!obj.contains_key("actor")); + } } diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index d55809675..220d58ee0 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -1248,12 +1248,17 @@ pub fn event_name(event: &Event) -> &'static str { } } -#[derive(Debug)] +#[derive(Debug, Default)] struct StoredEventFields { session_id: Option, parent_session_id: Option, node_id: Option, node_label: Option, + stage_id: Option, + parallel_group_id: Option, + parallel_branch_id: Option, + tool_call_id: Option, + actor: Option, } fn default_node_label(node_id: Option<&String>, node_label: Option) -> Option { @@ -1285,10 +1290,9 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { let node_id = Some(node_id.clone()); let node_label = default_node_label(node_id.as_ref(), Some(name.clone())); StoredEventFields { - session_id: None, - parent_session_id: None, node_id, node_label, + ..StoredEventFields::default() } } Event::CheckpointCompleted { node_id, .. } @@ -1306,10 +1310,9 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { let node_id = Some(node_id.clone()); let node_label = default_node_label(node_id.as_ref(), None); StoredEventFields { - session_id: None, - parent_session_id: None, node_id, node_label, + ..StoredEventFields::default() } } Event::Agent { @@ -1325,15 +1328,15 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { parent_session_id: parent_session_id.clone(), node_id, node_label, + ..StoredEventFields::default() } } Event::GitCommit { node_id, .. } => { let node_label = default_node_label(node_id.as_ref(), None); StoredEventFields { - session_id: None, - parent_session_id: None, node_id: node_id.clone(), node_label, + ..StoredEventFields::default() } } Event::ParallelBranchStarted { branch, .. } @@ -1341,10 +1344,9 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { let node_id = Some(branch.clone()); let node_label = default_node_label(node_id.as_ref(), None); StoredEventFields { - session_id: None, - parent_session_id: None, node_id, node_label, + ..StoredEventFields::default() } } Event::Prompt { stage, .. } @@ -1355,28 +1357,21 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { let node_id = Some(stage.clone()); let node_label = default_node_label(node_id.as_ref(), None); StoredEventFields { - session_id: None, - parent_session_id: None, node_id, node_label, + ..StoredEventFields::default() } } Event::StallWatchdogTimeout { node, .. } => { let node_id = Some(node.clone()); let node_label = default_node_label(node_id.as_ref(), None); StoredEventFields { - session_id: None, - parent_session_id: None, node_id, node_label, + ..StoredEventFields::default() } } - _ => StoredEventFields { - session_id: None, - parent_session_id: None, - node_id: None, - node_label: None, - }, + _ => StoredEventFields::default(), } } @@ -2394,8 +2389,13 @@ pub fn to_run_event_at(run_id: &RunId, event: &Event, ts: chrono::DateTime) run_id: *run_id, node_id: fields.node_id, node_label: fields.node_label, + stage_id: fields.stage_id, + parallel_group_id: fields.parallel_group_id, + parallel_branch_id: fields.parallel_branch_id, session_id: fields.session_id, parent_session_id: fields.parent_session_id, + tool_call_id: fields.tool_call_id, + actor: fields.actor, body, } } diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs index d4ff85107..6eb093010 100644 --- a/lib/crates/fabro-workflow/src/runtime_store.rs +++ b/lib/crates/fabro-workflow/src/runtime_store.rs @@ -193,8 +193,13 @@ mod tests { run_id: fixtures::RUN_1, node_id: None, node_label: None, + stage_id: None, + parallel_group_id: None, + parallel_branch_id: None, session_id: None, parent_session_id: None, + tool_call_id: None, + actor: None, body: EventBody::RunSubmitted(RunSubmittedProps { reason: None, definition_blob: None, From 91d610162b142d83cd750299b5d9058d8bfcad4a Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 10:14:45 -0400 Subject: [PATCH 02/13] feat(workflow): carry visit + parallel group/branch ids on stage events Adds visit: u32 to Event::StageStarted/Completed/Failed/Retrying so stored_event_fields() can derive stage_id = "{node_id}@{visit}". Adds parallel_group_id/parallel_branch_id to ParallelBranchStarted/ Completed Events, computed once in handler/parallel.rs from the parent parallel node id + visit_from_context + branch index. Emission sites in lifecycle/event.rs populate visit from state.node_visits via a new stage_visit helper. Stored_event_fields() still leaves stage_id and parallel ids None pending the extraction pass in the next commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/commands/run/run_progress/event.rs | 1 + .../src/commands/run/run_progress/mod.rs | 15 +++++++++++++++ lib/crates/fabro-cli/src/commands/store/dump.rs | 1 + lib/crates/fabro-workflow/src/error.rs | 1 + lib/crates/fabro-workflow/src/event.rs | 17 ++++++++++++++++- lib/crates/fabro-workflow/src/git.rs | 1 + .../fabro-workflow/src/handler/parallel.rs | 15 ++++++++++++++- .../fabro-workflow/src/lifecycle/event.rs | 15 +++++++++++++++ .../fabro-workflow/src/pipeline/pull_request.rs | 1 + 9 files changed, 65 insertions(+), 2 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs index f950f2216..20f5d98a0 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs @@ -510,6 +510,7 @@ mod tests { node_id: "plan".into(), name: "Plan".into(), index: 0, + visit: 1, duration_ms: 5000, status: "success".into(), preferred_label: None, diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index 0354d9ead..63507433e 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -487,6 +487,7 @@ mod tests { node_id: node_id.into(), name: name.into(), index: 0, + visit: 1, handler_type: String::new(), attempt: 1, max_attempts: 1, @@ -510,6 +511,7 @@ mod tests { node_id: node_id.into(), name: name.into(), index: 0, + visit: 1, duration_ms: 5000, status: "success".into(), preferred_label: None, @@ -561,6 +563,8 @@ mod tests { emit( &mut ui, Event::ParallelBranchStarted { + parallel_group_id: "fork1@1".into(), + parallel_branch_id: "fork1@1:0".into(), branch: "security".into(), index: 0, }, @@ -576,6 +580,8 @@ mod tests { emit( &mut ui, Event::ParallelBranchCompleted { + parallel_group_id: "fork1@1".into(), + parallel_branch_id: "fork1@1:0".into(), branch: "security".into(), index: 0, duration_ms: 2000, @@ -607,6 +613,8 @@ mod tests { emit( &mut ui, Event::ParallelBranchStarted { + parallel_group_id: "fork1@1".into(), + parallel_branch_id: "fork1@1:0".into(), branch: "security".into(), index: 0, }, @@ -700,6 +708,7 @@ mod tests { node_id: "code".into(), name: "Code".into(), index: 0, + visit: 1, attempt: 2, max_attempts: 3, delay_ms: 1500, @@ -944,6 +953,7 @@ mod tests { node_id: "code".into(), name: "Code".into(), index: 0, + visit: 1, attempt: 2, max_attempts: 3, delay_ms: 1500, @@ -1108,6 +1118,8 @@ mod tests { emit( &mut ui, Event::ParallelBranchStarted { + parallel_group_id: "fork1@1".into(), + parallel_branch_id: "fork1@1:0".into(), branch: "security".into(), index: 0, }, @@ -1115,6 +1127,8 @@ mod tests { emit( &mut ui, Event::ParallelBranchCompleted { + parallel_group_id: "fork1@1".into(), + parallel_branch_id: "fork1@1:0".into(), branch: "security".into(), index: 0, duration_ms: 500, @@ -1144,6 +1158,7 @@ mod tests { node_id: "code".into(), name: "Code".into(), index: 0, + visit: 1, handler_type: "agent".into(), attempt: 1, max_attempts: 1, diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 611f14c04..3f25253a5 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -599,6 +599,7 @@ mod tests { node_id: "code".to_string(), name: "Code".to_string(), index: 1, + visit: 2, duration_ms: 250, status: "partial_success".to_string(), preferred_label: None, diff --git a/lib/crates/fabro-workflow/src/error.rs b/lib/crates/fabro-workflow/src/error.rs index 942b442f1..b81f690a6 100644 --- a/lib/crates/fabro-workflow/src/error.rs +++ b/lib/crates/fabro-workflow/src/error.rs @@ -1692,6 +1692,7 @@ mod tests { node_id: "code".into(), name: "code".into(), index: 0, + visit: 1, failure: failure.clone(), will_retry: false, }; diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 220d58ee0..7bcf75194 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -137,6 +137,7 @@ pub enum Event { node_id: String, name: String, index: usize, + visit: u32, handler_type: String, attempt: usize, max_attempts: usize, @@ -145,6 +146,7 @@ pub enum Event { node_id: String, name: String, index: usize, + visit: u32, duration_ms: u64, status: String, preferred_label: Option, @@ -175,6 +177,7 @@ pub enum Event { node_id: String, name: String, index: usize, + visit: u32, failure: FailureDetail, will_retry: bool, }, @@ -182,6 +185,7 @@ pub enum Event { node_id: String, name: String, index: usize, + visit: u32, attempt: usize, max_attempts: usize, delay_ms: u64, @@ -193,10 +197,14 @@ pub enum Event { join_policy: String, }, ParallelBranchStarted { + parallel_group_id: String, + parallel_branch_id: String, branch: String, index: usize, }, ParallelBranchCompleted { + parallel_group_id: String, + parallel_branch_id: String, branch: String, index: usize, duration_ms: u64, @@ -676,6 +684,7 @@ impl Event { index, failure, will_retry, + .. } => { let error_msg = &failure.message; if *will_retry { @@ -705,6 +714,7 @@ impl Event { attempt, max_attempts, delay_ms, + .. } => { warn!( node_id, @@ -723,7 +733,7 @@ impl Event { } => { debug!(branch_count, join_policy, "Parallel execution started"); } - Self::ParallelBranchStarted { branch, index } => { + Self::ParallelBranchStarted { branch, index, .. } => { debug!(branch, index, "Parallel branch started"); } Self::ParallelBranchCompleted { @@ -2759,6 +2769,7 @@ mod tests { node_id: "plan".to_string(), name: "Plan".to_string(), index: 0, + visit: 1, duration_ms: 5000, status: "success".to_string(), preferred_label: None, @@ -2797,6 +2808,7 @@ mod tests { node_id: "plan".to_string(), name: "Plan".to_string(), index: 0, + visit: 1, duration_ms: 5000, status: "success".to_string(), preferred_label: None, @@ -2831,6 +2843,7 @@ mod tests { node_id: "code".to_string(), name: "Code".to_string(), index: 1, + visit: 1, failure: FailureDetail::new( "lint failed", crate::outcome::FailureCategory::Deterministic, @@ -3016,6 +3029,8 @@ mod tests { ); assert_eq!( event_name(&Event::ParallelBranchStarted { + parallel_group_id: "plan@1".to_string(), + parallel_branch_id: "plan@1:0".to_string(), branch: "fork".to_string(), index: 0, }), diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index 24ccf9fac..504bd03e5 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -449,6 +449,7 @@ mod tests { node_id: "work".into(), name: "Work".into(), index: 2, + visit: 2, duration_ms: 100, status: "success".into(), preferred_label: None, diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index 5c7b8158a..bec2bb5d8 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -132,6 +132,7 @@ impl Handler for ParallelHandler { struct BranchSetup { target_id: String, branch_index: usize, + parallel_branch_id: String, branch_context: Context, sandbox: Arc, worktree_path: Option, @@ -150,9 +151,12 @@ impl Handler for ParallelHandler { .unwrap_or("wait_all"), ); + let parallel_visit = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX); + let parallel_group_id = format!("{}@{}", node.id, parallel_visit); + services.emitter.emit(&Event::ParallelStarted { node_id: node.id.clone(), - visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), + visit: parallel_visit, branch_count: branches.len(), join_policy: join_policy.to_string(), }); @@ -253,9 +257,11 @@ impl Handler for ParallelHandler { (Arc::clone(&services.sandbox), None) }; + let parallel_branch_id = format!("{parallel_group_id}:{branch_index}"); branch_setups.push(BranchSetup { target_id, branch_index, + parallel_branch_id, branch_context, sandbox: branch_sandbox, worktree_path, @@ -283,6 +289,7 @@ impl Handler for ParallelHandler { .as_ref() .map(|gs| gs.git_author.clone()) .unwrap_or_default(); + let group_id = parallel_group_id.clone(); let handle = tokio::spawn(async move { let _permit = sem @@ -291,6 +298,8 @@ impl Handler for ParallelHandler { .map_err(|e| FabroError::handler(format!("semaphore error: {e}")))?; emitter.emit(&Event::ParallelBranchStarted { + parallel_group_id: group_id.clone(), + parallel_branch_id: setup.parallel_branch_id.clone(), branch: setup.target_id.clone(), index: setup.branch_index, }); @@ -302,6 +311,8 @@ impl Handler for ParallelHandler { setup.target_id )); emitter.emit(&Event::ParallelBranchCompleted { + parallel_group_id: group_id.clone(), + parallel_branch_id: setup.parallel_branch_id.clone(), branch: setup.target_id.clone(), index: setup.branch_index, duration_ms: millis_u64(branch_start.elapsed()), @@ -386,6 +397,8 @@ impl Handler for ParallelHandler { }; emitter.emit(&Event::ParallelBranchCompleted { + parallel_group_id: group_id.clone(), + parallel_branch_id: setup.parallel_branch_id.clone(), branch: setup.target_id.clone(), index: setup.branch_index, duration_ms: millis_u64(branch_start.elapsed()), diff --git a/lib/crates/fabro-workflow/src/lifecycle/event.rs b/lib/crates/fabro-workflow/src/lifecycle/event.rs index dbb4a5a2b..f6cbae9ed 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/event.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/event.rs @@ -79,6 +79,11 @@ fn response_from_outcome(node_id: &str, outcome: &Outcome) -> Option { .and_then(|value| value.as_str().map(ToOwned::to_owned)) } +fn stage_visit(state: &WfRunState, node_id: &str) -> u32 { + let visits = state.node_visits.get(node_id).copied().unwrap_or(1); + u32::try_from(visits.max(1)).unwrap_or(u32::MAX) +} + #[async_trait] impl RunLifecycle for EventLifecycle { async fn on_run_start(&self, _graph: &WorkflowGraph, _state: &WfRunState) -> CoreResult<()> { @@ -120,12 +125,14 @@ impl RunLifecycle for EventLifecycle { } let gv = node.inner(); let stage_index = state.stage_index; + let visit = stage_visit(state, &gv.id); let (loop_failure_signatures, restart_failure_signatures) = snapshot_failure_signatures(&self.circuit_breaker); self.emitter.emit(&Event::StageStarted { node_id: gv.id.clone(), name: gv.label().to_string(), index: stage_index, + visit, handler_type: gv.handler_type().unwrap_or_default().to_string(), attempt: 1, max_attempts: 1, @@ -134,6 +141,7 @@ impl RunLifecycle for EventLifecycle { node_id: gv.id.clone(), name: gv.label().to_string(), index: stage_index, + visit, duration_ms: 0, status: StageStatus::Success.to_string(), preferred_label: None, @@ -167,6 +175,7 @@ impl RunLifecycle for EventLifecycle { node_id: gv.id.clone(), name: gv.label().to_string(), index: state.stage_index, + visit: stage_visit(state, &gv.id), handler_type: gv.handler_type().unwrap_or_default().to_string(), attempt: ctx.attempt as usize, max_attempts: ctx.max_attempts as usize, @@ -183,11 +192,13 @@ impl RunLifecycle for EventLifecycle { let gv = ctx.node.inner(); let outcome = &ctx.result.outcome; let stage_index = state.stage_index; + let visit = stage_visit(state, &gv.id); self.emitter.emit(&Event::StageFailed { node_id: gv.id.clone(), name: gv.label().to_string(), index: stage_index, + visit, failure: outcome.failure.clone().unwrap_or_else(|| { FailureDetail::new("handler failed", FailureCategory::TransientInfra) }), @@ -198,6 +209,7 @@ impl RunLifecycle for EventLifecycle { node_id: gv.id.clone(), name: gv.label().to_string(), index: stage_index, + visit, attempt: ctx.attempt as usize, max_attempts: ctx.result.max_attempts as usize, delay_ms: ctx @@ -221,6 +233,7 @@ impl RunLifecycle for EventLifecycle { } let gv = node.inner(); let stage_index = state.stage_index; + let visit = stage_visit(state, &gv.id); let duration_ms = u64::try_from(result.duration.as_millis()).unwrap(); let (loop_failure_signatures, restart_failure_signatures) = snapshot_failure_signatures(&self.circuit_breaker); @@ -230,6 +243,7 @@ impl RunLifecycle for EventLifecycle { node_id: gv.id.clone(), name: gv.label().to_string(), index: stage_index, + visit, failure: outcome.failure.clone().unwrap_or_else(|| { FailureDetail::new("handler failed", FailureCategory::Deterministic) }), @@ -240,6 +254,7 @@ impl RunLifecycle for EventLifecycle { node_id: gv.id.clone(), name: gv.label().to_string(), index: stage_index, + visit, duration_ms, status: outcome.status.to_string(), preferred_label: outcome.preferred_label.clone(), diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 914a9daf5..0f8631b79 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -1197,6 +1197,7 @@ mod tests { node_id: "plan".to_string(), name: "plan".to_string(), index: 0, + visit: 1, duration_ms: 1, status: "success".to_string(), preferred_label: None, From b6eb462a057c5109d05564de735f2c5178c17a45 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 10:21:12 -0400 Subject: [PATCH 03/13] feat(workflow): populate new envelope fields in stored_event_fields Populates stage_id, parallel_group_id, parallel_branch_id, tool_call_id, and actor on RunEvent from the internal Event variants: - stage_id on stage.* events ("{node_id}@{visit}") - parallel_group_id on parallel.* events ("{node_id}@{visit}") - parallel_group_id + parallel_branch_id on parallel.branch.* - tool_call_id + stage_id on agent.tool.* events - actor=User from run.created provenance.subject.login - actor=Agent{session_id, model} on agent.message events Adds unit tests covering each extraction path. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-workflow/src/event.rs | 230 +++++++++++++++++++++++-- 1 file changed, 217 insertions(+), 13 deletions(-) diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 7bcf75194..80887a138 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -1268,7 +1268,7 @@ struct StoredEventFields { parallel_group_id: Option, parallel_branch_id: Option, tool_call_id: Option, - actor: Option, + actor: Option<::fabro_types::ActorRef>, } fn default_node_label(node_id: Option<&String>, node_label: Option) -> Option { @@ -1293,15 +1293,53 @@ fn stage_status_from_string(status: &str) -> StageStatus { fn stored_event_fields(event: &Event) -> StoredEventFields { match event { - Event::StageCompleted { node_id, name, .. } - | Event::StageFailed { node_id, name, .. } - | Event::StageStarted { node_id, name, .. } - | Event::StageRetrying { node_id, name, .. } => { - let node_id = Some(node_id.clone()); - let node_label = default_node_label(node_id.as_ref(), Some(name.clone())); + Event::RunCreated { provenance, .. } => StoredEventFields { + actor: provenance.as_ref().and_then(actor_from_provenance), + ..StoredEventFields::default() + }, + Event::StageCompleted { + node_id, + name, + visit, + .. + } + | Event::StageFailed { + node_id, + name, + visit, + .. + } + | Event::StageStarted { + node_id, + name, + visit, + .. + } + | Event::StageRetrying { + node_id, + name, + visit, + .. + } => { + let node_id_str = node_id.clone(); + let node_label = default_node_label(Some(&node_id_str), Some(name.clone())); + let stage_id = Some(format!("{node_id_str}@{visit}")); StoredEventFields { - node_id, + node_id: Some(node_id_str), node_label, + stage_id, + ..StoredEventFields::default() + } + } + Event::ParallelStarted { node_id, visit, .. } + | Event::ParallelCompleted { node_id, visit, .. } => { + let node_id_str = node_id.clone(); + let node_label = default_node_label(Some(&node_id_str), None); + let parallel_group_id = Some(format!("{node_id_str}@{visit}")); + StoredEventFields { + node_id: Some(node_id_str), + node_label, + parallel_group_id, ..StoredEventFields::default() } } @@ -1311,8 +1349,6 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { | Event::SubgraphCompleted { node_id, .. } | Event::ArtifactCaptured { node_id, .. } | Event::PromptCompleted { node_id, .. } - | Event::ParallelStarted { node_id, .. } - | Event::ParallelCompleted { node_id, .. } | Event::CommandStarted { node_id, .. } | Event::CommandCompleted { node_id, .. } | Event::AgentCliStarted { node_id, .. } @@ -1327,17 +1363,24 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { } Event::Agent { stage, + visit, + event: agent_event, session_id, parent_session_id, - .. } => { let node_id = Some(stage.clone()); let node_label = default_node_label(node_id.as_ref(), None); + let stage_id = Some(format!("{stage}@{visit}")); + let tool_call_id = agent_tool_call_id(agent_event).map(str::to_string); + let actor = agent_actor_for_event(agent_event, session_id.as_deref()); StoredEventFields { session_id: session_id.clone(), parent_session_id: parent_session_id.clone(), node_id, node_label, + stage_id, + tool_call_id, + actor, ..StoredEventFields::default() } } @@ -1349,13 +1392,25 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { ..StoredEventFields::default() } } - Event::ParallelBranchStarted { branch, .. } - | Event::ParallelBranchCompleted { branch, .. } => { + Event::ParallelBranchStarted { + parallel_group_id, + parallel_branch_id, + branch, + .. + } + | Event::ParallelBranchCompleted { + parallel_group_id, + parallel_branch_id, + branch, + .. + } => { let node_id = Some(branch.clone()); let node_label = default_node_label(node_id.as_ref(), None); StoredEventFields { node_id, node_label, + parallel_group_id: Some(parallel_group_id.clone()), + parallel_branch_id: Some(parallel_branch_id.clone()), ..StoredEventFields::default() } } @@ -1385,6 +1440,40 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { } } +fn actor_from_provenance( + provenance: &::fabro_types::RunProvenance, +) -> Option<::fabro_types::ActorRef> { + let subject = provenance.subject.as_ref()?; + let login = subject.login.clone()?; + Some(::fabro_types::ActorRef { + kind: ::fabro_types::ActorKind::User, + id: Some(login.clone()), + display: Some(login), + }) +} + +fn agent_tool_call_id(event: &AgentEvent) -> Option<&str> { + match event { + AgentEvent::ToolCallStarted { tool_call_id, .. } + | AgentEvent::ToolCallCompleted { tool_call_id, .. } => Some(tool_call_id.as_str()), + _ => None, + } +} + +fn agent_actor_for_event( + event: &AgentEvent, + session_id: Option<&str>, +) -> Option<::fabro_types::ActorRef> { + match event { + AgentEvent::AssistantMessage { model, .. } => Some(::fabro_types::ActorRef { + kind: ::fabro_types::ActorKind::Agent, + id: session_id.map(str::to_string), + display: Some(model.clone()), + }), + _ => None, + } +} + fn event_body_from_event(event: &Event) -> EventBody { match event { Event::RunCreated { @@ -2794,6 +2883,7 @@ mod tests { assert_eq!(stored.run_id, fixtures::RUN_2); assert_eq!(stored.node_id.as_deref(), Some("plan")); assert_eq!(stored.node_label.as_deref(), Some("Plan")); + assert_eq!(stored.stage_id.as_deref(), Some("plan@1")); let properties = stored.properties().unwrap(); assert_eq!(properties["duration_ms"], 5000); assert_eq!(properties["status"], "success"); @@ -3051,4 +3141,118 @@ mod tests { "agent.sub.spawned" ); } + + #[test] + fn parallel_started_populates_parallel_group_id() { + let stored = to_run_event( + &fixtures::RUN_1, + &Event::ParallelStarted { + node_id: "fanout".to_string(), + visit: 2, + branch_count: 3, + join_policy: "wait_all".to_string(), + }, + ); + assert_eq!(stored.parallel_group_id.as_deref(), Some("fanout@2")); + assert!(stored.parallel_branch_id.is_none()); + } + + #[test] + fn parallel_branch_started_populates_group_and_branch_ids() { + let stored = to_run_event( + &fixtures::RUN_1, + &Event::ParallelBranchStarted { + parallel_group_id: "fanout@2".to_string(), + parallel_branch_id: "fanout@2:1".to_string(), + branch: "review".to_string(), + index: 1, + }, + ); + assert_eq!(stored.parallel_group_id.as_deref(), Some("fanout@2")); + assert_eq!(stored.parallel_branch_id.as_deref(), Some("fanout@2:1")); + } + + #[test] + fn agent_tool_started_populates_tool_call_id_and_stage_id() { + let stored = to_run_event( + &fixtures::RUN_1, + &Event::Agent { + stage: "code".to_string(), + visit: 3, + event: AgentEvent::ToolCallStarted { + tool_name: "read_file".to_string(), + tool_call_id: "call_abc".to_string(), + arguments: serde_json::json!({"path": "src/main.rs"}), + }, + session_id: Some("ses_1".to_string()), + parent_session_id: None, + }, + ); + assert_eq!(stored.stage_id.as_deref(), Some("code@3")); + assert_eq!(stored.tool_call_id.as_deref(), Some("call_abc")); + } + + #[test] + fn agent_assistant_message_populates_agent_actor() { + let stored = to_run_event( + &fixtures::RUN_1, + &Event::Agent { + stage: "code".to_string(), + visit: 1, + event: AgentEvent::AssistantMessage { + text: "ok".to_string(), + model: "claude-sonnet".to_string(), + usage: LlmTokenCounts::default(), + tool_call_count: 0, + }, + session_id: Some("ses_agent".to_string()), + parent_session_id: None, + }, + ); + let actor = stored.actor.as_ref().expect("actor set"); + assert_eq!(actor.kind, ::fabro_types::ActorKind::Agent); + assert_eq!(actor.id.as_deref(), Some("ses_agent")); + assert_eq!(actor.display.as_deref(), Some("claude-sonnet")); + } + + #[test] + fn run_created_populates_user_actor_from_provenance() { + use ::fabro_types::{ + Graph, RunAuthMethod, RunProvenance, RunSubjectProvenance, Settings, fixtures, + }; + + let provenance = RunProvenance { + server: None, + client: None, + subject: Some(RunSubjectProvenance { + login: Some("alice".to_string()), + auth_method: RunAuthMethod::Cookie, + }), + }; + + let stored = to_run_event( + &fixtures::RUN_1, + &Event::RunCreated { + run_id: fixtures::RUN_1, + settings: serde_json::to_value(Settings::default()).unwrap(), + graph: serde_json::to_value(Graph::new("test")).unwrap(), + workflow_source: None, + workflow_config: None, + labels: Default::default(), + run_dir: "/tmp/run".to_string(), + working_directory: "/tmp/run".to_string(), + host_repo_path: None, + repo_origin_url: None, + base_branch: None, + workflow_slug: None, + db_prefix: None, + provenance: Some(provenance), + manifest_blob: None, + }, + ); + let actor = stored.actor.as_ref().expect("actor set"); + assert_eq!(actor.kind, ::fabro_types::ActorKind::User); + assert_eq!(actor.id.as_deref(), Some("alice")); + assert_eq!(actor.display.as_deref(), Some("alice")); + } } From 51dc4350a526c84ca2c35f1eddd556371cc02f43 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 10:40:21 -0400 Subject: [PATCH 04/13] feat(api): flatten EventEnvelope wire JSON (schema v2) Wire EventEnvelope now inlines the RunEvent payload fields alongside seq at the top level of the JSON object. The internal Rust EventEnvelope { seq, payload } stays structurally unchanged; only the API/SSE serialization layer flattens for clients. - OpenAPI spec: add stage_id, parallel_group_id, parallel_branch_id, tool_call_id, actor to RunEvent; model EventEnvelope as allOf(seq, RunEvent); introduce ActorRef/ActorKind schemas. - fabro-server: rewrite api_event_envelope_from_store to merge seq into the payload JSON value before returning the generated flat type; remove the now-unused nested ApiRunEvent conversion helper. - fabro-cli server_client: add wire_event_envelope_into_store helper that turns flat wire JSON back into fabro_store::EventEnvelope { seq, payload } for internal consumers. - Regenerate progenitor Rust types and typescript-axios client. - Update demo stubs, SSE tests, CLI test helpers, and insta snapshots to expect the flattened shape and the new stage_id field. Incidental: the typescript regeneration also picked up prior-merged spec fields (ApiQuestion stage/timeout/context, upload manifest batches, web-settings) that were stale in the TS client. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/api-reference/fabro-api.yaml | 79 ++++++++++++++++--- lib/crates/fabro-cli/src/server_client.rs | 40 +++++++++- lib/crates/fabro-cli/tests/it/cmd/attach.rs | 3 + lib/crates/fabro-cli/tests/it/cmd/run.rs | 38 +++++---- lib/crates/fabro-cli/tests/it/cmd/support.rs | 32 +++++++- .../fabro-cli/tests/it/scenario/smoke.rs | 42 +++++----- lib/crates/fabro-server/src/demo/mod.rs | 38 ++++----- lib/crates/fabro-server/src/server.rs | 40 ++++++---- .../tests/it/scenario/lifecycle.rs | 8 +- .../tests/it/scenario/run_completion.rs | 2 +- .../fabro-server/tests/it/scenario/sse.rs | 2 +- .../src/.openapi-generator/FILES | 2 + .../src/api/run-internals-api.ts | 9 ++- .../fabro-api-client/src/models/actor-kind.ts | 30 +++++++ .../fabro-api-client/src/models/actor-ref.ts | 36 +++++++++ .../src/models/api-question.ts | 12 +++ .../src/models/artifact-batch-upload-entry.ts | 3 +- .../models/artifact-batch-upload-manifest.ts | 3 +- .../src/models/event-envelope.ts | 15 ++-- .../fabro-api-client/src/models/index.ts | 2 + .../fabro-api-client/src/models/run-event.ts | 20 +++++ .../src/models/web-settings.ts | 4 + 22 files changed, 346 insertions(+), 114 deletions(-) create mode 100644 lib/packages/fabro-api-client/src/models/actor-kind.ts create mode 100644 lib/packages/fabro-api-client/src/models/actor-ref.ts diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 76cbc8ffe..d12a17ce9 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -2620,6 +2620,32 @@ components: items: $ref: "#/components/schemas/ErrorResponseEntry" + ActorKind: + description: High-level category of an event actor. + type: string + enum: + - user + - agent + - system + + ActorRef: + description: > + Optional primary actor associated with a run event. Present on control + actions and durable agent output where a stable user or agent identity + matters; omitted on routine runtime lifecycle events. + type: object + required: + - kind + properties: + kind: + $ref: "#/components/schemas/ActorKind" + id: + type: string + description: Stable actor identifier when available. + display: + type: string + description: Display-friendly label for the actor. + RunEvent: description: > Internal RunEvent-compatible JSON payload. The server validates this @@ -2644,12 +2670,39 @@ components: node_label: type: string nullable: true + stage_id: + type: string + nullable: true + description: Stage execution identity, formatted as "{node_id}@{visit}". + parallel_group_id: + type: string + nullable: true + description: > + Durable identity of one execution of a parallel node, formatted as + "{node_id}@{visit}". + parallel_branch_id: + type: string + nullable: true + description: > + Durable identity of one branch within a parallel execution, + formatted as "{parallel_group_id}:{index}". session_id: type: string nullable: true parent_session_id: type: string nullable: true + tool_call_id: + type: string + nullable: true + description: > + Stable identifier for a tool call, present on agent.tool.* events + and other durable events that directly describe the same tool + call. + actor: + allOf: + - $ref: "#/components/schemas/ActorRef" + nullable: true event: type: string description: Event type discriminator. @@ -2660,18 +2713,20 @@ components: additionalProperties: true EventEnvelope: - description: Stored event envelope with assigned sequence number. - type: object - required: - - seq - - payload - properties: - seq: - type: integer - description: Assigned event sequence number. - example: 42 - payload: - $ref: "#/components/schemas/RunEvent" + description: > + Stored event envelope with assigned sequence number. On the wire the + envelope is flattened: seq sits alongside the RunEvent payload fields + at the top level of the JSON object. + allOf: + - type: object + required: + - seq + properties: + seq: + type: integer + description: Assigned event sequence number. + example: 42 + - $ref: "#/components/schemas/RunEvent" PaginatedEventList: description: Paginated list of stored run events. diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index 410331208..b5fc56bc0 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -72,13 +72,47 @@ impl RunAttachEventStream { fn buffer_sse_events(&mut self, finalize: bool) -> Result<()> { for payload in sse::drain_sse_payloads(&mut self.pending_bytes, finalize) { - let event: types::EventEnvelope = serde_json::from_str(&payload)?; - self.buffered_events.push_back(convert_type(event)?); + let value: serde_json::Value = serde_json::from_str(&payload)?; + self.buffered_events + .push_back(wire_event_envelope_into_store(value)?); } Ok(()) } } +/// Converts a flattened wire `EventEnvelope` JSON value (seq alongside the +/// RunEvent payload fields at the top level) into the internal +/// `fabro_store::EventEnvelope` which keeps seq and payload separate. +fn wire_event_envelope_into_store(value: serde_json::Value) -> Result { + let serde_json::Value::Object(mut obj) = value else { + bail!("expected wire EventEnvelope JSON object"); + }; + let seq_value = obj + .remove("seq") + .context("wire EventEnvelope missing seq field")?; + let seq: u32 = match seq_value { + serde_json::Value::Number(n) => n + .as_u64() + .and_then(|v| u32::try_from(v).ok()) + .context("wire EventEnvelope seq is out of u32 range")?, + _ => bail!("wire EventEnvelope seq is not a number"), + }; + let run_id_str = obj + .get("run_id") + .and_then(|v| v.as_str()) + .context("wire EventEnvelope missing run_id")?; + let run_id: RunId = run_id_str.parse().context("invalid run_id in wire event")?; + let payload = fabro_store::EventPayload::new(serde_json::Value::Object(obj), &run_id) + .map_err(|err| anyhow!("wire EventEnvelope payload failed store validation: {err}"))?; + Ok(EventEnvelope { seq, payload }) +} + +fn wire_event_envelope_from_generated(value: types::EventEnvelope) -> Result { + let value = + serde_json::to_value(value).context("failed to serialize generated EventEnvelope")?; + wire_event_envelope_into_store(value) +} + pub(crate) use fabro_store::RunProjection; pub(crate) async fn connect_server(storage_dir: &Path) -> Result { @@ -407,7 +441,7 @@ impl ServerStoreClient { let page_events = parsed .data .into_iter() - .map(convert_type) + .map(wire_event_envelope_from_generated) .collect::>>()?; let next_page_since_seq = page_events.last().map(|event| event.seq.saturating_add(1)); all_events.extend(page_events); diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 4233ba8d3..cedc4882d 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -647,6 +647,7 @@ fn attach_json_errors_without_prompting_for_human_input() { "max_attempts": 1 }, "run_id": "[ULID]", + "stage_id": "start@1", "ts": "[TIMESTAMP]" }, { @@ -674,6 +675,7 @@ fn attach_json_errors_without_prompting_for_human_input() { "status": "success" }, "run_id": "[ULID]", + "stage_id": "start@1", "ts": "[TIMESTAMP]" }, { @@ -738,6 +740,7 @@ fn attach_json_errors_without_prompting_for_human_input() { "max_attempts": 1 }, "run_id": "[ULID]", + "stage_id": "approve@1", "ts": "[TIMESTAMP]" }, { diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index ce90bd159..606e6ccfa 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -74,16 +74,14 @@ fn remote_run_state_response() -> serde_json::Value { fn run_completed_event(run_id: &str) -> serde_json::Value { serde_json::json!({ "seq": 1, - "payload": { - "event": "run.completed", - "id": "evt-run-completed", - "run_id": run_id, - "ts": "2026-04-05T12:00:01Z", - "properties": { - "duration_ms": 12, - "artifact_count": 0, - "status": "success" - } + "event": "run.completed", + "id": "evt-run-completed", + "run_id": run_id, + "ts": "2026-04-05T12:00:01Z", + "properties": { + "duration_ms": 12, + "artifact_count": 0, + "status": "success" } }) } @@ -91,13 +89,11 @@ fn run_completed_event(run_id: &str) -> serde_json::Value { fn run_running_event(run_id: &str, seq: u32) -> serde_json::Value { serde_json::json!({ "seq": seq, - "payload": { - "event": "run.running", - "id": format!("evt-run-running-{seq}"), - "run_id": run_id, - "ts": "2026-04-05T12:00:00Z", - "properties": {} - } + "event": "run.running", + "id": format!("evt-run-running-{seq}"), + "run_id": run_id, + "ts": "2026-04-05T12:00:00Z", + "properties": {} }) } @@ -956,6 +952,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "max_attempts": 1 }, "run_id": "[ULID]", + "stage_id": "start@1", "ts": "[TIMESTAMP]" }, { @@ -983,6 +980,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "status": "success" }, "run_id": "[ULID]", + "stage_id": "start@1", "ts": "[TIMESTAMP]" }, { @@ -1047,6 +1045,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "max_attempts": 1 }, "run_id": "[ULID]", + "stage_id": "approve@1", "ts": "[TIMESTAMP]" }, { @@ -1125,6 +1124,7 @@ fn json_run_implies_auto_approve_for_human_gates() { ] }, "run_id": "[ULID]", + "stage_id": "approve@1", "ts": "[TIMESTAMP]" }, { @@ -1213,6 +1213,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "max_attempts": 1 }, "run_id": "[ULID]", + "stage_id": "ship@1", "ts": "[TIMESTAMP]" }, { @@ -1285,6 +1286,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "status": "success" }, "run_id": "[ULID]", + "stage_id": "ship@1", "ts": "[TIMESTAMP]" }, { @@ -1383,6 +1385,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "max_attempts": 1 }, "run_id": "[ULID]", + "stage_id": "exit@1", "ts": "[TIMESTAMP]" }, { @@ -1398,6 +1401,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "status": "success" }, "run_id": "[ULID]", + "stage_id": "exit@1", "ts": "[TIMESTAMP]" }, { diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 2f9a98713..5823a60de 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -661,7 +661,37 @@ pub(crate) fn run_events(run_dir: &Path) -> Vec { run_dir, &format!("/api/v1/runs/{run_id}/events"), )); - serde_json::from_value(response["data"].clone()).expect("event list should parse") + let items = response["data"] + .as_array() + .cloned() + .expect("event list response should contain a data array"); + items + .into_iter() + .map(wire_event_envelope_value_into_store) + .collect::, _>>() + .expect("wire event envelope list should parse") +} + +fn wire_event_envelope_value_into_store(value: serde_json::Value) -> Result { + let mut obj = match value { + serde_json::Value::Object(obj) => obj, + _ => return Err("wire envelope is not an object".to_string()), + }; + let seq = obj + .remove("seq") + .and_then(|v| v.as_u64()) + .and_then(|v| u32::try_from(v).ok()) + .ok_or_else(|| "wire envelope missing valid seq".to_string())?; + let run_id_str = obj + .get("run_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| "wire envelope missing run_id".to_string())?; + let run_id: RunId = run_id_str + .parse() + .map_err(|err| format!("invalid run_id in wire envelope: {err}"))?; + let payload = fabro_store::EventPayload::new(serde_json::Value::Object(obj), &run_id) + .map_err(|err| format!("wire envelope payload failed store validation: {err}"))?; + Ok(EventEnvelope { seq, payload }) } pub(crate) fn wait_for_event_names(run_dir: &Path, expected: &[&str]) { diff --git a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs index 1f1ee67f5..1deb67413 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/smoke.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/smoke.rs @@ -29,16 +29,14 @@ fn live_run_state_response() -> serde_json::Value { fn run_sse_body(run_id: &str) -> String { let completed = serde_json::json!({ "seq": 2, - "payload": { - "event": "run.completed", - "id": "evt-run-completed", - "run_id": run_id, - "ts": "2026-04-05T12:00:01Z", - "properties": { - "duration_ms": 12, - "artifact_count": 0, - "status": "success" - } + "event": "run.completed", + "id": "evt-run-completed", + "run_id": run_id, + "ts": "2026-04-05T12:00:01Z", + "properties": { + "duration_ms": 12, + "artifact_count": 0, + "status": "success" } }); @@ -267,13 +265,11 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { serde_json::json!({ "data": [{ "seq": 1, - "payload": { - "event": "run.running", - "id": "evt-run-running", - "run_id": success_run_id, - "ts": "2026-04-05T12:00:00Z", - "properties": {} - } + "event": "run.running", + "id": "evt-run-running", + "run_id": success_run_id, + "ts": "2026-04-05T12:00:00Z", + "properties": {} }], "meta": { "has_more": false } }) @@ -367,13 +363,11 @@ fn attach_smoke_covers_arg_validation_and_remote_server_behaviors() { serde_json::json!({ "data": [{ "seq": 1, - "payload": { - "event": "run.running", - "id": "evt-run-running", - "run_id": eof_run_id, - "ts": "2026-04-05T12:00:00Z", - "properties": {} - } + "event": "run.running", + "id": "evt-run-running", + "run_id": eof_run_id, + "ts": "2026-04-05T12:00:00Z", + "properties": {} }], "meta": { "has_more": false } }) diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 60ff9c54a..c1144a1dd 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -240,16 +240,14 @@ pub(crate) async fn run_events_stub( Event::default().data( json!({ "seq": 2, - "payload": { - "id": "evt_demo_attach_completed", - "ts": "2026-04-06T15:00:02Z", - "run_id": "01JQ0000000000000000000001", - "event": "run.completed", - "properties": { - "duration_ms": 42, - "artifact_count": 0, - "status": "success" - } + "id": "evt_demo_attach_completed", + "ts": "2026-04-06T15:00:02Z", + "run_id": "01JQ0000000000000000000001", + "event": "run.completed", + "properties": { + "duration_ms": 42, + "artifact_count": 0, + "status": "success" } }) .to_string(), @@ -507,12 +505,10 @@ pub(crate) async fn attach_events_stub( Event::default().data( json!({ "seq": 1, - "payload": { - "id": "evt_demo_1", - "ts": "2026-04-06T15:00:00Z", - "run_id": "01JQ0000000000000000000001", - "event": "run.started" - } + "id": "evt_demo_1", + "ts": "2026-04-06T15:00:00Z", + "run_id": "01JQ0000000000000000000001", + "event": "run.started" }) .to_string(), ), @@ -521,12 +517,10 @@ pub(crate) async fn attach_events_stub( Event::default().data( json!({ "seq": 2, - "payload": { - "id": "evt_demo_2", - "ts": "2026-04-06T15:00:01Z", - "run_id": "01JQ0000000000000000000001", - "event": "stage.started" - } + "id": "evt_demo_2", + "ts": "2026-04-06T15:00:01Z", + "run_id": "01JQ0000000000000000000001", + "event": "stage.started" }) .to_string(), ), diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 94d158599..76e8b5e13 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -111,10 +111,10 @@ pub use fabro_api::types::{ QuestionType as ApiQuestionType, RenderWorkflowGraphDirection, RenderWorkflowGraphFormat, RenderWorkflowGraphRequest, RunArtifactEntry, RunArtifactListResponse, RunBilling, RunBillingStage, RunBillingTotals, RunControlAction as ApiRunControlAction, RunError, - RunEvent as ApiRunEvent, RunManifest, RunStatus, RunStatusResponse, SandboxFileEntry, - SandboxFileListResponse, ServerSettings, SetSecretRequest, SshAccessRequest, SshAccessResponse, - StartRunRequest, StatusReason as ApiStatusReason, SubmitAnswerRequest, SystemInfoResponse, - SystemRunCounts, WriteBlobResponse, + RunManifest, RunStatus, RunStatusResponse, SandboxFileEntry, SandboxFileListResponse, + ServerSettings, SetSecretRequest, SshAccessRequest, SshAccessResponse, StartRunRequest, + StatusReason as ApiStatusReason, SubmitAnswerRequest, SystemInfoResponse, SystemRunCounts, + WriteBlobResponse, }; use fabro_graphviz::render::GraphFormat; @@ -2380,8 +2380,28 @@ fn octet_stream_response(bytes: Bytes) -> Response { } #[allow(clippy::result_large_err)] -fn api_run_event_from_store(payload: &EventPayload) -> Result { - serde_json::from_value(payload.as_value().clone()).map_err(|err| { +fn api_event_envelope_from_store(event: &EventEnvelope) -> Result { + // Wire EventEnvelope is flattened: seq sits alongside the RunEvent + // payload fields at the top level. The progenitor-generated type + // reflects that shape, so we merge seq into the payload value and + // deserialize directly. + let mut value = event.payload.as_value().clone(); + match value.as_object_mut() { + Some(map) => { + map.insert( + "seq".to_string(), + serde_json::Value::Number(i64::from(event.seq).into()), + ); + } + None => { + return Err(ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "stored event payload is not a JSON object".to_string(), + ) + .into_response()); + } + } + serde_json::from_value(value).map_err(|err| { ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize stored event: {err}"), @@ -2390,14 +2410,6 @@ fn api_run_event_from_store(payload: &EventPayload) -> Result Result { - Ok(ApiEventEnvelope { - payload: api_run_event_from_store(&event.payload)?, - seq: i64::from(event.seq), - }) -} - fn clear_live_run_state(run: &mut ManagedRun) { run.answer_transport = None; run.accepted_questions.clear(); diff --git a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs index ea0a4cc24..4a5d062f6 100644 --- a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs +++ b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs @@ -270,14 +270,12 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() { .unwrap() .iter() .filter_map(|event| { - (event["payload"]["event"] == "run.failed").then(|| { + (event["event"] == "run.failed").then(|| { ( - event["payload"]["properties"]["reason"] - .as_str() - .map(ToOwned::to_owned), - event["payload"]["properties"]["error"] + event["properties"]["reason"] .as_str() .map(ToOwned::to_owned), + event["properties"]["error"].as_str().map(ToOwned::to_owned), ) }) }) diff --git a/lib/crates/fabro-server/tests/it/scenario/run_completion.rs b/lib/crates/fabro-server/tests/it/scenario/run_completion.rs index 7b2dfb915..c8952956a 100644 --- a/lib/crates/fabro-server/tests/it/scenario/run_completion.rs +++ b/lib/crates/fabro-server/tests/it/scenario/run_completion.rs @@ -73,7 +73,7 @@ async fn attach_run_events_replays_terminal_event_after_completion() { .lines() .filter_map(|line| line.strip_prefix("data:")) .filter_map(|line| serde_json::from_str::(line.trim()).ok()) - .filter_map(|event| event["payload"]["event"].as_str().map(ToString::to_string)) + .filter_map(|event| event["event"].as_str().map(ToString::to_string)) .collect::>(); assert!( diff --git a/lib/crates/fabro-server/tests/it/scenario/sse.rs b/lib/crates/fabro-server/tests/it/scenario/sse.rs index 2f67b1685..cb2cb16a3 100644 --- a/lib/crates/fabro-server/tests/it/scenario/sse.rs +++ b/lib/crates/fabro-server/tests/it/scenario/sse.rs @@ -84,7 +84,7 @@ async fn sse_stream_contains_expected_event_types() { if let Some(json_str) = line.strip_prefix("data:") { let json_str = json_str.trim(); if let Ok(event) = serde_json::from_str::(json_str) { - if let Some(event_name) = event["payload"]["event"].as_str() { + if let Some(event_name) = event["event"].as_str() { event_types.push(event_name.to_string()); } } diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index f894e9b11..88db0f6d7 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -16,6 +16,8 @@ base.ts common.ts configuration.ts index.ts +models/actor-kind.ts +models/actor-ref.ts models/aggregate-billing-totals.ts models/aggregate-billing.ts models/api-question-option.ts diff --git a/lib/packages/fabro-api-client/src/api/run-internals-api.ts b/lib/packages/fabro-api-client/src/api/run-internals-api.ts index 12509d35c..a23b543a9 100644 --- a/lib/packages/fabro-api-client/src/api/run-internals-api.ts +++ b/lib/packages/fabro-api-client/src/api/run-internals-api.ts @@ -481,7 +481,7 @@ export const RunInternalsApiAxiosParamCreator = function (configuration?: Config }; }, /** - * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. + * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. @@ -847,7 +847,7 @@ export const RunInternalsApiFp = function(configuration?: Configuration) { return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); }, /** - * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. + * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. @@ -1028,7 +1028,7 @@ export const RunInternalsApiFactory = function (configuration?: Configuration, b return localVarFp.listStageTurns(id, stageId, pageLimit, pageOffset, options).then((request) => request(axios, basePath)); }, /** - * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. + * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. @@ -1201,7 +1201,7 @@ export class RunInternalsApi extends BaseAPI { } /** - * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. + * Uploads one or more artifacts for a stage. Intended for trusted internal callers. The server accepts both: - `application/octet-stream` for single-file uploads with the `filename` query parameter - strict manifest-first `multipart/form-data` uploads documented by `ArtifactBatchUploadManifest` The generated Rust client currently exposes the octet-stream variant because the OpenAPI code generator in this repo does not support multiple request media types on one operation. * @summary Put Stage Artifact * @param {string} id Unique run identifier (ULID). * @param {string} stageId Identifier of a stage within a run\'s workflow graph, serialized as `node_id@visit`. @@ -1260,3 +1260,4 @@ export class RunInternalsApi extends BaseAPI { return RunInternalsApiFp(this.configuration).writeRunBlob(id, body, options).then((request) => request(this.axios, this.basePath)); } } + diff --git a/lib/packages/fabro-api-client/src/models/actor-kind.ts b/lib/packages/fabro-api-client/src/models/actor-kind.ts new file mode 100644 index 000000000..abdc2fb3b --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/actor-kind.ts @@ -0,0 +1,30 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * High-level category of an event actor. + */ + +export const ActorKind = { + USER: 'user', + AGENT: 'agent', + SYSTEM: 'system' +} as const; + +export type ActorKind = typeof ActorKind[keyof typeof ActorKind]; + + + diff --git a/lib/packages/fabro-api-client/src/models/actor-ref.ts b/lib/packages/fabro-api-client/src/models/actor-ref.ts new file mode 100644 index 000000000..491cd29e4 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/actor-ref.ts @@ -0,0 +1,36 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +// May contain unused imports in some cases +// @ts-ignore +import type { ActorKind } from './actor-kind'; + +/** + * Optional primary actor associated with a run event. Present on control actions and durable agent output where a stable user or agent identity matters; omitted on routine runtime lifecycle events. + */ +export interface ActorRef { + 'kind': ActorKind; + /** + * Stable actor identifier when available. + */ + 'id'?: string; + /** + * Display-friendly label for the actor. + */ + 'display'?: string; +} + + + diff --git a/lib/packages/fabro-api-client/src/models/api-question.ts b/lib/packages/fabro-api-client/src/models/api-question.ts index fa10acb0f..78ea79cd3 100644 --- a/lib/packages/fabro-api-client/src/models/api-question.ts +++ b/lib/packages/fabro-api-client/src/models/api-question.ts @@ -32,6 +32,10 @@ export interface ApiQuestion { * The question text displayed to the user. */ 'text': string; + /** + * Workflow stage identifier that produced the question. + */ + 'stage': string; 'question_type': QuestionType; /** * Available options for selection-based questions. Empty for freeform questions. @@ -41,6 +45,14 @@ export interface ApiQuestion { * Whether the user may provide freeform text in addition to selecting options. */ 'allow_freeform': boolean; + /** + * Timeout for the question when configured by the workflow. + */ + 'timeout_seconds'?: number; + /** + * Optional contextual text shown alongside the question. + */ + 'context_display'?: string; } diff --git a/lib/packages/fabro-api-client/src/models/artifact-batch-upload-entry.ts b/lib/packages/fabro-api-client/src/models/artifact-batch-upload-entry.ts index 8a42ae05a..3a4545f4e 100644 --- a/lib/packages/fabro-api-client/src/models/artifact-batch-upload-entry.ts +++ b/lib/packages/fabro-api-client/src/models/artifact-batch-upload-entry.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -39,3 +39,4 @@ export interface ArtifactBatchUploadEntry { */ 'content_type'?: string; } + diff --git a/lib/packages/fabro-api-client/src/models/artifact-batch-upload-manifest.ts b/lib/packages/fabro-api-client/src/models/artifact-batch-upload-manifest.ts index ad483a824..8b0d60046 100644 --- a/lib/packages/fabro-api-client/src/models/artifact-batch-upload-manifest.ts +++ b/lib/packages/fabro-api-client/src/models/artifact-batch-upload-manifest.ts @@ -5,7 +5,7 @@ * HTTP API for managing Fabro workflow run executions. * * The version of the OpenAPI document: 0.1.0 - * + * * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). * https://openapi-generator.tech @@ -23,3 +23,4 @@ import type { ArtifactBatchUploadEntry } from './artifact-batch-upload-entry'; export interface ArtifactBatchUploadManifest { 'entries': Array; } + diff --git a/lib/packages/fabro-api-client/src/models/event-envelope.ts b/lib/packages/fabro-api-client/src/models/event-envelope.ts index fd7d04aea..3b99be9e1 100644 --- a/lib/packages/fabro-api-client/src/models/event-envelope.ts +++ b/lib/packages/fabro-api-client/src/models/event-envelope.ts @@ -13,18 +13,17 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { ActorRef } from './actor-ref'; // May contain unused imports in some cases // @ts-ignore import type { RunEvent } from './run-event'; /** - * Stored event envelope with assigned sequence number. + * @type EventEnvelope + * Stored event envelope with assigned sequence number. On the wire the envelope is flattened: seq sits alongside the RunEvent payload fields at the top level of the JSON object. */ -export interface EventEnvelope { - /** - * Assigned event sequence number. - */ - 'seq': number; - 'payload': RunEvent; -} +export type EventEnvelope = RunEvent; + diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index aecd845cf..adc64cfd0 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -1,3 +1,5 @@ +export * from './actor-kind'; +export * from './actor-ref'; export * from './aggregate-billing'; export * from './aggregate-billing-totals'; export * from './api-question'; diff --git a/lib/packages/fabro-api-client/src/models/run-event.ts b/lib/packages/fabro-api-client/src/models/run-event.ts index c9090d435..f7319b815 100644 --- a/lib/packages/fabro-api-client/src/models/run-event.ts +++ b/lib/packages/fabro-api-client/src/models/run-event.ts @@ -13,6 +13,9 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { ActorRef } from './actor-ref'; /** * Internal RunEvent-compatible JSON payload. The server validates this body by deserializing into the typed RunEvent struct. @@ -25,8 +28,25 @@ export interface RunEvent { 'run_id': string; 'node_id'?: string; 'node_label'?: string; + /** + * Stage execution identity, formatted as \"{node_id}@{visit}\". + */ + 'stage_id'?: string; + /** + * Durable identity of one execution of a parallel node, formatted as \"{node_id}@{visit}\". + */ + 'parallel_group_id'?: string; + /** + * Durable identity of one branch within a parallel execution, formatted as \"{parallel_group_id}:{index}\". + */ + 'parallel_branch_id'?: string; 'session_id'?: string; 'parent_session_id'?: string; + /** + * Stable identifier for a tool call, present on agent.tool.* events and other durable events that directly describe the same tool call. + */ + 'tool_call_id'?: string; + 'actor'?: ActorRef; /** * Event type discriminator. */ diff --git a/lib/packages/fabro-api-client/src/models/web-settings.ts b/lib/packages/fabro-api-client/src/models/web-settings.ts index a1bd5dd22..cbe3dee56 100644 --- a/lib/packages/fabro-api-client/src/models/web-settings.ts +++ b/lib/packages/fabro-api-client/src/models/web-settings.ts @@ -21,6 +21,10 @@ import type { AuthSettings } from './auth-settings'; * Web UI configuration. */ export interface WebSettings { + /** + * Whether the embedded web UI and browser-oriented routes are enabled. + */ + 'enabled'?: boolean; /** * Web UI URL. */ From b51403ae646e3aa661a753a7b49ac504d5af1f53 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 11:08:38 -0400 Subject: [PATCH 05/13] refactor(events): simplify envelope metadata plumbing Centralize flattened EventEnvelope conversion in fabro-store so the CLI, server, and test helpers reuse one wire-shape path. Also thread parallel group and branch ids through nested stage and agent events so the new envelope fields stay populated inside parallel branches. --- lib/crates/fabro-cli/src/server_client.rs | 31 +------ lib/crates/fabro-cli/tests/it/cmd/support.rs | 25 +----- lib/crates/fabro-cli/tests/it/workflow/mod.rs | 10 ++- lib/crates/fabro-server/src/server.rs | 27 ++----- lib/crates/fabro-store/src/types.rs | 81 +++++++++++++++++++ lib/crates/fabro-workflow/src/context.rs | 36 +++++++++ lib/crates/fabro-workflow/src/error.rs | 2 + lib/crates/fabro-workflow/src/event.rs | 79 +++++++++++++++++- lib/crates/fabro-workflow/src/git.rs | 2 + .../fabro-workflow/src/handler/agent.rs | 2 + .../fabro-workflow/src/handler/llm/api.rs | 26 +++++- .../fabro-workflow/src/handler/parallel.rs | 14 +++- .../fabro-workflow/src/lifecycle/event.rs | 26 ++++++ .../src/pipeline/pull_request.rs | 2 + .../fabro-workflow/src/pipeline/retro.rs | 2 + 15 files changed, 280 insertions(+), 85 deletions(-) diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index b5fc56bc0..a4aa44822 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -74,43 +74,16 @@ impl RunAttachEventStream { for payload in sse::drain_sse_payloads(&mut self.pending_bytes, finalize) { let value: serde_json::Value = serde_json::from_str(&payload)?; self.buffered_events - .push_back(wire_event_envelope_into_store(value)?); + .push_back(EventEnvelope::from_wire_value(value)?); } Ok(()) } } -/// Converts a flattened wire `EventEnvelope` JSON value (seq alongside the -/// RunEvent payload fields at the top level) into the internal -/// `fabro_store::EventEnvelope` which keeps seq and payload separate. -fn wire_event_envelope_into_store(value: serde_json::Value) -> Result { - let serde_json::Value::Object(mut obj) = value else { - bail!("expected wire EventEnvelope JSON object"); - }; - let seq_value = obj - .remove("seq") - .context("wire EventEnvelope missing seq field")?; - let seq: u32 = match seq_value { - serde_json::Value::Number(n) => n - .as_u64() - .and_then(|v| u32::try_from(v).ok()) - .context("wire EventEnvelope seq is out of u32 range")?, - _ => bail!("wire EventEnvelope seq is not a number"), - }; - let run_id_str = obj - .get("run_id") - .and_then(|v| v.as_str()) - .context("wire EventEnvelope missing run_id")?; - let run_id: RunId = run_id_str.parse().context("invalid run_id in wire event")?; - let payload = fabro_store::EventPayload::new(serde_json::Value::Object(obj), &run_id) - .map_err(|err| anyhow!("wire EventEnvelope payload failed store validation: {err}"))?; - Ok(EventEnvelope { seq, payload }) -} - fn wire_event_envelope_from_generated(value: types::EventEnvelope) -> Result { let value = serde_json::to_value(value).context("failed to serialize generated EventEnvelope")?; - wire_event_envelope_into_store(value) + EventEnvelope::from_wire_value(value).map_err(Into::into) } pub(crate) use fabro_store::RunProjection; diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 5823a60de..62e7cf38e 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -14,7 +14,6 @@ use fabro_config::Storage; use fabro_server::bind::Bind; use fabro_store::EventEnvelope; use fabro_test::TestContext; -use fabro_types::RunId; use serde_json::Value; use shlex::try_quote; @@ -667,33 +666,11 @@ pub(crate) fn run_events(run_dir: &Path) -> Vec { .expect("event list response should contain a data array"); items .into_iter() - .map(wire_event_envelope_value_into_store) + .map(EventEnvelope::from_wire_value) .collect::, _>>() .expect("wire event envelope list should parse") } -fn wire_event_envelope_value_into_store(value: serde_json::Value) -> Result { - let mut obj = match value { - serde_json::Value::Object(obj) => obj, - _ => return Err("wire envelope is not an object".to_string()), - }; - let seq = obj - .remove("seq") - .and_then(|v| v.as_u64()) - .and_then(|v| u32::try_from(v).ok()) - .ok_or_else(|| "wire envelope missing valid seq".to_string())?; - let run_id_str = obj - .get("run_id") - .and_then(|v| v.as_str()) - .ok_or_else(|| "wire envelope missing run_id".to_string())?; - let run_id: RunId = run_id_str - .parse() - .map_err(|err| format!("invalid run_id in wire envelope: {err}"))?; - let payload = fabro_store::EventPayload::new(serde_json::Value::Object(obj), &run_id) - .map_err(|err| format!("wire envelope payload failed store validation: {err}"))?; - Ok(EventEnvelope { seq, payload }) -} - pub(crate) fn wait_for_event_names(run_dir: &Path, expected: &[&str]) { let deadline = std::time::Instant::now() + COMMAND_TIMEOUT; diff --git a/lib/crates/fabro-cli/tests/it/workflow/mod.rs b/lib/crates/fabro-cli/tests/it/workflow/mod.rs index 0a5d25e37..6fe7383f1 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/mod.rs @@ -173,7 +173,15 @@ fn run_events(run_dir: &Path) -> Vec { storage_dir, &format!("/api/v1/runs/{run_id}/events"), )); - serde_json::from_value(response["data"].clone()).expect("event list should parse") + let items = response["data"] + .as_array() + .cloned() + .expect("event list response should contain a data array"); + items + .into_iter() + .map(EventEnvelope::from_wire_value) + .collect::, _>>() + .expect("wire event envelope list should parse") } macro_rules! sandbox_tests { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 76e8b5e13..1672da3d2 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -2381,26 +2381,13 @@ fn octet_stream_response(bytes: Bytes) -> Response { #[allow(clippy::result_large_err)] fn api_event_envelope_from_store(event: &EventEnvelope) -> Result { - // Wire EventEnvelope is flattened: seq sits alongside the RunEvent - // payload fields at the top level. The progenitor-generated type - // reflects that shape, so we merge seq into the payload value and - // deserialize directly. - let mut value = event.payload.as_value().clone(); - match value.as_object_mut() { - Some(map) => { - map.insert( - "seq".to_string(), - serde_json::Value::Number(i64::from(event.seq).into()), - ); - } - None => { - return Err(ApiError::new( - StatusCode::INTERNAL_SERVER_ERROR, - "stored event payload is not a JSON object".to_string(), - ) - .into_response()); - } - } + let value = event.to_wire_value().map_err(|err| { + ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to serialize stored event: {err}"), + ) + .into_response() + })?; serde_json::from_value(value).map_err(|err| { ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/lib/crates/fabro-store/src/types.rs b/lib/crates/fabro-store/src/types.rs index 80073b6f3..1c63553b5 100644 --- a/lib/crates/fabro-store/src/types.rs +++ b/lib/crates/fabro-store/src/types.rs @@ -85,3 +85,84 @@ pub struct EventEnvelope { pub seq: u32, pub payload: EventPayload, } + +impl EventEnvelope { + pub fn from_wire_value(value: serde_json::Value) -> Result { + let serde_json::Value::Object(mut obj) = value else { + return Err(StoreError::InvalidEvent( + "wire EventEnvelope must be a JSON object".into(), + )); + }; + let seq = obj + .remove("seq") + .and_then(|value| value.as_u64()) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| { + StoreError::InvalidEvent("wire EventEnvelope missing valid seq".into()) + })?; + let run_id = obj + .get("run_id") + .and_then(|value| value.as_str()) + .ok_or_else(|| StoreError::InvalidEvent("wire EventEnvelope missing run_id".into()))? + .parse() + .map_err(|err| StoreError::InvalidEvent(format!("invalid wire run_id: {err}")))?; + let payload = EventPayload::new(serde_json::Value::Object(obj), &run_id)?; + Ok(Self { seq, payload }) + } + + pub fn to_wire_value(&self) -> Result { + let mut value = self.payload.as_value().clone(); + let map = value.as_object_mut().ok_or_else(|| { + StoreError::InvalidEvent("stored event payload must be a JSON object".into()) + })?; + map.insert( + "seq".to_string(), + serde_json::Value::Number(u64::from(self.seq).into()), + ); + Ok(value) + } +} + +#[cfg(test)] +mod tests { + use chrono::{TimeZone, Utc}; + + use fabro_types::{EventBody, RunEvent, fixtures, run_event::RunCompletedProps}; + + use super::{EventEnvelope, EventPayload}; + + #[test] + fn wire_event_envelope_round_trips() { + let event = RunEvent { + id: "evt_1".to_string(), + ts: Utc.with_ymd_and_hms(2026, 4, 9, 12, 0, 0).unwrap(), + run_id: fixtures::RUN_1, + node_id: Some("code".to_string()), + node_label: Some("Code".to_string()), + stage_id: Some("code@1".to_string()), + parallel_group_id: None, + parallel_branch_id: None, + session_id: None, + parent_session_id: None, + tool_call_id: None, + actor: None, + body: EventBody::RunCompleted(RunCompletedProps { + duration_ms: 42, + artifact_count: 0, + status: "success".to_string(), + reason: None, + total_usd_micros: None, + final_git_commit_sha: None, + final_patch: None, + billing: None, + }), + }; + let payload = EventPayload::new(event.to_value().unwrap(), &fixtures::RUN_1).unwrap(); + let envelope = EventEnvelope { seq: 7, payload }; + + let wire = envelope.to_wire_value().unwrap(); + let parsed = EventEnvelope::from_wire_value(wire).unwrap(); + + assert_eq!(parsed, envelope); + } +} diff --git a/lib/crates/fabro-workflow/src/context.rs b/lib/crates/fabro-workflow/src/context.rs index fc3cc9463..2b33ceedb 100644 --- a/lib/crates/fabro-workflow/src/context.rs +++ b/lib/crates/fabro-workflow/src/context.rs @@ -23,6 +23,8 @@ pub mod keys { pub const INTERNAL_THREAD_ID: &str = "internal.thread_id"; pub const INTERNAL_NODE_VISIT_COUNT: &str = "internal.node_visit_count"; pub const INTERNAL_PARENT_PREAMBLE: &str = "internal.parent_preamble"; + pub const INTERNAL_PARALLEL_GROUP_ID: &str = "internal.parallel_group_id"; + pub const INTERNAL_PARALLEL_BRANCH_ID: &str = "internal.parallel_branch_id"; // --- current.* keys --- pub const CURRENT_PREAMBLE: &str = "current.preamble"; @@ -141,6 +143,8 @@ pub trait WorkflowContext { fn thread_id(&self) -> Option; fn preamble(&self) -> String; fn run_id(&self) -> String; + fn parallel_group_id(&self) -> Option; + fn parallel_branch_id(&self) -> Option; } impl WorkflowContext for Context { @@ -162,6 +166,16 @@ impl WorkflowContext for Context { fn run_id(&self) -> String { self.get_string(keys::INTERNAL_RUN_ID, "unknown") } + + fn parallel_group_id(&self) -> Option { + self.get(keys::INTERNAL_PARALLEL_GROUP_ID) + .and_then(|value| value.as_str().map(String::from)) + } + + fn parallel_branch_id(&self) -> Option { + self.get(keys::INTERNAL_PARALLEL_BRANCH_ID) + .and_then(|value| value.as_str().map(String::from)) + } } #[cfg(test)] @@ -309,6 +323,28 @@ mod tests { assert_eq!(ctx.thread_id(), Some("main".to_string())); } + #[test] + fn parallel_ids_default() { + let ctx = Context::new(); + assert_eq!(ctx.parallel_group_id(), None); + assert_eq!(ctx.parallel_branch_id(), None); + } + + #[test] + fn parallel_ids_set() { + let ctx = Context::new(); + ctx.set( + keys::INTERNAL_PARALLEL_GROUP_ID, + serde_json::json!("fanout@2"), + ); + ctx.set( + keys::INTERNAL_PARALLEL_BRANCH_ID, + serde_json::json!("fanout@2:1"), + ); + assert_eq!(ctx.parallel_group_id(), Some("fanout@2".to_string())); + assert_eq!(ctx.parallel_branch_id(), Some("fanout@2:1".to_string())); + } + #[test] fn node_visit_count_default() { let ctx = Context::new(); diff --git a/lib/crates/fabro-workflow/src/error.rs b/lib/crates/fabro-workflow/src/error.rs index b81f690a6..a6ea8e01a 100644 --- a/lib/crates/fabro-workflow/src/error.rs +++ b/lib/crates/fabro-workflow/src/error.rs @@ -1693,6 +1693,8 @@ mod tests { name: "code".into(), index: 0, visit: 1, + parallel_group_id: None, + parallel_branch_id: None, failure: failure.clone(), will_retry: false, }; diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 80887a138..eddf0f2d7 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -5,7 +5,8 @@ use std::sync::atomic::{AtomicI64, Ordering}; use ::fabro_types::run_event as fabro_types; use ::fabro_types::{ - BilledTokenCounts, RunBlobId, RunControlAction, RunEvent, RunId, StageStatus, StatusReason, + BilledTokenCounts, RunBlobId, RunControlAction, RunEvent, RunId, StageId, StageStatus, + StatusReason, }; use anyhow::{Context, Result}; use chrono::Utc; @@ -138,6 +139,10 @@ pub enum Event { name: String, index: usize, visit: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + parallel_group_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + parallel_branch_id: Option, handler_type: String, attempt: usize, max_attempts: usize, @@ -147,6 +152,10 @@ pub enum Event { name: String, index: usize, visit: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + parallel_group_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + parallel_branch_id: Option, duration_ms: u64, status: String, preferred_label: Option, @@ -178,6 +187,10 @@ pub enum Event { name: String, index: usize, visit: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + parallel_group_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + parallel_branch_id: Option, failure: FailureDetail, will_retry: bool, }, @@ -186,6 +199,10 @@ pub enum Event { name: String, index: usize, visit: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + parallel_group_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + parallel_branch_id: Option, attempt: usize, max_attempts: usize, delay_ms: u64, @@ -360,6 +377,10 @@ pub enum Event { session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] parent_session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + parallel_group_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + parallel_branch_id: Option, }, SubgraphStarted { node_id: String, @@ -1301,33 +1322,43 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { node_id, name, visit, + parallel_group_id, + parallel_branch_id, .. } | Event::StageFailed { node_id, name, visit, + parallel_group_id, + parallel_branch_id, .. } | Event::StageStarted { node_id, name, visit, + parallel_group_id, + parallel_branch_id, .. } | Event::StageRetrying { node_id, name, visit, + parallel_group_id, + parallel_branch_id, .. } => { let node_id_str = node_id.clone(); let node_label = default_node_label(Some(&node_id_str), Some(name.clone())); - let stage_id = Some(format!("{node_id_str}@{visit}")); + let stage_id = Some(StageId::new(node_id_str.clone(), *visit).to_string()); StoredEventFields { node_id: Some(node_id_str), node_label, stage_id, + parallel_group_id: parallel_group_id.clone(), + parallel_branch_id: parallel_branch_id.clone(), ..StoredEventFields::default() } } @@ -1335,7 +1366,7 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { | Event::ParallelCompleted { node_id, visit, .. } => { let node_id_str = node_id.clone(); let node_label = default_node_label(Some(&node_id_str), None); - let parallel_group_id = Some(format!("{node_id_str}@{visit}")); + let parallel_group_id = Some(StageId::new(node_id_str.clone(), *visit).to_string()); StoredEventFields { node_id: Some(node_id_str), node_label, @@ -1367,10 +1398,12 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { event: agent_event, session_id, parent_session_id, + parallel_group_id, + parallel_branch_id, } => { let node_id = Some(stage.clone()); let node_label = default_node_label(node_id.as_ref(), None); - let stage_id = Some(format!("{stage}@{visit}")); + let stage_id = Some(StageId::new(stage.clone(), *visit).to_string()); let tool_call_id = agent_tool_call_id(agent_event).map(str::to_string); let actor = agent_actor_for_event(agent_event, session_id.as_deref()); StoredEventFields { @@ -1379,6 +1412,8 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { node_id, node_label, stage_id, + parallel_group_id: parallel_group_id.clone(), + parallel_branch_id: parallel_branch_id.clone(), tool_call_id, actor, ..StoredEventFields::default() @@ -2859,6 +2894,8 @@ mod tests { name: "Plan".to_string(), index: 0, visit: 1, + parallel_group_id: None, + parallel_branch_id: None, duration_ms: 5000, status: "success".to_string(), preferred_label: None, @@ -2899,6 +2936,8 @@ mod tests { name: "Plan".to_string(), index: 0, visit: 1, + parallel_group_id: None, + parallel_branch_id: None, duration_ms: 5000, status: "success".to_string(), preferred_label: None, @@ -2934,6 +2973,8 @@ mod tests { name: "Code".to_string(), index: 1, visit: 1, + parallel_group_id: None, + parallel_branch_id: None, failure: FailureDetail::new( "lint failed", crate::outcome::FailureCategory::Deterministic, @@ -2963,6 +3004,8 @@ mod tests { }, session_id: Some("ses_child".to_string()), parent_session_id: Some("ses_parent".to_string()), + parallel_group_id: None, + parallel_branch_id: None, }, ); @@ -3137,11 +3180,33 @@ mod tests { }, session_id: None, parent_session_id: None, + parallel_group_id: None, + parallel_branch_id: None, }), "agent.sub.spawned" ); } + #[test] + fn stage_started_populates_parallel_ids_when_present() { + let stored = to_run_event( + &fixtures::RUN_1, + &Event::StageStarted { + node_id: "review".to_string(), + name: "review".to_string(), + index: 1, + visit: 1, + parallel_group_id: Some("fanout@2".to_string()), + parallel_branch_id: Some("fanout@2:1".to_string()), + handler_type: "agent".to_string(), + attempt: 1, + max_attempts: 1, + }, + ); + assert_eq!(stored.parallel_group_id.as_deref(), Some("fanout@2")); + assert_eq!(stored.parallel_branch_id.as_deref(), Some("fanout@2:1")); + } + #[test] fn parallel_started_populates_parallel_group_id() { let stored = to_run_event( @@ -3186,10 +3251,14 @@ mod tests { }, session_id: Some("ses_1".to_string()), parent_session_id: None, + parallel_group_id: Some("fanout@2".to_string()), + parallel_branch_id: Some("fanout@2:0".to_string()), }, ); assert_eq!(stored.stage_id.as_deref(), Some("code@3")); assert_eq!(stored.tool_call_id.as_deref(), Some("call_abc")); + assert_eq!(stored.parallel_group_id.as_deref(), Some("fanout@2")); + assert_eq!(stored.parallel_branch_id.as_deref(), Some("fanout@2:0")); } #[test] @@ -3207,6 +3276,8 @@ mod tests { }, session_id: Some("ses_agent".to_string()), parent_session_id: None, + parallel_group_id: None, + parallel_branch_id: None, }, ); let actor = stored.actor.as_ref().expect("actor set"); diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index 504bd03e5..6f1a84551 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -450,6 +450,8 @@ mod tests { name: "Work".into(), index: 2, visit: 2, + parallel_group_id: None, + parallel_branch_id: None, duration_ms: 100, status: "success".into(), preferred_label: None, diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 6c1beb854..85ea0e26d 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -719,6 +719,8 @@ mod tests { }, session_id: Some("session_123".to_string()), parent_session_id: None, + parallel_group_id: context.parallel_group_id(), + parallel_branch_id: context.parallel_branch_id(), }); Ok(CodergenResult::Text { text: "done".to_string(), diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 7c59044ef..d8f7a934f 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -41,6 +41,21 @@ fn current_visit(context: &Context) -> u32 { u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX) } +#[derive(Clone)] +struct StageEventScope { + visit: u32, + parallel_group_id: Option, + parallel_branch_id: Option, +} + +fn current_stage_event_scope(context: &Context) -> StageEventScope { + StageEventScope { + visit: current_visit(context), + parallel_group_id: context.parallel_group_id(), + parallel_branch_id: context.parallel_branch_id(), + } +} + /// Shared state for tracking file modifications from agent tool calls. struct FileTracking { /// Maps tool_call_id → file_path for in-flight write/edit calls. @@ -86,7 +101,7 @@ fn track_file_event(event: &AgentEvent, state: &mut FileTracking) { fn spawn_event_forwarder( session: &Session, node_id: String, - visit: u32, + scope: StageEventScope, emitter: Arc, file_tracking: Arc>, ) { @@ -105,10 +120,12 @@ fn spawn_event_forwarder( { emitter.emit(&Event::Agent { stage: node_id.clone(), - visit, + visit: scope.visit, event: event.event.clone(), session_id: Some(event.session_id.clone()), parent_session_id: event.parent_session_id.clone(), + parallel_group_id: scope.parallel_group_id.clone(), + parallel_branch_id: scope.parallel_branch_id.clone(), }); } } @@ -455,12 +472,13 @@ impl CodergenBackend for AgentApiBackend { touched: HashSet::new(), last: None, })); + let event_scope = current_stage_event_scope(context); // Subscribe to session events: forward to pipeline emitter + track files. spawn_event_forwarder( &session, node.id.clone(), - current_visit(context), + event_scope.clone(), Arc::clone(emitter), Arc::clone(&file_tracking), ); @@ -525,7 +543,7 @@ impl CodergenBackend for AgentApiBackend { spawn_event_forwarder( &session, node.id.clone(), - current_visit(context), + event_scope.clone(), Arc::clone(emitter), Arc::clone(&file_tracking), ); diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index bec2bb5d8..ccd07125d 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -4,7 +4,7 @@ use std::time::Instant; use async_trait::async_trait; use fabro_agent::{Sandbox, WorktreeOptions, WorktreeSandbox}; -use fabro_types::RunId; +use fabro_types::{RunId, StageId}; use tokio::sync::Semaphore; use crate::context::keys; @@ -152,7 +152,7 @@ impl Handler for ParallelHandler { ); let parallel_visit = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX); - let parallel_group_id = format!("{}@{}", node.id, parallel_visit); + let parallel_group_id = StageId::new(node.id.clone(), parallel_visit).to_string(); services.emitter.emit(&Event::ParallelStarted { node_id: node.id.clone(), @@ -208,6 +208,15 @@ impl Handler for ParallelHandler { for (branch_index, edge) in branches.iter().enumerate() { let target_id = edge.to.clone(); let branch_context = context.fork(); + let parallel_branch_id = format!("{parallel_group_id}:{branch_index}"); + branch_context.set( + keys::INTERNAL_PARALLEL_GROUP_ID, + serde_json::json!(¶llel_group_id), + ); + branch_context.set( + keys::INTERNAL_PARALLEL_BRANCH_ID, + serde_json::json!(¶llel_branch_id), + ); let (branch_sandbox, worktree_path): (Arc, Option) = if let ( Some(ref gs), @@ -257,7 +266,6 @@ impl Handler for ParallelHandler { (Arc::clone(&services.sandbox), None) }; - let parallel_branch_id = format!("{parallel_group_id}:{branch_index}"); branch_setups.push(BranchSetup { target_id, branch_index, diff --git a/lib/crates/fabro-workflow/src/lifecycle/event.rs b/lib/crates/fabro-workflow/src/lifecycle/event.rs index f6cbae9ed..6bca3b85e 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/event.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/event.rs @@ -17,6 +17,7 @@ use super::circuit_breaker::CircuitBreakerLifecycle; use super::git::GitCheckpointResult; use crate::artifact; use crate::context; +use crate::context::WorkflowContext; use crate::error::FabroError; use crate::event::{Emitter, Event}; use crate::graph::WorkflowGraph; @@ -84,6 +85,13 @@ fn stage_visit(state: &WfRunState, node_id: &str) -> u32 { u32::try_from(visits.max(1)).unwrap_or(u32::MAX) } +fn stage_parallel_ids(state: &WfRunState) -> (Option, Option) { + ( + state.context.parallel_group_id(), + state.context.parallel_branch_id(), + ) +} + #[async_trait] impl RunLifecycle for EventLifecycle { async fn on_run_start(&self, _graph: &WorkflowGraph, _state: &WfRunState) -> CoreResult<()> { @@ -126,6 +134,7 @@ impl RunLifecycle for EventLifecycle { let gv = node.inner(); let stage_index = state.stage_index; let visit = stage_visit(state, &gv.id); + let (parallel_group_id, parallel_branch_id) = stage_parallel_ids(state); let (loop_failure_signatures, restart_failure_signatures) = snapshot_failure_signatures(&self.circuit_breaker); self.emitter.emit(&Event::StageStarted { @@ -133,6 +142,8 @@ impl RunLifecycle for EventLifecycle { name: gv.label().to_string(), index: stage_index, visit, + parallel_group_id: parallel_group_id.clone(), + parallel_branch_id: parallel_branch_id.clone(), handler_type: gv.handler_type().unwrap_or_default().to_string(), attempt: 1, max_attempts: 1, @@ -142,6 +153,8 @@ impl RunLifecycle for EventLifecycle { name: gv.label().to_string(), index: stage_index, visit, + parallel_group_id, + parallel_branch_id, duration_ms: 0, status: StageStatus::Success.to_string(), preferred_label: None, @@ -171,11 +184,14 @@ impl RunLifecycle for EventLifecycle { state: &WfRunState, ) -> CoreResult>> { let gv = ctx.node.inner(); + let (parallel_group_id, parallel_branch_id) = stage_parallel_ids(state); self.emitter.emit(&Event::StageStarted { node_id: gv.id.clone(), name: gv.label().to_string(), index: state.stage_index, visit: stage_visit(state, &gv.id), + parallel_group_id, + parallel_branch_id, handler_type: gv.handler_type().unwrap_or_default().to_string(), attempt: ctx.attempt as usize, max_attempts: ctx.max_attempts as usize, @@ -193,12 +209,15 @@ impl RunLifecycle for EventLifecycle { let outcome = &ctx.result.outcome; let stage_index = state.stage_index; let visit = stage_visit(state, &gv.id); + let (parallel_group_id, parallel_branch_id) = stage_parallel_ids(state); self.emitter.emit(&Event::StageFailed { node_id: gv.id.clone(), name: gv.label().to_string(), index: stage_index, visit, + parallel_group_id: parallel_group_id.clone(), + parallel_branch_id: parallel_branch_id.clone(), failure: outcome.failure.clone().unwrap_or_else(|| { FailureDetail::new("handler failed", FailureCategory::TransientInfra) }), @@ -210,6 +229,8 @@ impl RunLifecycle for EventLifecycle { name: gv.label().to_string(), index: stage_index, visit, + parallel_group_id, + parallel_branch_id, attempt: ctx.attempt as usize, max_attempts: ctx.result.max_attempts as usize, delay_ms: ctx @@ -234,6 +255,7 @@ impl RunLifecycle for EventLifecycle { let gv = node.inner(); let stage_index = state.stage_index; let visit = stage_visit(state, &gv.id); + let (parallel_group_id, parallel_branch_id) = stage_parallel_ids(state); let duration_ms = u64::try_from(result.duration.as_millis()).unwrap(); let (loop_failure_signatures, restart_failure_signatures) = snapshot_failure_signatures(&self.circuit_breaker); @@ -244,6 +266,8 @@ impl RunLifecycle for EventLifecycle { name: gv.label().to_string(), index: stage_index, visit, + parallel_group_id, + parallel_branch_id, failure: outcome.failure.clone().unwrap_or_else(|| { FailureDetail::new("handler failed", FailureCategory::Deterministic) }), @@ -255,6 +279,8 @@ impl RunLifecycle for EventLifecycle { name: gv.label().to_string(), index: stage_index, visit, + parallel_group_id, + parallel_branch_id, duration_ms, status: outcome.status.to_string(), preferred_label: outcome.preferred_label.clone(), diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 0f8631b79..33c5bd1e8 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -1198,6 +1198,8 @@ mod tests { name: "plan".to_string(), index: 0, visit: 1, + parallel_group_id: None, + parallel_branch_id: None, duration_ms: 1, status: "success".to_string(), preferred_label: None, diff --git a/lib/crates/fabro-workflow/src/pipeline/retro.rs b/lib/crates/fabro-workflow/src/pipeline/retro.rs index 1511c0ced..7073e4cf7 100644 --- a/lib/crates/fabro-workflow/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflow/src/pipeline/retro.rs @@ -76,6 +76,8 @@ pub async fn run_retro(options: &RetroOptions, dry_run: bool) -> Option { event: event.event.clone(), session_id: Some(event.session_id.clone()), parent_session_id: event.parent_session_id.clone(), + parallel_group_id: None, + parallel_branch_id: None, }); } }) From 747ccc03837f46146764e2fd5f0713909ea4d57f Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 11:42:02 -0400 Subject: [PATCH 06/13] refactor(events): tidy schema v2 plumbing Quality cleanup on top of the v2 envelope commits: - fabro-workflow/src/event.rs: add ActorKind/ActorRef/RunProvenance to the existing ::fabro_types import block so call sites can use unqualified names (restores CLAUDE.md import style). Extract a node_stored_fields helper to collapse 4 near-identical match arms in stored_event_fields. Drop the no-op ..default() from the Agent arm where all 9 fields are set explicitly. - fabro-types/src/run_event/mod.rs: collapse 9 copies of the obj.get/as_str/to_string chain in from_ref behind an opt_str closure. - fabro-server/src/server.rs: dedupe the two identical error closures in api_event_envelope_from_store. Skip the typed ApiEventEnvelope roundtrip in sse_event_from_store so streamed events go straight from the wire Value to a JSON string. - fabro-workflow/src/handler/llm/api.rs: inline current_visit into its sole caller current_stage_event_scope. Also fixes pre-existing test compile breakage carried in by the v2 commits: restore the fabro_types::RunId import in support.rs (removed by b51403ae but still referenced by find_run_dir), and thread parallel_group_id/parallel_branch_id: None through 9 Event::Stage*/Event::Agent constructors in run_progress and store/dump tests that 91d61016 missed. No behavior change aside from the SSE hot path avoiding one full strong-type deserialize + reserialize per event. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/commands/run/run_progress/event.rs | 4 + .../src/commands/run/run_progress/mod.rs | 12 +++ .../fabro-cli/src/commands/store/dump.rs | 2 + lib/crates/fabro-cli/tests/it/cmd/support.rs | 1 + lib/crates/fabro-server/src/server.rs | 17 ++-- lib/crates/fabro-types/src/run_event/mod.rs | 41 ++------- lib/crates/fabro-workflow/src/event.rs | 85 ++++++------------- .../fabro-workflow/src/handler/llm/api.rs | 6 +- 8 files changed, 62 insertions(+), 106 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs index 20f5d98a0..880338894 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs @@ -511,6 +511,8 @@ mod tests { name: "Plan".into(), index: 0, visit: 1, + parallel_group_id: None, + parallel_branch_id: None, duration_ms: 5000, status: "success".into(), preferred_label: None, @@ -555,6 +557,8 @@ mod tests { }, session_id: None, parent_session_id: None, + parallel_group_id: None, + parallel_branch_id: None, }; let stored = to_run_event(&fixtures::RUN_1, &event); diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index 63507433e..5c5ced36d 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -479,6 +479,8 @@ mod tests { event, session_id: None, parent_session_id: None, + parallel_group_id: None, + parallel_branch_id: None, } } @@ -488,6 +490,8 @@ mod tests { name: name.into(), index: 0, visit: 1, + parallel_group_id: None, + parallel_branch_id: None, handler_type: String::new(), attempt: 1, max_attempts: 1, @@ -512,6 +516,8 @@ mod tests { name: name.into(), index: 0, visit: 1, + parallel_group_id: None, + parallel_branch_id: None, duration_ms: 5000, status: "success".into(), preferred_label: None, @@ -709,6 +715,8 @@ mod tests { name: "Code".into(), index: 0, visit: 1, + parallel_group_id: None, + parallel_branch_id: None, attempt: 2, max_attempts: 3, delay_ms: 1500, @@ -954,6 +962,8 @@ mod tests { name: "Code".into(), index: 0, visit: 1, + parallel_group_id: None, + parallel_branch_id: None, attempt: 2, max_attempts: 3, delay_ms: 1500, @@ -1159,6 +1169,8 @@ mod tests { name: "Code".into(), index: 0, visit: 1, + parallel_group_id: None, + parallel_branch_id: None, handler_type: "agent".into(), attempt: 1, max_attempts: 1, diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 3f25253a5..929deb4c1 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -600,6 +600,8 @@ mod tests { name: "Code".to_string(), index: 1, visit: 2, + parallel_group_id: None, + parallel_branch_id: None, duration_ms: 250, status: "partial_success".to_string(), preferred_label: None, diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 62e7cf38e..d93851add 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -14,6 +14,7 @@ use fabro_config::Storage; use fabro_server::bind::Bind; use fabro_store::EventEnvelope; use fabro_test::TestContext; +use fabro_types::RunId; use serde_json::Value; use shlex::try_quote; diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 1672da3d2..acf1fce6a 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -1480,8 +1480,8 @@ fn event_matches_run_filter(event: &EventEnvelope, run_filter: Option<&HashSet Option { - let event = api_event_envelope_from_store(event).ok()?; - let data = serde_json::to_string(&event).ok()?; + let wire = event.to_wire_value().ok()?; + let data = serde_json::to_string(&wire).ok()?; let data = redact_jsonl_line(&data); Some(Event::default().data(data)) } @@ -2381,20 +2381,15 @@ fn octet_stream_response(bytes: Bytes) -> Response { #[allow(clippy::result_large_err)] fn api_event_envelope_from_store(event: &EventEnvelope) -> Result { - let value = event.to_wire_value().map_err(|err| { + fn serialize_error(err: impl std::fmt::Display) -> Response { ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to serialize stored event: {err}"), ) .into_response() - })?; - serde_json::from_value(value).map_err(|err| { - ApiError::new( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to serialize stored event: {err}"), - ) - .into_response() - }) + } + let value = event.to_wire_value().map_err(serialize_error)?; + serde_json::from_value(value).map_err(serialize_error) } fn clear_live_run_state(run: &mut ManagedRun) { diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index 56f771698..d8a1bc1db 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -595,6 +595,7 @@ impl RunEvent { let obj = value.as_object().ok_or_else(|| { ::custom("run event must be a JSON object") })?; + let opt_str = |key: &str| obj.get(key).and_then(Value::as_str).map(str::to_string); let id = obj.get("id").and_then(Value::as_str).ok_or_else(|| { ::custom("missing or non-string field: id") })?; @@ -621,38 +622,14 @@ impl RunEvent { id: id.to_string(), ts, run_id, - node_id: obj - .get("node_id") - .and_then(Value::as_str) - .map(str::to_string), - node_label: obj - .get("node_label") - .and_then(Value::as_str) - .map(str::to_string), - stage_id: obj - .get("stage_id") - .and_then(Value::as_str) - .map(str::to_string), - parallel_group_id: obj - .get("parallel_group_id") - .and_then(Value::as_str) - .map(str::to_string), - parallel_branch_id: obj - .get("parallel_branch_id") - .and_then(Value::as_str) - .map(str::to_string), - session_id: obj - .get("session_id") - .and_then(Value::as_str) - .map(str::to_string), - parent_session_id: obj - .get("parent_session_id") - .and_then(Value::as_str) - .map(str::to_string), - tool_call_id: obj - .get("tool_call_id") - .and_then(Value::as_str) - .map(str::to_string), + node_id: opt_str("node_id"), + node_label: opt_str("node_label"), + stage_id: opt_str("stage_id"), + parallel_group_id: opt_str("parallel_group_id"), + parallel_branch_id: opt_str("parallel_branch_id"), + session_id: opt_str("session_id"), + parent_session_id: opt_str("parent_session_id"), + tool_call_id: opt_str("tool_call_id"), actor, event, properties: &properties, diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index eddf0f2d7..b018e97f1 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -5,8 +5,8 @@ use std::sync::atomic::{AtomicI64, Ordering}; use ::fabro_types::run_event as fabro_types; use ::fabro_types::{ - BilledTokenCounts, RunBlobId, RunControlAction, RunEvent, RunId, StageId, StageStatus, - StatusReason, + ActorKind, ActorRef, BilledTokenCounts, RunBlobId, RunControlAction, RunEvent, RunId, + RunProvenance, StageId, StageStatus, StatusReason, }; use anyhow::{Context, Result}; use chrono::Utc; @@ -55,7 +55,7 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] db_prefix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - provenance: Option<::fabro_types::RunProvenance>, + provenance: Option, #[serde(default, skip_serializing_if = "Option::is_none")] manifest_blob: Option, }, @@ -1289,13 +1289,22 @@ struct StoredEventFields { parallel_group_id: Option, parallel_branch_id: Option, tool_call_id: Option, - actor: Option<::fabro_types::ActorRef>, + actor: Option, } fn default_node_label(node_id: Option<&String>, node_label: Option) -> Option { node_label.or_else(|| node_id.cloned()) } +fn node_stored_fields(node_id: Option) -> StoredEventFields { + let node_label = default_node_label(node_id.as_ref(), None); + StoredEventFields { + node_id, + node_label, + ..StoredEventFields::default() + } +} + fn billed_token_counts_from_llm(usage: &LlmTokenCounts) -> BilledTokenCounts { BilledTokenCounts { input_tokens: usage.input_tokens, @@ -1383,15 +1392,7 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { | Event::CommandStarted { node_id, .. } | Event::CommandCompleted { node_id, .. } | Event::AgentCliStarted { node_id, .. } - | Event::AgentCliCompleted { node_id, .. } => { - let node_id = Some(node_id.clone()); - let node_label = default_node_label(node_id.as_ref(), None); - StoredEventFields { - node_id, - node_label, - ..StoredEventFields::default() - } - } + | Event::AgentCliCompleted { node_id, .. } => node_stored_fields(Some(node_id.clone())), Event::Agent { stage, visit, @@ -1416,17 +1417,9 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { parallel_branch_id: parallel_branch_id.clone(), tool_call_id, actor, - ..StoredEventFields::default() - } - } - Event::GitCommit { node_id, .. } => { - let node_label = default_node_label(node_id.as_ref(), None); - StoredEventFields { - node_id: node_id.clone(), - node_label, - ..StoredEventFields::default() } } + Event::GitCommit { node_id, .. } => node_stored_fields(node_id.clone()), Event::ParallelBranchStarted { parallel_group_id, parallel_branch_id, @@ -1453,35 +1446,16 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { | Event::InterviewStarted { stage, .. } | Event::InterviewTimeout { stage, .. } | Event::InterviewInterrupted { stage, .. } - | Event::Failover { stage, .. } => { - let node_id = Some(stage.clone()); - let node_label = default_node_label(node_id.as_ref(), None); - StoredEventFields { - node_id, - node_label, - ..StoredEventFields::default() - } - } - Event::StallWatchdogTimeout { node, .. } => { - let node_id = Some(node.clone()); - let node_label = default_node_label(node_id.as_ref(), None); - StoredEventFields { - node_id, - node_label, - ..StoredEventFields::default() - } - } + | Event::Failover { stage, .. } => node_stored_fields(Some(stage.clone())), + Event::StallWatchdogTimeout { node, .. } => node_stored_fields(Some(node.clone())), _ => StoredEventFields::default(), } } -fn actor_from_provenance( - provenance: &::fabro_types::RunProvenance, -) -> Option<::fabro_types::ActorRef> { - let subject = provenance.subject.as_ref()?; - let login = subject.login.clone()?; - Some(::fabro_types::ActorRef { - kind: ::fabro_types::ActorKind::User, +fn actor_from_provenance(provenance: &RunProvenance) -> Option { + let login = provenance.subject.as_ref()?.login.clone()?; + Some(ActorRef { + kind: ActorKind::User, id: Some(login.clone()), display: Some(login), }) @@ -1495,13 +1469,10 @@ fn agent_tool_call_id(event: &AgentEvent) -> Option<&str> { } } -fn agent_actor_for_event( - event: &AgentEvent, - session_id: Option<&str>, -) -> Option<::fabro_types::ActorRef> { +fn agent_actor_for_event(event: &AgentEvent, session_id: Option<&str>) -> Option { match event { - AgentEvent::AssistantMessage { model, .. } => Some(::fabro_types::ActorRef { - kind: ::fabro_types::ActorKind::Agent, + AgentEvent::AssistantMessage { model, .. } => Some(ActorRef { + kind: ActorKind::Agent, id: session_id.map(str::to_string), display: Some(model.clone()), }), @@ -3281,16 +3252,14 @@ mod tests { }, ); let actor = stored.actor.as_ref().expect("actor set"); - assert_eq!(actor.kind, ::fabro_types::ActorKind::Agent); + assert_eq!(actor.kind, ActorKind::Agent); assert_eq!(actor.id.as_deref(), Some("ses_agent")); assert_eq!(actor.display.as_deref(), Some("claude-sonnet")); } #[test] fn run_created_populates_user_actor_from_provenance() { - use ::fabro_types::{ - Graph, RunAuthMethod, RunProvenance, RunSubjectProvenance, Settings, fixtures, - }; + use ::fabro_types::{Graph, RunAuthMethod, RunSubjectProvenance, Settings, fixtures}; let provenance = RunProvenance { server: None, @@ -3322,7 +3291,7 @@ mod tests { }, ); let actor = stored.actor.as_ref().expect("actor set"); - assert_eq!(actor.kind, ::fabro_types::ActorKind::User); + assert_eq!(actor.kind, ActorKind::User); assert_eq!(actor.id.as_deref(), Some("alice")); assert_eq!(actor.display.as_deref(), Some("alice")); } diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index d8f7a934f..a95b0a64d 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -37,10 +37,6 @@ fn build_profile(model: &str, provider: Provider) -> Box { } } -fn current_visit(context: &Context) -> u32 { - u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX) -} - #[derive(Clone)] struct StageEventScope { visit: u32, @@ -50,7 +46,7 @@ struct StageEventScope { fn current_stage_event_scope(context: &Context) -> StageEventScope { StageEventScope { - visit: current_visit(context), + visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), parallel_group_id: context.parallel_group_id(), parallel_branch_id: context.parallel_branch_id(), } From ea84d9bc726f76c59faef90d384d10f7ff37c95c Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 12:09:00 -0400 Subject: [PATCH 07/13] refactor(store): flatten EventEnvelope wire shape via serde Replace the hand-written to_wire_value / from_wire_value helpers and the wire_event_envelope_from_generated bridge with #[serde(flatten)] on EventEnvelope.payload. Derived serde now produces and accepts the wire shape natively: { "seq": 42, "id": "...", "event": "...", ... } instead of the nested { "seq": 42, "payload": { ... } } the derive would otherwise emit. #[serde(flatten)] composes fine with the #[serde(transparent)] EventPayload(Value) wrapper, so the inner payload object is merged into the outer map on both sides. - fabro-store/src/types.rs: add #[serde(flatten)]; delete the two wire helpers (33 lines of Value-map poking); update the round-trip test to assert the shape is actually flat. - fabro-server/src/server.rs: sse_event_from_store serializes the envelope directly; api_event_envelope_from_store pipelines to_value into from_value. - fabro-cli/src/server_client.rs: buffer_sse_events parses straight into EventEnvelope via serde_json::from_str; list_run_events uses the existing convert_type helper in place of the deleted wire_event_envelope_from_generated bridge. - fabro-cli tests: helpers that called from_wire_value now call serde_json::from_value. Drops the shape check that from_wire_value used to perform on parse (id/ts/run_id/event must exist as strings): that check extracted run_id from the payload and then validated it against itself, so it only guaranteed presence, not correctness. EventPayload::new(value, expected_run_id) still runs the same check where a caller has a real external run_id to cross-match. Generated code and the OpenAPI allOf(seq, RunEvent) schema are untouched; the wire JSON is byte-identical before and after. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-cli/src/server_client.rs | 11 +---- lib/crates/fabro-cli/tests/it/cmd/support.rs | 2 +- lib/crates/fabro-cli/tests/it/workflow/mod.rs | 2 +- lib/crates/fabro-server/src/server.rs | 21 ++++----- lib/crates/fabro-store/src/types.rs | 46 +++---------------- 5 files changed, 21 insertions(+), 61 deletions(-) diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index a4aa44822..97b325301 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -72,20 +72,13 @@ impl RunAttachEventStream { fn buffer_sse_events(&mut self, finalize: bool) -> Result<()> { for payload in sse::drain_sse_payloads(&mut self.pending_bytes, finalize) { - let value: serde_json::Value = serde_json::from_str(&payload)?; self.buffered_events - .push_back(EventEnvelope::from_wire_value(value)?); + .push_back(serde_json::from_str(&payload)?); } Ok(()) } } -fn wire_event_envelope_from_generated(value: types::EventEnvelope) -> Result { - let value = - serde_json::to_value(value).context("failed to serialize generated EventEnvelope")?; - EventEnvelope::from_wire_value(value).map_err(Into::into) -} - pub(crate) use fabro_store::RunProjection; pub(crate) async fn connect_server(storage_dir: &Path) -> Result { @@ -414,7 +407,7 @@ impl ServerStoreClient { let page_events = parsed .data .into_iter() - .map(wire_event_envelope_from_generated) + .map(convert_type::<_, EventEnvelope>) .collect::>>()?; let next_page_since_seq = page_events.last().map(|event| event.seq.saturating_add(1)); all_events.extend(page_events); diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index d93851add..53e75af42 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -667,7 +667,7 @@ pub(crate) fn run_events(run_dir: &Path) -> Vec { .expect("event list response should contain a data array"); items .into_iter() - .map(EventEnvelope::from_wire_value) + .map(serde_json::from_value) .collect::, _>>() .expect("wire event envelope list should parse") } diff --git a/lib/crates/fabro-cli/tests/it/workflow/mod.rs b/lib/crates/fabro-cli/tests/it/workflow/mod.rs index 6fe7383f1..b07bed9e2 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/mod.rs @@ -179,7 +179,7 @@ fn run_events(run_dir: &Path) -> Vec { .expect("event list response should contain a data array"); items .into_iter() - .map(EventEnvelope::from_wire_value) + .map(serde_json::from_value) .collect::, _>>() .expect("wire event envelope list should parse") } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index acf1fce6a..93f6bfde4 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -1480,8 +1480,7 @@ fn event_matches_run_filter(event: &EventEnvelope, run_filter: Option<&HashSet Option { - let wire = event.to_wire_value().ok()?; - let data = serde_json::to_string(&wire).ok()?; + let data = serde_json::to_string(event).ok()?; let data = redact_jsonl_line(&data); Some(Event::default().data(data)) } @@ -2381,15 +2380,15 @@ fn octet_stream_response(bytes: Bytes) -> Response { #[allow(clippy::result_large_err)] fn api_event_envelope_from_store(event: &EventEnvelope) -> Result { - fn serialize_error(err: impl std::fmt::Display) -> Response { - ApiError::new( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to serialize stored event: {err}"), - ) - .into_response() - } - let value = event.to_wire_value().map_err(serialize_error)?; - serde_json::from_value(value).map_err(serialize_error) + serde_json::to_value(event) + .and_then(serde_json::from_value) + .map_err(|err| { + ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to serialize stored event: {err}"), + ) + .into_response() + }) } fn clear_live_run_state(run: &mut ManagedRun) { diff --git a/lib/crates/fabro-store/src/types.rs b/lib/crates/fabro-store/src/types.rs index 1c63553b5..4c3d23f75 100644 --- a/lib/crates/fabro-store/src/types.rs +++ b/lib/crates/fabro-store/src/types.rs @@ -83,46 +83,10 @@ impl TryFrom<&EventPayload> for RunEvent { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct EventEnvelope { pub seq: u32, + #[serde(flatten)] pub payload: EventPayload, } -impl EventEnvelope { - pub fn from_wire_value(value: serde_json::Value) -> Result { - let serde_json::Value::Object(mut obj) = value else { - return Err(StoreError::InvalidEvent( - "wire EventEnvelope must be a JSON object".into(), - )); - }; - let seq = obj - .remove("seq") - .and_then(|value| value.as_u64()) - .and_then(|value| u32::try_from(value).ok()) - .ok_or_else(|| { - StoreError::InvalidEvent("wire EventEnvelope missing valid seq".into()) - })?; - let run_id = obj - .get("run_id") - .and_then(|value| value.as_str()) - .ok_or_else(|| StoreError::InvalidEvent("wire EventEnvelope missing run_id".into()))? - .parse() - .map_err(|err| StoreError::InvalidEvent(format!("invalid wire run_id: {err}")))?; - let payload = EventPayload::new(serde_json::Value::Object(obj), &run_id)?; - Ok(Self { seq, payload }) - } - - pub fn to_wire_value(&self) -> Result { - let mut value = self.payload.as_value().clone(); - let map = value.as_object_mut().ok_or_else(|| { - StoreError::InvalidEvent("stored event payload must be a JSON object".into()) - })?; - map.insert( - "seq".to_string(), - serde_json::Value::Number(u64::from(self.seq).into()), - ); - Ok(value) - } -} - #[cfg(test)] mod tests { use chrono::{TimeZone, Utc}; @@ -160,9 +124,13 @@ mod tests { let payload = EventPayload::new(event.to_value().unwrap(), &fixtures::RUN_1).unwrap(); let envelope = EventEnvelope { seq: 7, payload }; - let wire = envelope.to_wire_value().unwrap(); - let parsed = EventEnvelope::from_wire_value(wire).unwrap(); + let wire = serde_json::to_value(&envelope).unwrap(); + assert_eq!(wire["seq"], 7); + assert_eq!(wire["id"], "evt_1"); + assert_eq!(wire["event"], "run.completed"); + assert!(wire.get("payload").is_none(), "wire shape must be flat"); + let parsed: EventEnvelope = serde_json::from_value(wire).unwrap(); assert_eq!(parsed, envelope); } } From 30fcee98c220fc37ce76638a6e66872abb0d7b4e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 12:39:40 -0400 Subject: [PATCH 08/13] refactor(events): type stage/parallel ids with newtypes Promote RunEvent.stage_id / parallel_group_id / parallel_branch_id and the internal Event enum's matching fields from stringly-typed Option to Option / Option. The wire contract is now self-enforcing: malformed strings are rejected at the serde seam, not quietly round-tripped, and the three StageId::new(...).to_string() calls in stored_event_fields() just drop the .to_string() since the newtypes flow straight through. - fabro-types/src/stage_id.rs: new ParallelBranchId { group: StageId, index: u32 } mirroring StageId's Display / FromStr / serde string form. "{group}:{index}" (e.g. "fanout@2:0"). Tests for round-trip and parse rejections. - fabro-types/src/lib.rs: re-export ParallelBranchId. - fabro-types/src/run_event/mod.rs: RunEvent, RunEventRaw, and RunEventParts take Option / Option. from_ref gains a small generic opt_field helper that also replaces the bespoke actor null-handling branch. to_value uses serde_json::to_value(value) for the three typed fields. - fabro-workflow/src/event.rs: Event::Stage{Started,Completed, Failed,Retrying} and Event::Agent take Option / Option. Event::ParallelBranch{Started,Completed} take the required (non-Option) typed forms. StoredEventFields and stored_event_fields() plumb the newtypes end-to-end. - fabro-workflow/src/context.rs: WorkflowContext::parallel_group_id() returns Option, parallel_branch_id() returns Option. Read via serde_json::from_value which validates the shape on the way out. - fabro-workflow/src/handler/parallel.rs: builds typed values directly, stores in context via serde_json::to_value (still produces a JSON string through the custom Serialize). BranchSetup holds a ParallelBranchId. - fabro-workflow/src/handler/llm/api.rs: StageEventScope holds typed ids. - fabro-workflow/src/lifecycle/event.rs: stage_parallel_ids returns typed tuple. Wire JSON is byte-identical before and after (StageId serializes as "{node_id}@{visit}", ParallelBranchId as "{node_id}@{visit}:{index}", matching the existing spec). Progenitor-generated types and OpenAPI schema untouched. Existing None-only fixtures in runtime_store, git, pipeline, error, run_state, rewind, pr_view, and store/dump didn't need any edit because None fits any Option. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/commands/run/run_progress/mod.rs | 22 ++-- lib/crates/fabro-store/src/types.rs | 4 +- lib/crates/fabro-types/src/lib.rs | 2 +- lib/crates/fabro-types/src/run_event/mod.rs | 57 +++++---- lib/crates/fabro-types/src/stage_id.rs | 120 +++++++++++++++++- lib/crates/fabro-workflow/src/context.rs | 20 +-- lib/crates/fabro-workflow/src/event.rs | 87 +++++++------ .../fabro-workflow/src/handler/llm/api.rs | 5 +- .../fabro-workflow/src/handler/parallel.rs | 16 ++- .../fabro-workflow/src/lifecycle/event.rs | 4 +- 10 files changed, 241 insertions(+), 96 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index 5c5ced36d..5e15deb73 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -420,7 +420,7 @@ mod tests { use fabro_agent::{AgentEvent, SandboxEvent}; use fabro_llm::types::TokenCounts; use fabro_model::Provider; - use fabro_types::fixtures; + use fabro_types::{ParallelBranchId, StageId, fixtures}; use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at}; use fabro_workflow::outcome::billed_model_usage_from_llm; @@ -569,8 +569,8 @@ mod tests { emit( &mut ui, Event::ParallelBranchStarted { - parallel_group_id: "fork1@1".into(), - parallel_branch_id: "fork1@1:0".into(), + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), branch: "security".into(), index: 0, }, @@ -586,8 +586,8 @@ mod tests { emit( &mut ui, Event::ParallelBranchCompleted { - parallel_group_id: "fork1@1".into(), - parallel_branch_id: "fork1@1:0".into(), + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), branch: "security".into(), index: 0, duration_ms: 2000, @@ -619,8 +619,8 @@ mod tests { emit( &mut ui, Event::ParallelBranchStarted { - parallel_group_id: "fork1@1".into(), - parallel_branch_id: "fork1@1:0".into(), + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), branch: "security".into(), index: 0, }, @@ -1128,8 +1128,8 @@ mod tests { emit( &mut ui, Event::ParallelBranchStarted { - parallel_group_id: "fork1@1".into(), - parallel_branch_id: "fork1@1:0".into(), + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), branch: "security".into(), index: 0, }, @@ -1137,8 +1137,8 @@ mod tests { emit( &mut ui, Event::ParallelBranchCompleted { - parallel_group_id: "fork1@1".into(), - parallel_branch_id: "fork1@1:0".into(), + parallel_group_id: StageId::new("fork1", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("fork1", 1), 0), branch: "security".into(), index: 0, duration_ms: 500, diff --git a/lib/crates/fabro-store/src/types.rs b/lib/crates/fabro-store/src/types.rs index 4c3d23f75..35b485f01 100644 --- a/lib/crates/fabro-store/src/types.rs +++ b/lib/crates/fabro-store/src/types.rs @@ -91,7 +91,7 @@ pub struct EventEnvelope { mod tests { use chrono::{TimeZone, Utc}; - use fabro_types::{EventBody, RunEvent, fixtures, run_event::RunCompletedProps}; + use fabro_types::{EventBody, RunEvent, StageId, fixtures, run_event::RunCompletedProps}; use super::{EventEnvelope, EventPayload}; @@ -103,7 +103,7 @@ mod tests { run_id: fixtures::RUN_1, node_id: Some("code".to_string()), node_label: Some("Code".to_string()), - stage_id: Some("code@1".to_string()), + stage_id: Some(StageId::new("code", 1)), parallel_group_id: None, parallel_branch_id: None, session_id: None, diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs index c6dda288f..7dd665897 100644 --- a/lib/crates/fabro-types/src/lib.rs +++ b/lib/crates/fabro-types/src/lib.rs @@ -54,7 +54,7 @@ pub use run_id::RunId; pub use run_id::fixtures; pub use sandbox_record::SandboxRecord; pub use settings::{ArtifactStorageBackend, ArtifactStorageSettings, Settings}; -pub use stage_id::StageId; +pub use stage_id::{ParallelBranchId, StageId}; pub use start::StartRecord; pub use status::{ InvalidTransition, ParseRunStatusError, RunControlAction, RunStatus, RunStatusRecord, diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index d8a1bc1db..80fe77849 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -11,7 +11,7 @@ use serde::ser::Error as SerError; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use serde_json::{Map, Value, json}; -use crate::RunId; +use crate::{ParallelBranchId, RunId, StageId}; pub use agent::*; pub use infra::*; @@ -51,9 +51,9 @@ pub struct RunEvent { pub run_id: RunId, pub node_id: Option, pub node_label: Option, - pub stage_id: Option, - pub parallel_group_id: Option, - pub parallel_branch_id: Option, + pub stage_id: Option, + pub parallel_group_id: Option, + pub parallel_branch_id: Option, pub session_id: Option, pub parent_session_id: Option, pub tool_call_id: Option, @@ -293,11 +293,11 @@ struct RunEventRaw { #[serde(default)] node_label: Option, #[serde(default)] - stage_id: Option, + stage_id: Option, #[serde(default)] - parallel_group_id: Option, + parallel_group_id: Option, #[serde(default)] - parallel_branch_id: Option, + parallel_branch_id: Option, #[serde(default)] session_id: Option, #[serde(default)] @@ -321,9 +321,9 @@ struct RunEventParts<'a> { run_id: RunId, node_id: Option, node_label: Option, - stage_id: Option, - parallel_group_id: Option, - parallel_branch_id: Option, + stage_id: Option, + parallel_group_id: Option, + parallel_branch_id: Option, session_id: Option, parent_session_id: Option, tool_call_id: Option, @@ -592,6 +592,16 @@ impl RunEvent { } pub fn from_ref(value: &Value) -> serde_json::Result { + fn opt_field Deserialize<'a>>( + obj: &Map, + key: &str, + ) -> serde_json::Result> { + match obj.get(key) { + Some(value) if !value.is_null() => Ok(Some(T::deserialize(value)?)), + _ => Ok(None), + } + } + let obj = value.as_object().ok_or_else(|| { ::custom("run event must be a JSON object") })?; @@ -614,23 +624,19 @@ impl RunEvent { .get("properties") .cloned() .unwrap_or_else(default_properties); - let actor = match obj.get("actor") { - Some(value) if !value.is_null() => Some(ActorRef::deserialize(value)?), - _ => None, - }; Self::from_parts(RunEventParts { id: id.to_string(), ts, run_id, node_id: opt_str("node_id"), node_label: opt_str("node_label"), - stage_id: opt_str("stage_id"), - parallel_group_id: opt_str("parallel_group_id"), - parallel_branch_id: opt_str("parallel_branch_id"), + stage_id: opt_field(obj, "stage_id")?, + parallel_group_id: opt_field(obj, "parallel_group_id")?, + parallel_branch_id: opt_field(obj, "parallel_branch_id")?, session_id: opt_str("session_id"), parent_session_id: opt_str("parent_session_id"), tool_call_id: opt_str("tool_call_id"), - actor, + actor: opt_field(obj, "actor")?, event, properties: &properties, }) @@ -695,18 +701,18 @@ impl RunEvent { map.insert("node_label".to_string(), Value::String(value.clone())); } if let Some(value) = &self.stage_id { - map.insert("stage_id".to_string(), Value::String(value.clone())); + map.insert("stage_id".to_string(), serde_json::to_value(value)?); } if let Some(value) = &self.parallel_group_id { map.insert( "parallel_group_id".to_string(), - Value::String(value.clone()), + serde_json::to_value(value)?, ); } if let Some(value) = &self.parallel_branch_id { map.insert( "parallel_branch_id".to_string(), - Value::String(value.clone()), + serde_json::to_value(value)?, ); } if let Some(value) = &self.tool_call_id { @@ -981,9 +987,12 @@ mod tests { }); let parsed = RunEvent::from_value(value.clone()).unwrap(); - assert_eq!(parsed.stage_id.as_deref(), Some("code@1")); - assert_eq!(parsed.parallel_group_id.as_deref(), Some("code@1")); - assert_eq!(parsed.parallel_branch_id.as_deref(), Some("code@1:0")); + assert_eq!(parsed.stage_id, Some(StageId::new("code", 1))); + assert_eq!(parsed.parallel_group_id, Some(StageId::new("code", 1))); + assert_eq!( + parsed.parallel_branch_id, + Some(ParallelBranchId::new(StageId::new("code", 1), 0)) + ); assert_eq!(parsed.tool_call_id.as_deref(), Some("call_1")); let actor = parsed.actor.as_ref().expect("actor present"); assert_eq!(actor.kind, ActorKind::Agent); diff --git a/lib/crates/fabro-types/src/stage_id.rs b/lib/crates/fabro-types/src/stage_id.rs index 747133c78..846d61cb8 100644 --- a/lib/crates/fabro-types/src/stage_id.rs +++ b/lib/crates/fabro-types/src/stage_id.rs @@ -90,9 +90,90 @@ impl<'de> Deserialize<'de> for StageId { } } +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ParallelBranchId { + group: StageId, + index: u32, +} + +impl ParallelBranchId { + #[must_use] + pub fn new(group: StageId, index: u32) -> Self { + Self { group, index } + } + + #[must_use] + pub fn group(&self) -> &StageId { + &self.group + } + + #[must_use] + pub fn index(&self) -> u32 { + self.index + } +} + +impl fmt::Display for ParallelBranchId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}:{}", self.group, self.index) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseParallelBranchIdError(String); + +impl fmt::Display for ParseParallelBranchIdError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for ParseParallelBranchIdError {} + +impl FromStr for ParallelBranchId { + type Err = ParseParallelBranchIdError; + + fn from_str(s: &str) -> Result { + let (group, index) = s.rsplit_once(':').ok_or_else(|| { + ParseParallelBranchIdError("parallel branch id must contain ':'".to_string()) + })?; + let group = group.parse::().map_err(|err| { + ParseParallelBranchIdError(format!("invalid parallel group id: {err}")) + })?; + if index.is_empty() { + return Err(ParseParallelBranchIdError( + "parallel branch id index must not be empty".to_string(), + )); + } + let index = index.parse().map_err(|err| { + ParseParallelBranchIdError(format!("invalid parallel branch index: {err}")) + })?; + Ok(Self::new(group, index)) + } +} + +impl Serialize for ParallelBranchId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for ParallelBranchId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(D::Error::custom) + } +} + #[cfg(test)] mod tests { - use super::StageId; + use super::{ParallelBranchId, StageId}; #[test] fn display_and_parse_round_trip() { @@ -151,4 +232,41 @@ mod tests { let err = "@3".parse::().unwrap_err(); assert_eq!(err.to_string(), "stage id node_id must not be empty"); } + + #[test] + fn parallel_branch_id_display_and_parse_round_trip() { + let branch = ParallelBranchId::new(StageId::new("fanout", 2), 3); + assert_eq!(branch.to_string(), "fanout@2:3"); + assert_eq!("fanout@2:3".parse::().unwrap(), branch); + } + + #[test] + fn parallel_branch_id_serde_round_trip_uses_string_form() { + let branch = ParallelBranchId::new(StageId::new("fanout", 2), 0); + let value = serde_json::to_value(&branch).unwrap(); + assert_eq!(value, serde_json::json!("fanout@2:0")); + let decoded: ParallelBranchId = serde_json::from_value(value).unwrap(); + assert_eq!(decoded, branch); + } + + #[test] + fn parallel_branch_id_rejects_missing_colon() { + let err = "fanout@2".parse::().unwrap_err(); + assert_eq!(err.to_string(), "parallel branch id must contain ':'"); + } + + #[test] + fn parallel_branch_id_rejects_bad_group() { + let err = "fanout:0".parse::().unwrap_err(); + assert!(err.to_string().starts_with("invalid parallel group id:")); + } + + #[test] + fn parallel_branch_id_rejects_non_numeric_index() { + let err = "fanout@2:zero".parse::().unwrap_err(); + assert!( + err.to_string() + .starts_with("invalid parallel branch index:") + ); + } } diff --git a/lib/crates/fabro-workflow/src/context.rs b/lib/crates/fabro-workflow/src/context.rs index 2b33ceedb..153d8911c 100644 --- a/lib/crates/fabro-workflow/src/context.rs +++ b/lib/crates/fabro-workflow/src/context.rs @@ -136,6 +136,7 @@ pub mod keys { pub use fabro_core::Context; use fabro_graphviz::Fidelity; +use fabro_types::{ParallelBranchId, StageId}; /// Domain-specific typed accessors for workflow context values. pub trait WorkflowContext { @@ -143,8 +144,8 @@ pub trait WorkflowContext { fn thread_id(&self) -> Option; fn preamble(&self) -> String; fn run_id(&self) -> String; - fn parallel_group_id(&self) -> Option; - fn parallel_branch_id(&self) -> Option; + fn parallel_group_id(&self) -> Option; + fn parallel_branch_id(&self) -> Option; } impl WorkflowContext for Context { @@ -167,14 +168,14 @@ impl WorkflowContext for Context { self.get_string(keys::INTERNAL_RUN_ID, "unknown") } - fn parallel_group_id(&self) -> Option { + fn parallel_group_id(&self) -> Option { self.get(keys::INTERNAL_PARALLEL_GROUP_ID) - .and_then(|value| value.as_str().map(String::from)) + .and_then(|value| serde_json::from_value(value).ok()) } - fn parallel_branch_id(&self) -> Option { + fn parallel_branch_id(&self) -> Option { self.get(keys::INTERNAL_PARALLEL_BRANCH_ID) - .and_then(|value| value.as_str().map(String::from)) + .and_then(|value| serde_json::from_value(value).ok()) } } @@ -341,8 +342,11 @@ mod tests { keys::INTERNAL_PARALLEL_BRANCH_ID, serde_json::json!("fanout@2:1"), ); - assert_eq!(ctx.parallel_group_id(), Some("fanout@2".to_string())); - assert_eq!(ctx.parallel_branch_id(), Some("fanout@2:1".to_string())); + assert_eq!(ctx.parallel_group_id(), Some(StageId::new("fanout", 2))); + assert_eq!( + ctx.parallel_branch_id(), + Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)) + ); } #[test] diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index b018e97f1..b2e0f96ef 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -5,8 +5,8 @@ use std::sync::atomic::{AtomicI64, Ordering}; use ::fabro_types::run_event as fabro_types; use ::fabro_types::{ - ActorKind, ActorRef, BilledTokenCounts, RunBlobId, RunControlAction, RunEvent, RunId, - RunProvenance, StageId, StageStatus, StatusReason, + ActorKind, ActorRef, BilledTokenCounts, ParallelBranchId, RunBlobId, RunControlAction, + RunEvent, RunId, RunProvenance, StageId, StageStatus, StatusReason, }; use anyhow::{Context, Result}; use chrono::Utc; @@ -140,9 +140,9 @@ pub enum Event { index: usize, visit: u32, #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_group_id: Option, + parallel_group_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_branch_id: Option, + parallel_branch_id: Option, handler_type: String, attempt: usize, max_attempts: usize, @@ -153,9 +153,9 @@ pub enum Event { index: usize, visit: u32, #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_group_id: Option, + parallel_group_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_branch_id: Option, + parallel_branch_id: Option, duration_ms: u64, status: String, preferred_label: Option, @@ -188,9 +188,9 @@ pub enum Event { index: usize, visit: u32, #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_group_id: Option, + parallel_group_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_branch_id: Option, + parallel_branch_id: Option, failure: FailureDetail, will_retry: bool, }, @@ -200,9 +200,9 @@ pub enum Event { index: usize, visit: u32, #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_group_id: Option, + parallel_group_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_branch_id: Option, + parallel_branch_id: Option, attempt: usize, max_attempts: usize, delay_ms: u64, @@ -214,14 +214,14 @@ pub enum Event { join_policy: String, }, ParallelBranchStarted { - parallel_group_id: String, - parallel_branch_id: String, + parallel_group_id: StageId, + parallel_branch_id: ParallelBranchId, branch: String, index: usize, }, ParallelBranchCompleted { - parallel_group_id: String, - parallel_branch_id: String, + parallel_group_id: StageId, + parallel_branch_id: ParallelBranchId, branch: String, index: usize, duration_ms: u64, @@ -378,9 +378,9 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] parent_session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_group_id: Option, + parallel_group_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_branch_id: Option, + parallel_branch_id: Option, }, SubgraphStarted { node_id: String, @@ -1285,9 +1285,9 @@ struct StoredEventFields { parent_session_id: Option, node_id: Option, node_label: Option, - stage_id: Option, - parallel_group_id: Option, - parallel_branch_id: Option, + stage_id: Option, + parallel_group_id: Option, + parallel_branch_id: Option, tool_call_id: Option, actor: Option, } @@ -1361,7 +1361,7 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { } => { let node_id_str = node_id.clone(); let node_label = default_node_label(Some(&node_id_str), Some(name.clone())); - let stage_id = Some(StageId::new(node_id_str.clone(), *visit).to_string()); + let stage_id = Some(StageId::new(node_id_str.clone(), *visit)); StoredEventFields { node_id: Some(node_id_str), node_label, @@ -1375,7 +1375,7 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { | Event::ParallelCompleted { node_id, visit, .. } => { let node_id_str = node_id.clone(); let node_label = default_node_label(Some(&node_id_str), None); - let parallel_group_id = Some(StageId::new(node_id_str.clone(), *visit).to_string()); + let parallel_group_id = Some(StageId::new(node_id_str.clone(), *visit)); StoredEventFields { node_id: Some(node_id_str), node_label, @@ -1404,7 +1404,7 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { } => { let node_id = Some(stage.clone()); let node_label = default_node_label(node_id.as_ref(), None); - let stage_id = Some(StageId::new(stage.clone(), *visit).to_string()); + let stage_id = Some(StageId::new(stage.clone(), *visit)); let tool_call_id = agent_tool_call_id(agent_event).map(str::to_string); let actor = agent_actor_for_event(agent_event, session_id.as_deref()); StoredEventFields { @@ -2891,7 +2891,7 @@ mod tests { assert_eq!(stored.run_id, fixtures::RUN_2); assert_eq!(stored.node_id.as_deref(), Some("plan")); assert_eq!(stored.node_label.as_deref(), Some("Plan")); - assert_eq!(stored.stage_id.as_deref(), Some("plan@1")); + assert_eq!(stored.stage_id, Some(StageId::new("plan", 1))); let properties = stored.properties().unwrap(); assert_eq!(properties["duration_ms"], 5000); assert_eq!(properties["status"], "success"); @@ -3133,8 +3133,8 @@ mod tests { ); assert_eq!( event_name(&Event::ParallelBranchStarted { - parallel_group_id: "plan@1".to_string(), - parallel_branch_id: "plan@1:0".to_string(), + parallel_group_id: StageId::new("plan", 1), + parallel_branch_id: ParallelBranchId::new(StageId::new("plan", 1), 0), branch: "fork".to_string(), index: 0, }), @@ -3167,15 +3167,18 @@ mod tests { name: "review".to_string(), index: 1, visit: 1, - parallel_group_id: Some("fanout@2".to_string()), - parallel_branch_id: Some("fanout@2:1".to_string()), + parallel_group_id: Some(StageId::new("fanout", 2)), + parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)), handler_type: "agent".to_string(), attempt: 1, max_attempts: 1, }, ); - assert_eq!(stored.parallel_group_id.as_deref(), Some("fanout@2")); - assert_eq!(stored.parallel_branch_id.as_deref(), Some("fanout@2:1")); + assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); + assert_eq!( + stored.parallel_branch_id, + Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)) + ); } #[test] @@ -3189,7 +3192,7 @@ mod tests { join_policy: "wait_all".to_string(), }, ); - assert_eq!(stored.parallel_group_id.as_deref(), Some("fanout@2")); + assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); assert!(stored.parallel_branch_id.is_none()); } @@ -3198,14 +3201,17 @@ mod tests { let stored = to_run_event( &fixtures::RUN_1, &Event::ParallelBranchStarted { - parallel_group_id: "fanout@2".to_string(), - parallel_branch_id: "fanout@2:1".to_string(), + parallel_group_id: StageId::new("fanout", 2), + parallel_branch_id: ParallelBranchId::new(StageId::new("fanout", 2), 1), branch: "review".to_string(), index: 1, }, ); - assert_eq!(stored.parallel_group_id.as_deref(), Some("fanout@2")); - assert_eq!(stored.parallel_branch_id.as_deref(), Some("fanout@2:1")); + assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); + assert_eq!( + stored.parallel_branch_id, + Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)) + ); } #[test] @@ -3222,14 +3228,17 @@ mod tests { }, session_id: Some("ses_1".to_string()), parent_session_id: None, - parallel_group_id: Some("fanout@2".to_string()), - parallel_branch_id: Some("fanout@2:0".to_string()), + parallel_group_id: Some(StageId::new("fanout", 2)), + parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 2), 0)), }, ); - assert_eq!(stored.stage_id.as_deref(), Some("code@3")); + assert_eq!(stored.stage_id, Some(StageId::new("code", 3))); assert_eq!(stored.tool_call_id.as_deref(), Some("call_abc")); - assert_eq!(stored.parallel_group_id.as_deref(), Some("fanout@2")); - assert_eq!(stored.parallel_branch_id.as_deref(), Some("fanout@2:0")); + assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); + assert_eq!( + stored.parallel_branch_id, + Some(ParallelBranchId::new(StageId::new("fanout", 2), 0)) + ); } #[test] diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index a95b0a64d..73390f201 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -13,6 +13,7 @@ use fabro_llm::types::{Message, Request, TokenCounts}; use fabro_mcp::config::McpServerSettings; use fabro_model::FallbackTarget; use fabro_model::Provider; +use fabro_types::{ParallelBranchId, StageId}; use tokio::sync::Mutex as TokioMutex; use super::super::agent::{CodergenBackend, CodergenResult}; @@ -40,8 +41,8 @@ fn build_profile(model: &str, provider: Provider) -> Box { #[derive(Clone)] struct StageEventScope { visit: u32, - parallel_group_id: Option, - parallel_branch_id: Option, + parallel_group_id: Option, + parallel_branch_id: Option, } fn current_stage_event_scope(context: &Context) -> StageEventScope { diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index ccd07125d..968e9044b 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -4,7 +4,7 @@ use std::time::Instant; use async_trait::async_trait; use fabro_agent::{Sandbox, WorktreeOptions, WorktreeSandbox}; -use fabro_types::{RunId, StageId}; +use fabro_types::{ParallelBranchId, RunId, StageId}; use tokio::sync::Semaphore; use crate::context::keys; @@ -132,7 +132,7 @@ impl Handler for ParallelHandler { struct BranchSetup { target_id: String, branch_index: usize, - parallel_branch_id: String, + parallel_branch_id: ParallelBranchId, branch_context: Context, sandbox: Arc, worktree_path: Option, @@ -152,7 +152,7 @@ impl Handler for ParallelHandler { ); let parallel_visit = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX); - let parallel_group_id = StageId::new(node.id.clone(), parallel_visit).to_string(); + let parallel_group_id = StageId::new(node.id.clone(), parallel_visit); services.emitter.emit(&Event::ParallelStarted { node_id: node.id.clone(), @@ -208,14 +208,18 @@ impl Handler for ParallelHandler { for (branch_index, edge) in branches.iter().enumerate() { let target_id = edge.to.clone(); let branch_context = context.fork(); - let parallel_branch_id = format!("{parallel_group_id}:{branch_index}"); + let parallel_branch_id = ParallelBranchId::new( + parallel_group_id.clone(), + u32::try_from(branch_index).unwrap_or(u32::MAX), + ); branch_context.set( keys::INTERNAL_PARALLEL_GROUP_ID, - serde_json::json!(¶llel_group_id), + serde_json::to_value(¶llel_group_id).expect("StageId serializes as string"), ); branch_context.set( keys::INTERNAL_PARALLEL_BRANCH_ID, - serde_json::json!(¶llel_branch_id), + serde_json::to_value(¶llel_branch_id) + .expect("ParallelBranchId serializes as string"), ); let (branch_sandbox, worktree_path): (Arc, Option) = if let ( diff --git a/lib/crates/fabro-workflow/src/lifecycle/event.rs b/lib/crates/fabro-workflow/src/lifecycle/event.rs index 6bca3b85e..00ce55b28 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/event.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/event.rs @@ -23,7 +23,7 @@ use crate::event::{Emitter, Event}; use crate::graph::WorkflowGraph; use crate::graph::WorkflowNode; use crate::outcome::{BilledModelUsage, FailureCategory, FailureDetail, Outcome, StageStatus}; -use fabro_types::{BilledTokenCounts, RunId, StatusReason}; +use fabro_types::{BilledTokenCounts, ParallelBranchId, RunId, StageId, StatusReason}; type WfRunState = ExecutionState>; type WfNodeResult = NodeResult>; @@ -85,7 +85,7 @@ fn stage_visit(state: &WfRunState, node_id: &str) -> u32 { u32::try_from(visits.max(1)).unwrap_or(u32::MAX) } -fn stage_parallel_ids(state: &WfRunState) -> (Option, Option) { +fn stage_parallel_ids(state: &WfRunState) -> (Option, Option) { ( state.context.parallel_group_id(), state.context.parallel_branch_id(), From 9b0b8d94fc1131a061aad708a02bc08e809ff6e2 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 16:00:05 -0400 Subject: [PATCH 09/13] fix(api): generate typescript EventEnvelope with typed seq The typescript-axios generator was collapsing EventEnvelope's allOf([inline_object, $ref: RunEvent]) to a bare `type EventEnvelope = RunEvent` alias, losing the `seq` field at the type level. TypeScript consumers could write `envelope.seq` and get `any` (via RunEvent's additionalProperties index signature), but had no type-level guarantee that seq was present. Extract `EventSeq` as a named component schema and switch EventEnvelope's allOf to two $refs. typescript-axios now generates `export type EventEnvelope = EventSeq & RunEvent`, which makes `envelope.seq: number` a typed property. The Rust progenitor client is unchanged: it still flattens the allOf into a single EventEnvelope struct with `seq: i64` inline, exactly as before. Wire JSON is byte-identical on both sides. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/api-reference/fabro-api.yaml | 20 ++++++++------ .../src/.openapi-generator/FILES | 1 + .../src/models/event-envelope.ts | 5 +++- .../fabro-api-client/src/models/event-seq.ts | 26 +++++++++++++++++++ .../fabro-api-client/src/models/index.ts | 1 + 5 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 lib/packages/fabro-api-client/src/models/event-seq.ts diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index d12a17ce9..d7ee6f200 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -2712,20 +2712,24 @@ components: additionalProperties: true additionalProperties: true + EventSeq: + description: Assigned sequence number component of a stored event envelope. + type: object + required: + - seq + properties: + seq: + type: integer + description: Assigned event sequence number. + example: 42 + EventEnvelope: description: > Stored event envelope with assigned sequence number. On the wire the envelope is flattened: seq sits alongside the RunEvent payload fields at the top level of the JSON object. allOf: - - type: object - required: - - seq - properties: - seq: - type: integer - description: Assigned event sequence number. - example: 42 + - $ref: "#/components/schemas/EventSeq" - $ref: "#/components/schemas/RunEvent" PaginatedEventList: diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 88db0f6d7..500b36498 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -62,6 +62,7 @@ models/disk-usage-summary-row.ts models/error-response-entry.ts models/error-response.ts models/event-envelope.ts +models/event-seq.ts models/execute-query-request.ts models/execute-query-response-rows-inner-inner.ts models/execute-query-response.ts diff --git a/lib/packages/fabro-api-client/src/models/event-envelope.ts b/lib/packages/fabro-api-client/src/models/event-envelope.ts index 3b99be9e1..7947bcff7 100644 --- a/lib/packages/fabro-api-client/src/models/event-envelope.ts +++ b/lib/packages/fabro-api-client/src/models/event-envelope.ts @@ -18,12 +18,15 @@ import type { ActorRef } from './actor-ref'; // May contain unused imports in some cases // @ts-ignore +import type { EventSeq } from './event-seq'; +// May contain unused imports in some cases +// @ts-ignore import type { RunEvent } from './run-event'; /** * @type EventEnvelope * Stored event envelope with assigned sequence number. On the wire the envelope is flattened: seq sits alongside the RunEvent payload fields at the top level of the JSON object. */ -export type EventEnvelope = RunEvent; +export type EventEnvelope = EventSeq & RunEvent; diff --git a/lib/packages/fabro-api-client/src/models/event-seq.ts b/lib/packages/fabro-api-client/src/models/event-seq.ts new file mode 100644 index 000000000..79f135951 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/event-seq.ts @@ -0,0 +1,26 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Assigned sequence number component of a stored event envelope. + */ +export interface EventSeq { + /** + * Assigned event sequence number. + */ + 'seq': number; +} + diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index adc64cfd0..83ec5eb72 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -44,6 +44,7 @@ export * from './disk-usage-summary-row'; export * from './error-response'; export * from './error-response-entry'; export * from './event-envelope'; +export * from './event-seq'; export * from './execute-query-request'; export * from './execute-query-response'; export * from './execute-query-response-rows-inner-inner'; From c6a78a4286fb3d03cdaec561b9f759ae11b88d98 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 16:34:59 -0400 Subject: [PATCH 10/13] refactor(events): thread stage scope through emitter Populate stage_id / parallel_group_id / parallel_branch_id on every event tied to a concrete stage execution, per the spec at docs-internal/fabro-event-schema-v2-concrete-shape.md:223-279. Before this commit, stored_event_fields() only set stage_id for the four Event::Stage* variants and Event::Agent -- the only variants that carried visit/parallel_group_id/parallel_branch_id in their payload. Every other stage-scoped event (Checkpoint*, PromptCompleted, Command*, AgentCli*, Prompt, Interview*, Failover, StallWatchdog, GitCommit, ArtifactCaptured) fell through to node_stored_fields() and left stage_id as None. New approach: scope is carried alongside the event, not on the variant. - fabro-workflow/src/event.rs: new StageScope type { node_id, visit, parallel_group_id, parallel_branch_id }. New Emitter::emit_scoped(&event, &scope) for stage-level emission. to_run_event_at and stored_event_fields take an Option<&StageScope> that merges into the returned envelope fields. StageScope::for_handler(context, node_id) is the canonical handler-side constructor -- prefers context.current_stage_scope() set by the fidelity lifecycle, falls back to a scope synthesized from the node_id + context visit count for tests that don't go through the full lifecycle. - fabro-workflow/src/context.rs: new WorkflowContext::current_stage_scope() method reads CURRENT_NODE, internal.node_visit_count, internal.parallel_group_id, internal.parallel_branch_id from the context. - Remove the now-redundant visit/parallel_group_id/parallel_branch_id fields from Event::Stage{Started,Completed,Failed,Retrying} and the parallel_* fields from Event::Agent. These existed only to feed stored_event_fields() and are obsolete once scope is threaded through the emitter. Emission site migration (all stage-scoped handlers now use emit_scoped): - lifecycle/event.rs: StageStarted, StageCompleted, StageFailed, StageRetrying, CheckpointCompleted, GitCommit (from on_checkpoint) - lifecycle/git.rs: CheckpointFailed - lifecycle/artifact.rs: ArtifactCaptured - handler/command.rs: CommandStarted, CommandCompleted - handler/prompt.rs: Prompt, PromptCompleted - handler/agent.rs: Prompt, PromptCompleted - handler/fan_in.rs: Prompt, PromptCompleted - handler/human.rs: InterviewStarted, InterviewTimeout, InterviewInterrupted, InterviewCompleted - handler/llm/api.rs: Failover, Agent (via spawn_event_forwarder which now carries a StageScope across the tokio::spawn boundary) - handler/llm/cli.rs: AgentCliStarted, AgentCliCompleted - handler/parallel.rs: ParallelBranchStarted, ParallelBranchCompleted StallWatchdogTimeout stays on plain emit() because the watchdog fires from an error path without a live stage context. Deleted the local StageEventScope struct + current_stage_event_scope helper from handler/llm/api.rs; it's generalized into StageScope. Tests: two new unit tests in event.rs -- stage_scope_populates_stage_id_on_non_stage_events verifies CommandStarted / Prompt / GitCommit all pick up stage_id from scope, run_level_events_without_scope_leave_stage_id_absent confirms run.* events still get no stage scope. Updated all test fixtures across fabro-workflow, fabro-cli to drop the removed Event variant fields. Accepted two insta snapshot updates in fabro-cli/tests/it/cmd/{attach,run}.rs that now include the formerly-missing stage_id fields on checkpoint and interview events. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/commands/run/run_progress/event.rs | 5 - .../src/commands/run/run_progress/mod.rs | 20 +- .../fabro-cli/src/commands/store/dump.rs | 3 - lib/crates/fabro-cli/tests/it/cmd/attach.rs | 2 + lib/crates/fabro-cli/tests/it/cmd/run.rs | 9 + lib/crates/fabro-workflow/src/context.rs | 19 + lib/crates/fabro-workflow/src/error.rs | 3 - lib/crates/fabro-workflow/src/event.rs | 256 ++++++++----- lib/crates/fabro-workflow/src/git.rs | 3 - .../fabro-workflow/src/handler/agent.rs | 65 ++-- .../fabro-workflow/src/handler/command.rs | 40 +- .../fabro-workflow/src/handler/fan_in.rs | 56 +-- .../fabro-workflow/src/handler/human.rs | 14 +- .../fabro-workflow/src/handler/llm/api.rs | 60 ++- .../fabro-workflow/src/handler/llm/cli.rs | 39 +- .../fabro-workflow/src/handler/parallel.rs | 62 ++-- .../fabro-workflow/src/handler/prompt.rs | 39 +- .../fabro-workflow/src/lifecycle/artifact.rs | 25 +- .../fabro-workflow/src/lifecycle/event.rs | 343 +++++++++--------- .../fabro-workflow/src/lifecycle/git.rs | 13 +- .../fabro-workflow/src/operations/create.rs | 1 + .../src/pipeline/pull_request.rs | 3 - .../fabro-workflow/src/pipeline/retro.rs | 2 - 23 files changed, 608 insertions(+), 474 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs index 880338894..f950f2216 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs @@ -510,9 +510,6 @@ mod tests { node_id: "plan".into(), name: "Plan".into(), index: 0, - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, duration_ms: 5000, status: "success".into(), preferred_label: None, @@ -557,8 +554,6 @@ mod tests { }, session_id: None, parent_session_id: None, - parallel_group_id: None, - parallel_branch_id: None, }; let stored = to_run_event(&fixtures::RUN_1, &event); diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs index 5e15deb73..cb30ea84f 100644 --- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs @@ -479,8 +479,6 @@ mod tests { event, session_id: None, parent_session_id: None, - parallel_group_id: None, - parallel_branch_id: None, } } @@ -489,9 +487,6 @@ mod tests { node_id: node_id.into(), name: name.into(), index: 0, - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, handler_type: String::new(), attempt: 1, max_attempts: 1, @@ -515,9 +510,6 @@ mod tests { node_id: node_id.into(), name: name.into(), index: 0, - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, duration_ms: 5000, status: "success".into(), preferred_label: None, @@ -714,9 +706,6 @@ mod tests { node_id: "code".into(), name: "Code".into(), index: 0, - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, attempt: 2, max_attempts: 3, delay_ms: 1500, @@ -961,9 +950,6 @@ mod tests { node_id: "code".into(), name: "Code".into(), index: 0, - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, attempt: 2, max_attempts: 3, delay_ms: 1500, @@ -1168,14 +1154,12 @@ mod tests { node_id: "code".into(), name: "Code".into(), index: 0, - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, handler_type: "agent".into(), attempt: 1, max_attempts: 1, }, started_ts, + None, )) .unwrap(); let tool_started = serde_json::to_string(&to_run_event_at( @@ -1189,6 +1173,7 @@ mod tests { }, ), started_ts, + None, )) .unwrap(); let tool_completed = serde_json::to_string(&to_run_event_at( @@ -1203,6 +1188,7 @@ mod tests { }, ), completed_ts, + None, )) .unwrap(); diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 929deb4c1..611f14c04 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -599,9 +599,6 @@ mod tests { node_id: "code".to_string(), name: "Code".to_string(), index: 1, - visit: 2, - parallel_group_id: None, - parallel_branch_id: None, duration_ms: 250, status: "partial_success".to_string(), preferred_label: None, diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index cedc4882d..9ba4c9d74 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -726,6 +726,7 @@ fn attach_json_errors_without_prompting_for_human_input() { "status": "success" }, "run_id": "[ULID]", + "stage_id": "start@1", "ts": "[TIMESTAMP]" }, { @@ -766,6 +767,7 @@ fn attach_json_errors_without_prompting_for_human_input() { "stage": "approve" }, "run_id": "[ULID]", + "stage_id": "approve@1", "ts": "[TIMESTAMP]" } ] diff --git a/lib/crates/fabro-cli/tests/it/cmd/run.rs b/lib/crates/fabro-cli/tests/it/cmd/run.rs index 606e6ccfa..977859943 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/run.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/run.rs @@ -1031,6 +1031,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "status": "success" }, "run_id": "[ULID]", + "stage_id": "start@1", "ts": "[TIMESTAMP]" }, { @@ -1071,11 +1072,14 @@ fn json_run_implies_auto_approve_for_human_gates() { "stage": "approve" }, "run_id": "[ULID]", + "stage_id": "approve@1", "ts": "[TIMESTAMP]" }, { "event": "interview.completed", "id": "[EVENT_ID]", + "node_id": "approve", + "node_label": "approve", "properties": { "answer": "A", "duration_ms": "[DURATION_MS]", @@ -1083,6 +1087,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "question_id": "[ULID]" }, "run_id": "[ULID]", + "stage_id": "approve@1", "ts": "[TIMESTAMP]" }, { @@ -1199,6 +1204,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "status": "success" }, "run_id": "[ULID]", + "stage_id": "approve@1", "ts": "[TIMESTAMP]" }, { @@ -1227,6 +1233,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "script": "echo shipped" }, "run_id": "[ULID]", + "stage_id": "ship@1", "ts": "[TIMESTAMP]" }, { @@ -1242,6 +1249,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "timed_out": false }, "run_id": "[ULID]", + "stage_id": "ship@1", "ts": "[TIMESTAMP]" }, { @@ -1371,6 +1379,7 @@ fn json_run_implies_auto_approve_for_human_gates() { "status": "success" }, "run_id": "[ULID]", + "stage_id": "ship@1", "ts": "[TIMESTAMP]" }, { diff --git a/lib/crates/fabro-workflow/src/context.rs b/lib/crates/fabro-workflow/src/context.rs index 153d8911c..482089f38 100644 --- a/lib/crates/fabro-workflow/src/context.rs +++ b/lib/crates/fabro-workflow/src/context.rs @@ -135,6 +135,8 @@ pub mod keys { pub use fabro_core::Context; +use crate::event::StageScope; +use crate::run_dir::visit_from_context; use fabro_graphviz::Fidelity; use fabro_types::{ParallelBranchId, StageId}; @@ -146,6 +148,10 @@ pub trait WorkflowContext { fn run_id(&self) -> String; fn parallel_group_id(&self) -> Option; fn parallel_branch_id(&self) -> Option; + /// Build the stage-level emit scope from the currently-executing node and its + /// accumulated visit count. Returns `None` for run-level emissions where no + /// stage is active (i.e., `CURRENT_NODE` is unset). + fn current_stage_scope(&self) -> Option; } impl WorkflowContext for Context { @@ -177,6 +183,19 @@ impl WorkflowContext for Context { self.get(keys::INTERNAL_PARALLEL_BRANCH_ID) .and_then(|value| serde_json::from_value(value).ok()) } + + fn current_stage_scope(&self) -> Option { + let node_id = self + .get(keys::CURRENT_NODE) + .and_then(|value| value.as_str().map(String::from))?; + let visit = u32::try_from(visit_from_context(self)).unwrap_or(u32::MAX); + Some(StageScope { + node_id, + visit, + parallel_group_id: self.parallel_group_id(), + parallel_branch_id: self.parallel_branch_id(), + }) + } } #[cfg(test)] diff --git a/lib/crates/fabro-workflow/src/error.rs b/lib/crates/fabro-workflow/src/error.rs index a6ea8e01a..942b442f1 100644 --- a/lib/crates/fabro-workflow/src/error.rs +++ b/lib/crates/fabro-workflow/src/error.rs @@ -1692,9 +1692,6 @@ mod tests { node_id: "code".into(), name: "code".into(), index: 0, - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, failure: failure.clone(), will_retry: false, }; diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index b2e0f96ef..060f7d116 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -19,8 +19,10 @@ use tokio::io::{AsyncWrite, AsyncWriteExt}; use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot}; use uuid::Uuid; +use crate::context::{Context as WfContext, WorkflowContext}; use crate::error::FabroError; use crate::outcome::{BilledModelUsage, FailureDetail, Outcome}; +use crate::run_dir::visit_from_context; use fabro_agent::{AgentEvent, SandboxEvent, WorktreeEvent, WorktreeEventCallback}; use fabro_llm::types::TokenCounts as LlmTokenCounts; use fabro_util::redact::redact_json_value; @@ -138,11 +140,6 @@ pub enum Event { node_id: String, name: String, index: usize, - visit: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_group_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_branch_id: Option, handler_type: String, attempt: usize, max_attempts: usize, @@ -151,11 +148,6 @@ pub enum Event { node_id: String, name: String, index: usize, - visit: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_group_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_branch_id: Option, duration_ms: u64, status: String, preferred_label: Option, @@ -186,11 +178,6 @@ pub enum Event { node_id: String, name: String, index: usize, - visit: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_group_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_branch_id: Option, failure: FailureDetail, will_retry: bool, }, @@ -198,11 +185,6 @@ pub enum Event { node_id: String, name: String, index: usize, - visit: u32, - #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_group_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_branch_id: Option, attempt: usize, max_attempts: usize, delay_ms: u64, @@ -377,10 +359,6 @@ pub enum Event { session_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] parent_session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_group_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - parallel_branch_id: Option, }, SubgraphStarted { node_id: String, @@ -1321,53 +1299,45 @@ fn stage_status_from_string(status: &str) -> StageStatus { serde_json::from_value(Value::String(status.to_string())).expect("valid stage status") } -fn stored_event_fields(event: &Event) -> StoredEventFields { +fn stored_event_fields(event: &Event, scope: Option<&StageScope>) -> StoredEventFields { + let mut fields = stored_event_fields_for_variant(event); + if let Some(scope) = scope { + if fields.node_id.is_none() { + fields.node_id = Some(scope.node_id.clone()); + fields.node_label = default_node_label(Some(&scope.node_id), fields.node_label); + } + if fields.stage_id.is_none() { + fields.stage_id = Some(StageId::new(scope.node_id.clone(), scope.visit)); + } + if fields.parallel_group_id.is_none() { + fields + .parallel_group_id + .clone_from(&scope.parallel_group_id); + } + if fields.parallel_branch_id.is_none() { + fields + .parallel_branch_id + .clone_from(&scope.parallel_branch_id); + } + } + fields +} + +fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields { match event { Event::RunCreated { provenance, .. } => StoredEventFields { actor: provenance.as_ref().and_then(actor_from_provenance), ..StoredEventFields::default() }, - Event::StageCompleted { - node_id, - name, - visit, - parallel_group_id, - parallel_branch_id, - .. - } - | Event::StageFailed { - node_id, - name, - visit, - parallel_group_id, - parallel_branch_id, - .. - } - | Event::StageStarted { - node_id, - name, - visit, - parallel_group_id, - parallel_branch_id, - .. - } - | Event::StageRetrying { - node_id, - name, - visit, - parallel_group_id, - parallel_branch_id, - .. - } => { + Event::StageCompleted { node_id, name, .. } + | Event::StageFailed { node_id, name, .. } + | Event::StageStarted { node_id, name, .. } + | Event::StageRetrying { node_id, name, .. } => { let node_id_str = node_id.clone(); let node_label = default_node_label(Some(&node_id_str), Some(name.clone())); - let stage_id = Some(StageId::new(node_id_str.clone(), *visit)); StoredEventFields { node_id: Some(node_id_str), node_label, - stage_id, - parallel_group_id: parallel_group_id.clone(), - parallel_branch_id: parallel_branch_id.clone(), ..StoredEventFields::default() } } @@ -1399,8 +1369,6 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { event: agent_event, session_id, parent_session_id, - parallel_group_id, - parallel_branch_id, } => { let node_id = Some(stage.clone()); let node_label = default_node_label(node_id.as_ref(), None); @@ -1413,10 +1381,9 @@ fn stored_event_fields(event: &Event) -> StoredEventFields { node_id, node_label, stage_id, - parallel_group_id: parallel_group_id.clone(), - parallel_branch_id: parallel_branch_id.clone(), tool_call_id, actor, + ..StoredEventFields::default() } } Event::GitCommit { node_id, .. } => node_stored_fields(node_id.clone()), @@ -2481,12 +2448,44 @@ fn event_body_from_event(event: &Event) -> EventBody { } } -pub fn to_run_event(run_id: &RunId, event: &Event) -> RunEvent { - to_run_event_at(run_id, event, Utc::now()) +/// Stage-level scope threaded through event emission to populate +/// `stage_id` / `parallel_group_id` / `parallel_branch_id` on events +/// that happen inside a concrete stage execution. +#[derive(Clone, Debug)] +pub struct StageScope { + pub node_id: String, + pub visit: u32, + pub parallel_group_id: Option, + pub parallel_branch_id: Option, } -pub fn to_run_event_at(run_id: &RunId, event: &Event, ts: chrono::DateTime) -> RunEvent { - let fields = stored_event_fields(event); +impl StageScope { + /// Build scope for a handler invocation. Prefers the current_stage_scope + /// set by the fidelity lifecycle before_attempt hook, but falls back to + /// a scope synthesized from the node id and the context's visit count + /// for tests and other direct-handler call sites that don't go through + /// the full lifecycle. + pub fn for_handler(context: &WfContext, node_id: impl Into) -> Self { + context.current_stage_scope().unwrap_or_else(|| Self { + node_id: node_id.into(), + visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), + parallel_group_id: context.parallel_group_id(), + parallel_branch_id: context.parallel_branch_id(), + }) + } +} + +pub fn to_run_event(run_id: &RunId, event: &Event) -> RunEvent { + to_run_event_at(run_id, event, Utc::now(), None) +} + +pub fn to_run_event_at( + run_id: &RunId, + event: &Event, + ts: chrono::DateTime, + scope: Option<&StageScope>, +) -> RunEvent { + let fields = stored_event_fields(event, scope); let body = event_body_from_event(event); RunEvent { id: Uuid::now_v7().to_string(), @@ -2759,6 +2758,14 @@ impl Emitter { } pub fn emit(&self, event: &Event) { + self.emit_with_scope(event, None); + } + + pub fn emit_scoped(&self, event: &Event, scope: &StageScope) { + self.emit_with_scope(event, Some(scope)); + } + + fn emit_with_scope(&self, event: &Event, scope: Option<&StageScope>) { self.last_event_at.store(epoch_millis(), Ordering::Relaxed); event.trace(); if let Event::WorkflowRunStarted { run_id, .. } = event { @@ -2767,7 +2774,7 @@ impl Emitter { "workflow run started event must match emitter run_id" ); } - let stored = to_run_event(&self.run_id, event); + let stored = to_run_event_at(&self.run_id, event, Utc::now(), scope); self.dispatch_run_event(&stored); } @@ -2858,15 +2865,12 @@ mod tests { #[test] fn run_event_stage_completed_places_node_fields_in_header() { - let stored = to_run_event( + let stored = to_run_event_at( &fixtures::RUN_2, &Event::StageCompleted { node_id: "plan".to_string(), name: "Plan".to_string(), index: 0, - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, duration_ms: 5000, status: "success".to_string(), preferred_label: None, @@ -2885,6 +2889,13 @@ mod tests { attempt: 1, max_attempts: 1, }, + Utc::now(), + Some(&StageScope { + node_id: "plan".to_string(), + visit: 1, + parallel_group_id: None, + parallel_branch_id: None, + }), ); assert_eq!(stored.event_name(), "stage.completed"); @@ -2906,9 +2917,6 @@ mod tests { node_id: "plan".to_string(), name: "Plan".to_string(), index: 0, - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, duration_ms: 5000, status: "success".to_string(), preferred_label: None, @@ -2943,9 +2951,6 @@ mod tests { node_id: "code".to_string(), name: "Code".to_string(), index: 1, - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, failure: FailureDetail::new( "lint failed", crate::outcome::FailureCategory::Deterministic, @@ -2975,8 +2980,6 @@ mod tests { }, session_id: Some("ses_child".to_string()), parent_session_id: Some("ses_parent".to_string()), - parallel_group_id: None, - parallel_branch_id: None, }, ); @@ -3151,8 +3154,6 @@ mod tests { }, session_id: None, parent_session_id: None, - parallel_group_id: None, - parallel_branch_id: None, }), "agent.sub.spawned" ); @@ -3160,19 +3161,23 @@ mod tests { #[test] fn stage_started_populates_parallel_ids_when_present() { - let stored = to_run_event( + let stored = to_run_event_at( &fixtures::RUN_1, &Event::StageStarted { node_id: "review".to_string(), name: "review".to_string(), index: 1, - visit: 1, - parallel_group_id: Some(StageId::new("fanout", 2)), - parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)), handler_type: "agent".to_string(), attempt: 1, max_attempts: 1, }, + Utc::now(), + Some(&StageScope { + node_id: "review".to_string(), + visit: 1, + parallel_group_id: Some(StageId::new("fanout", 2)), + parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 2), 1)), + }), ); assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); assert_eq!( @@ -3216,7 +3221,7 @@ mod tests { #[test] fn agent_tool_started_populates_tool_call_id_and_stage_id() { - let stored = to_run_event( + let stored = to_run_event_at( &fixtures::RUN_1, &Event::Agent { stage: "code".to_string(), @@ -3228,9 +3233,14 @@ mod tests { }, session_id: Some("ses_1".to_string()), parent_session_id: None, + }, + Utc::now(), + Some(&StageScope { + node_id: "code".to_string(), + visit: 3, parallel_group_id: Some(StageId::new("fanout", 2)), parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 2), 0)), - }, + }), ); assert_eq!(stored.stage_id, Some(StageId::new("code", 3))); assert_eq!(stored.tool_call_id.as_deref(), Some("call_abc")); @@ -3241,6 +3251,70 @@ mod tests { ); } + #[test] + fn stage_scope_populates_stage_id_on_non_stage_events() { + // Events tied to a concrete stage execution but lacking scope in their + // own variant fields (CheckpointCompleted, CommandStarted, PromptCompleted, + // Prompt, InterviewStarted, Failover, GitCommit) should pick up stage_id + // / parallel_group_id / parallel_branch_id from the scope argument. + let scope = StageScope { + node_id: "build".to_string(), + visit: 2, + parallel_group_id: Some(StageId::new("fanout", 1)), + parallel_branch_id: Some(ParallelBranchId::new(StageId::new("fanout", 1), 0)), + }; + + let command_started = to_run_event_at( + &fixtures::RUN_1, + &Event::CommandStarted { + node_id: "build".to_string(), + script: "echo".to_string(), + command: "echo".to_string(), + language: "shell".to_string(), + timeout_ms: None, + }, + Utc::now(), + Some(&scope), + ); + assert_eq!(command_started.stage_id, Some(StageId::new("build", 2))); + assert_eq!(command_started.parallel_group_id, scope.parallel_group_id); + assert_eq!(command_started.parallel_branch_id, scope.parallel_branch_id); + + let prompt = to_run_event_at( + &fixtures::RUN_1, + &Event::Prompt { + stage: "build".to_string(), + visit: 2, + text: "do it".to_string(), + mode: None, + provider: None, + model: None, + }, + Utc::now(), + Some(&scope), + ); + assert_eq!(prompt.stage_id, Some(StageId::new("build", 2))); + + let git_commit = to_run_event_at( + &fixtures::RUN_1, + &Event::GitCommit { + node_id: Some("build".to_string()), + sha: "deadbeef".to_string(), + }, + Utc::now(), + Some(&scope), + ); + assert_eq!(git_commit.stage_id, Some(StageId::new("build", 2))); + } + + #[test] + fn run_level_events_without_scope_leave_stage_id_absent() { + let stored = to_run_event(&fixtures::RUN_1, &Event::RunRunning { reason: None }); + assert!(stored.stage_id.is_none()); + assert!(stored.parallel_group_id.is_none()); + assert!(stored.parallel_branch_id.is_none()); + } + #[test] fn agent_assistant_message_populates_agent_actor() { let stored = to_run_event( @@ -3256,8 +3330,6 @@ mod tests { }, session_id: Some("ses_agent".to_string()), parent_session_id: None, - parallel_group_id: None, - parallel_branch_id: None, }, ); let actor = stored.actor.as_ref().expect("actor set"); diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index 6f1a84551..24ccf9fac 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -449,9 +449,6 @@ mod tests { node_id: "work".into(), name: "Work".into(), index: 2, - visit: 2, - parallel_group_id: None, - parallel_branch_id: None, duration_ms: 100, status: "success".into(), preferred_label: None, diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 85ea0e26d..541c2d226 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -10,7 +10,7 @@ use fabro_types::RunId; use crate::context::keys; use crate::context::{Context, WorkflowContext}; use crate::error::FabroError; -use crate::event::{Emitter, Event}; +use crate::event::{Emitter, Event, StageScope}; use crate::outcome::{ BilledModelUsage, FailureCategory, FailureDetail, Outcome, OutcomeExt, StageStatus, }; @@ -256,14 +256,18 @@ impl Handler for AgentHandler { .map(String::from) .or_else(|| Some(Provider::default_from_env().as_str().to_string())); let prompt_model = node.model().map(String::from); - services.emitter.emit(&Event::Prompt { - stage: node.id.clone(), - visit, - text: prompt.clone(), - mode: Some("agent".to_string()), - provider: prompt_provider, - model: prompt_model, - }); + let stage_scope = StageScope::for_handler(context, &node.id); + services.emitter.emit_scoped( + &Event::Prompt { + stage: node.id.clone(), + visit, + text: prompt.clone(), + mode: Some("agent".to_string()), + provider: prompt_provider, + model: prompt_model, + }, + &stage_scope, + ); // 3. Call LLM backend (agent loop) let thread_id = context.thread_id(); @@ -329,13 +333,16 @@ impl Handler for AgentHandler { .map(String::from) .or_else(|| Some(Provider::default_from_env().as_str().to_string())) .unwrap_or_default(); - services.emitter.emit(&Event::PromptCompleted { - node_id: node.id.clone(), - response: response_text.clone(), - model: response_model, - provider: response_provider, - billing: stage_usage.clone(), - }); + services.emitter.emit_scoped( + &Event::PromptCompleted { + node_id: node.id.clone(), + response: response_text.clone(), + model: response_model, + provider: response_provider, + billing: stage_usage.clone(), + }, + &stage_scope, + ); // Build and write status let mut outcome = Outcome::success(); @@ -709,19 +716,21 @@ mod tests { _sandbox: &Arc, _tool_hooks: Option>, ) -> Result { - emitter.emit(&crate::event::Event::Agent { - stage: node.id.clone(), - visit: u32::try_from(crate::run_dir::visit_from_context(context)) - .unwrap_or(u32::MAX), - event: fabro_agent::AgentEvent::SessionStarted { - provider: Some("openai".to_string()), - model: Some("gpt-5.4".to_string()), + let scope = StageScope::for_handler(context, &node.id); + emitter.emit_scoped( + &crate::event::Event::Agent { + stage: node.id.clone(), + visit: u32::try_from(crate::run_dir::visit_from_context(context)) + .unwrap_or(u32::MAX), + event: fabro_agent::AgentEvent::SessionStarted { + provider: Some("openai".to_string()), + model: Some("gpt-5.4".to_string()), + }, + session_id: Some("session_123".to_string()), + parent_session_id: None, }, - session_id: Some("session_123".to_string()), - parent_session_id: None, - parallel_group_id: context.parallel_group_id(), - parallel_branch_id: context.parallel_branch_id(), - }); + &scope, + ); Ok(CodergenResult::Text { text: "done".to_string(), usage: None, diff --git a/lib/crates/fabro-workflow/src/handler/command.rs b/lib/crates/fabro-workflow/src/handler/command.rs index b25a717ac..09d1e520b 100644 --- a/lib/crates/fabro-workflow/src/handler/command.rs +++ b/lib/crates/fabro-workflow/src/handler/command.rs @@ -4,6 +4,7 @@ use crate::context::Context; use crate::context::keys; use crate::error::FabroError; use crate::event::Event; +use crate::event::StageScope; use crate::outcome::{Outcome, OutcomeExt}; use async_trait::async_trait; use fabro_graphviz::graph::{Graph, Node}; @@ -57,7 +58,7 @@ impl Handler for CommandHandler { async fn execute( &self, node: &Node, - _context: &Context, + context: &Context, _graph: &Graph, _run_dir: &Path, services: &EngineServices, @@ -90,13 +91,17 @@ impl Handler for CommandHandler { } else { script.to_string() }; - services.emitter.emit(&Event::CommandStarted { - node_id: node.id.clone(), - script: script.to_string(), - command: command.clone(), - language: language.to_string(), - timeout_ms: timeout_ms(node), - }); + let stage_scope = StageScope::for_handler(context, &node.id); + services.emitter.emit_scoped( + &Event::CommandStarted { + node_id: node.id.clone(), + script: script.to_string(), + command: command.clone(), + language: language.to_string(), + timeout_ms: timeout_ms(node), + }, + &stage_scope, + ); let timeout_ms = node .timeout() @@ -118,14 +123,17 @@ impl Handler for CommandHandler { let result = result.map_err(|e| FabroError::handler(format!("Failed to spawn script: {e}")))?; - services.emitter.emit(&Event::CommandCompleted { - node_id: node.id.clone(), - stdout: result.stdout.clone(), - stderr: result.stderr.clone(), - exit_code: (!result.timed_out).then_some(result.exit_code), - duration_ms: result.duration_ms, - timed_out: result.timed_out, - }); + services.emitter.emit_scoped( + &Event::CommandCompleted { + node_id: node.id.clone(), + stdout: result.stdout.clone(), + stderr: result.stderr.clone(), + exit_code: (!result.timed_out).then_some(result.exit_code), + duration_ms: result.duration_ms, + timed_out: result.timed_out, + }, + &stage_scope, + ); if result.timed_out { return Err(FabroError::handler(format!( diff --git a/lib/crates/fabro-workflow/src/handler/fan_in.rs b/lib/crates/fabro-workflow/src/handler/fan_in.rs index 69fae9878..458ff4886 100644 --- a/lib/crates/fabro-workflow/src/handler/fan_in.rs +++ b/lib/crates/fabro-workflow/src/handler/fan_in.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use crate::context::Context; use crate::context::keys; use crate::error::FabroError; -use crate::event::{Emitter, Event}; +use crate::event::{Emitter, Event, StageScope}; use crate::outcome::{Outcome, OutcomeExt}; use crate::run_dir::visit_from_context; use crate::sandbox_git::git_merge_ff_only; @@ -232,15 +232,19 @@ async fn llm_evaluate( ); let visit_u32 = u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX); + let stage_scope = StageScope::for_handler(context, node_id); - emitter.emit(&Event::Prompt { - stage: node_id.to_string(), - visit: visit_u32, - text: full_prompt.clone(), - mode: Some("fan_in".to_string()), - provider: None, - model: None, - }); + emitter.emit_scoped( + &Event::Prompt { + stage: node_id.to_string(), + visit: visit_u32, + text: full_prompt.clone(), + mode: Some("fan_in".to_string()), + provider: None, + model: None, + }, + &stage_scope, + ); // Build a synthetic node for the backend call let eval_node = Node::new("fan_in_eval"); @@ -269,13 +273,16 @@ async fn llm_evaluate( .unwrap_or_else(|| "unknown".to_string()); let response_text = serde_json::to_string_pretty(&outcome).unwrap_or_else(|_| "{}".to_string()); - emitter.emit(&Event::PromptCompleted { - node_id: node_id.to_string(), - response: response_text.clone(), - model: String::new(), - provider: String::new(), - billing: None, - }); + emitter.emit_scoped( + &Event::PromptCompleted { + node_id: node_id.to_string(), + response: response_text.clone(), + model: String::new(), + provider: String::new(), + billing: None, + }, + &stage_scope, + ); Ok(Candidate { id: best_id, status: outcome.status.to_string(), @@ -283,13 +290,16 @@ async fn llm_evaluate( }) } Ok(CodergenResult::Text { text, .. }) => { - emitter.emit(&Event::PromptCompleted { - node_id: node_id.to_string(), - response: text.clone(), - model: String::new(), - provider: String::new(), - billing: None, - }); + emitter.emit_scoped( + &Event::PromptCompleted { + node_id: node_id.to_string(), + response: text.clone(), + model: String::new(), + provider: String::new(), + billing: None, + }, + &stage_scope, + ); // The LLM responded with text; try to find a matching candidate ID let text = text.trim().to_string(); diff --git a/lib/crates/fabro-workflow/src/handler/human.rs b/lib/crates/fabro-workflow/src/handler/human.rs index ba3842be4..7475378cf 100644 --- a/lib/crates/fabro-workflow/src/handler/human.rs +++ b/lib/crates/fabro-workflow/src/handler/human.rs @@ -8,7 +8,7 @@ use async_trait::async_trait; use crate::context::Context; use crate::context::keys; use crate::error::FabroError; -use crate::event::{Emitter, Event}; +use crate::event::{Emitter, Event, StageScope}; use crate::millis_u64; use crate::outcome::{Outcome, OutcomeExt}; use fabro_graphviz::graph::{Graph, Node}; @@ -88,10 +88,10 @@ impl HumanHandler { self } - fn emit(&self, default_emitter: &Arc, event: &Event) { + fn emit(&self, default_emitter: &Arc, event: &Event, scope: &StageScope) { match &self.emitter { - Some(emitter) => emitter.emit(event), - None => default_emitter.emit(event), + Some(emitter) => emitter.emit_scoped(event, scope), + None => default_emitter.emit_scoped(event, scope), } } } @@ -209,6 +209,7 @@ impl Handler for HumanHandler { // 3. Present to interviewer let question_text = node.label().to_string(); let question_id = question.id.clone(); + let stage_scope = StageScope::for_handler(context, &node.id); self.emit( &services.emitter, &Event::InterviewStarted { @@ -228,6 +229,7 @@ impl Handler for HumanHandler { timeout_seconds: question.timeout_seconds, context_display: question.context_display.clone(), }, + &stage_scope, ); let interview_start = Instant::now(); let answer = self.interviewer.ask(question).await; @@ -242,6 +244,7 @@ impl Handler for HumanHandler { stage: node.id.clone(), duration_ms: millis_u64(interview_start.elapsed()), }, + &stage_scope, ); let default_choice = node .attrs @@ -279,6 +282,7 @@ impl Handler for HumanHandler { reason: "interrupted".to_string(), duration_ms: millis_u64(interview_start.elapsed()), }, + &stage_scope, ); return Ok(unanswered_human_gate( "human interaction interrupted before an answer was provided", @@ -293,6 +297,7 @@ impl Handler for HumanHandler { answer: answer_text(&answer), duration_ms: millis_u64(interview_start.elapsed()), }, + &stage_scope, ); return Ok(unanswered_human_gate("human skipped interaction")); } @@ -306,6 +311,7 @@ impl Handler for HumanHandler { answer: answer_text(&answer), duration_ms: millis_u64(interview_start.elapsed()), }, + &stage_scope, ); // 6. Try fixed-choice match diff --git a/lib/crates/fabro-workflow/src/handler/llm/api.rs b/lib/crates/fabro-workflow/src/handler/llm/api.rs index 73390f201..3e03af58d 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/api.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/api.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex}; use async_trait::async_trait; +use crate::event::StageScope; use fabro_agent::{ AgentEvent, AgentProfile, AnthropicProfile, GeminiProfile, OpenAiProfile, Sandbox, Session, SessionOptions, Turn, @@ -13,7 +14,6 @@ use fabro_llm::types::{Message, Request, TokenCounts}; use fabro_mcp::config::McpServerSettings; use fabro_model::FallbackTarget; use fabro_model::Provider; -use fabro_types::{ParallelBranchId, StageId}; use tokio::sync::Mutex as TokioMutex; use super::super::agent::{CodergenBackend, CodergenResult}; @@ -22,7 +22,6 @@ use crate::context::{Context, WorkflowContext}; use crate::error::FabroError; use crate::event::{Emitter, Event}; use crate::outcome::billed_model_usage_from_llm; -use crate::run_dir::visit_from_context; use fabro_graphviz::graph::Node; fn build_profile(model: &str, provider: Provider) -> Box { @@ -38,21 +37,6 @@ fn build_profile(model: &str, provider: Provider) -> Box { } } -#[derive(Clone)] -struct StageEventScope { - visit: u32, - parallel_group_id: Option, - parallel_branch_id: Option, -} - -fn current_stage_event_scope(context: &Context) -> StageEventScope { - StageEventScope { - visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), - parallel_group_id: context.parallel_group_id(), - parallel_branch_id: context.parallel_branch_id(), - } -} - /// Shared state for tracking file modifications from agent tool calls. struct FileTracking { /// Maps tool_call_id → file_path for in-flight write/edit calls. @@ -98,7 +82,7 @@ fn track_file_event(event: &AgentEvent, state: &mut FileTracking) { fn spawn_event_forwarder( session: &Session, node_id: String, - scope: StageEventScope, + scope: StageScope, emitter: Arc, file_tracking: Arc>, ) { @@ -115,15 +99,16 @@ fn spawn_event_forwarder( if !event.event.is_streaming_noise() && !matches!(&event.event, AgentEvent::ProcessingEnd) { - emitter.emit(&Event::Agent { - stage: node_id.clone(), - visit: scope.visit, - event: event.event.clone(), - session_id: Some(event.session_id.clone()), - parent_session_id: event.parent_session_id.clone(), - parallel_group_id: scope.parallel_group_id.clone(), - parallel_branch_id: scope.parallel_branch_id.clone(), - }); + emitter.emit_scoped( + &Event::Agent { + stage: node_id.clone(), + visit: scope.visit, + event: event.event.clone(), + session_id: Some(event.session_id.clone()), + parent_session_id: event.parent_session_id.clone(), + }, + &scope, + ); } } }); @@ -469,7 +454,7 @@ impl CodergenBackend for AgentApiBackend { touched: HashSet::new(), last: None, })); - let event_scope = current_stage_event_scope(context); + let event_scope = StageScope::for_handler(context, &node.id); // Subscribe to session events: forward to pipeline emitter + track files. spawn_event_forwarder( @@ -503,14 +488,17 @@ impl CodergenBackend for AgentApiBackend { let mut succeeded = false; for target in &self.fallback_chain { - emitter.emit(&Event::Failover { - stage: node.id.clone(), - from_provider: from_provider.clone(), - from_model: from_model.clone(), - to_provider: target.provider.clone(), - to_model: target.model.clone(), - error: error_msg.clone(), - }); + emitter.emit_scoped( + &Event::Failover { + stage: node.id.clone(), + from_provider: from_provider.clone(), + from_model: from_model.clone(), + to_provider: target.provider.clone(), + to_model: target.model.clone(), + error: error_msg.clone(), + }, + &event_scope, + ); let target_provider: Provider = match target.provider.parse() { Ok(p) => p, diff --git a/lib/crates/fabro-workflow/src/handler/llm/cli.rs b/lib/crates/fabro-workflow/src/handler/llm/cli.rs index 6f1bb7401..9e52d8c63 100644 --- a/lib/crates/fabro-workflow/src/handler/llm/cli.rs +++ b/lib/crates/fabro-workflow/src/handler/llm/cli.rs @@ -10,7 +10,7 @@ use tokio::time::sleep; use super::super::agent::{CodergenBackend, CodergenResult}; use crate::context::Context; use crate::error::FabroError; -use crate::event::{Emitter, Event}; +use crate::event::{Emitter, Event, StageScope}; use crate::outcome::billed_model_usage_from_llm; use crate::run_dir::visit_from_context; use fabro_graphviz::graph::Node; @@ -496,14 +496,18 @@ impl CodergenBackend for AgentCliBackend { ensure_cli(cli, provider, sandbox, emitter).await?; let command = cli_command_for_provider(provider, model, &prompt_path); - emitter.emit(&Event::AgentCliStarted { - node_id: node.id.clone(), - visit: current_visit(_context), - mode: "cli".to_string(), - provider: provider.as_str().to_string(), - model: model.to_string(), - command: command.clone(), - }); + let stage_scope = StageScope::for_handler(_context, &node.id); + emitter.emit_scoped( + &Event::AgentCliStarted { + node_id: node.id.clone(), + visit: current_visit(_context), + mode: "cli".to_string(), + provider: provider.as_str().to_string(), + model: model.to_string(), + command: command.clone(), + }, + &stage_scope, + ); // Forward provider API key and custom env vars so the CLI tool can authenticate. // Build a HashMap to pass via exec_command's env_vars parameter — this @@ -620,13 +624,16 @@ impl CodergenBackend for AgentCliBackend { timed_out: false, duration_ms, }; - emitter.emit(&Event::AgentCliCompleted { - node_id: node.id.clone(), - stdout: result.stdout.clone(), - stderr: result.stderr.clone(), - exit_code: result.exit_code, - duration_ms: result.duration_ms, - }); + emitter.emit_scoped( + &Event::AgentCliCompleted { + node_id: node.id.clone(), + stdout: result.stdout.clone(), + stderr: result.stderr.clone(), + exit_code: result.exit_code, + duration_ms: result.duration_ms, + }, + &stage_scope, + ); // 3e. Cleanup temp files let _ = sandbox diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index 968e9044b..0592595b9 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -10,7 +10,7 @@ use tokio::sync::Semaphore; use crate::context::keys; use crate::context::{Context, WorkflowContext}; use crate::error::FabroError; -use crate::event::Event; +use crate::event::{Event, StageScope}; use crate::git::sanitize_ref_component; use crate::hook_context::set_hook_node; use crate::millis_u64; @@ -280,6 +280,8 @@ impl Handler for ParallelHandler { }); } + let parent_scope = StageScope::for_handler(context, &node.id); + // --- Fan out: concurrent execution --- let mut handles = Vec::new(); for setup in branch_setups { @@ -302,6 +304,7 @@ impl Handler for ParallelHandler { .map(|gs| gs.git_author.clone()) .unwrap_or_default(); let group_id = parallel_group_id.clone(); + let branch_scope = parent_scope.clone(); let handle = tokio::spawn(async move { let _permit = sem @@ -309,12 +312,15 @@ impl Handler for ParallelHandler { .await .map_err(|e| FabroError::handler(format!("semaphore error: {e}")))?; - emitter.emit(&Event::ParallelBranchStarted { - parallel_group_id: group_id.clone(), - parallel_branch_id: setup.parallel_branch_id.clone(), - branch: setup.target_id.clone(), - index: setup.branch_index, - }); + emitter.emit_scoped( + &Event::ParallelBranchStarted { + parallel_group_id: group_id.clone(), + parallel_branch_id: setup.parallel_branch_id.clone(), + branch: setup.target_id.clone(), + index: setup.branch_index, + }, + &branch_scope, + ); let branch_start = Instant::now(); let Some(target_node) = graph.nodes.get(&setup.target_id) else { @@ -322,15 +328,18 @@ impl Handler for ParallelHandler { "branch target node not found: {}", setup.target_id )); - emitter.emit(&Event::ParallelBranchCompleted { - parallel_group_id: group_id.clone(), - parallel_branch_id: setup.parallel_branch_id.clone(), - branch: setup.target_id.clone(), - index: setup.branch_index, - duration_ms: millis_u64(branch_start.elapsed()), - status: "fail".to_string(), - head_sha: None, - }); + emitter.emit_scoped( + &Event::ParallelBranchCompleted { + parallel_group_id: group_id.clone(), + parallel_branch_id: setup.parallel_branch_id.clone(), + branch: setup.target_id.clone(), + index: setup.branch_index, + duration_ms: millis_u64(branch_start.elapsed()), + status: "fail".to_string(), + head_sha: None, + }, + &branch_scope, + ); return Ok(BranchResult { id: setup.target_id.clone(), outcome, @@ -408,15 +417,18 @@ impl Handler for ParallelHandler { None }; - emitter.emit(&Event::ParallelBranchCompleted { - parallel_group_id: group_id.clone(), - parallel_branch_id: setup.parallel_branch_id.clone(), - branch: setup.target_id.clone(), - index: setup.branch_index, - duration_ms: millis_u64(branch_start.elapsed()), - status: outcome.status.to_string(), - head_sha: head_sha.clone(), - }); + emitter.emit_scoped( + &Event::ParallelBranchCompleted { + parallel_group_id: group_id.clone(), + parallel_branch_id: setup.parallel_branch_id.clone(), + branch: setup.target_id.clone(), + index: setup.branch_index, + duration_ms: millis_u64(branch_start.elapsed()), + status: outcome.status.to_string(), + head_sha: head_sha.clone(), + }, + &branch_scope, + ); Ok::(BranchResult { id: setup.target_id, diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs index fe0134d10..9dc5e25c2 100644 --- a/lib/crates/fabro-workflow/src/handler/prompt.rs +++ b/lib/crates/fabro-workflow/src/handler/prompt.rs @@ -3,7 +3,7 @@ use std::path::Path; use crate::context::keys; use crate::context::{Context, WorkflowContext}; use crate::error::FabroError; -use crate::event::Event; +use crate::event::{Event, StageScope}; use crate::outcome::Outcome; use crate::run_dir::visit_from_context; use async_trait::async_trait; @@ -91,14 +91,18 @@ impl Handler for PromptHandler { .map(String::from) .or_else(|| Some(Provider::default_from_env().as_str().to_string())); let prompt_model = node.model().map(String::from); - services.emitter.emit(&Event::Prompt { - stage: node.id.clone(), - visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), - text: prompt.clone(), - mode: Some("prompt".to_string()), - provider: prompt_provider.clone(), - model: prompt_model.clone(), - }); + let stage_scope = StageScope::for_handler(context, &node.id); + services.emitter.emit_scoped( + &Event::Prompt { + stage: node.id.clone(), + visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), + text: prompt.clone(), + mode: Some("prompt".to_string()), + provider: prompt_provider.clone(), + model: prompt_model.clone(), + }, + &stage_scope, + ); // 3. Call LLM backend (one_shot) let (response_text, stage_usage, backend_files_touched) = @@ -140,13 +144,16 @@ impl Handler for PromptHandler { .or_else(|| Some(Provider::default_from_env().as_str().to_string())) .unwrap_or_default(); - services.emitter.emit(&Event::PromptCompleted { - node_id: node.id.clone(), - response: response_text.clone(), - model: response_model, - provider: response_provider, - billing: stage_usage.clone(), - }); + services.emitter.emit_scoped( + &Event::PromptCompleted { + node_id: node.id.clone(), + response: response_text.clone(), + model: response_model, + provider: response_provider, + billing: stage_usage.clone(), + }, + &stage_scope, + ); // 4. Build and write status let mut outcome = Outcome::success(); diff --git a/lib/crates/fabro-workflow/src/lifecycle/artifact.rs b/lib/crates/fabro-workflow/src/lifecycle/artifact.rs index 78e980dda..a101857b4 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/artifact.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/artifact.rs @@ -19,6 +19,7 @@ use crate::artifact_upload::ArtifactSink; use crate::event::{Emitter, Event, RunNoticeLevel}; use crate::graph::WorkflowGraph; use crate::graph::WorkflowNode; +use crate::lifecycle::event::stage_scope_for; use crate::outcome::BilledModelUsage; use crate::runtime_store::RunStoreHandle; use fabro_core::lifecycle::NodeDecision; @@ -136,18 +137,22 @@ impl RunLifecycle for ArtifactLifecycle { }); return Ok(()); } + let scope = stage_scope_for(state, node_id); for asset in &summary.captured_assets { self.captured_artifact_count.fetch_add(1, Ordering::Relaxed); - self.emitter.emit(&Event::ArtifactCaptured { - node_id: node_id.to_string(), - attempt: ctx.attempt, - node_slug: node_slug.clone(), - path: asset.path.clone(), - mime: asset.mime.clone(), - content_md5: asset.content_md5.clone(), - content_sha256: asset.content_sha256.clone(), - bytes: asset.bytes, - }); + self.emitter.emit_scoped( + &Event::ArtifactCaptured { + node_id: node_id.to_string(), + attempt: ctx.attempt, + node_slug: node_slug.clone(), + path: asset.path.clone(), + mime: asset.mime.clone(), + content_md5: asset.content_md5.clone(), + content_sha256: asset.content_sha256.clone(), + bytes: asset.bytes, + }, + &scope, + ); } } Ok(_) => {} // no files collected diff --git a/lib/crates/fabro-workflow/src/lifecycle/event.rs b/lib/crates/fabro-workflow/src/lifecycle/event.rs index 00ce55b28..2c9691f2b 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/event.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/event.rs @@ -19,11 +19,11 @@ use crate::artifact; use crate::context; use crate::context::WorkflowContext; use crate::error::FabroError; -use crate::event::{Emitter, Event}; +use crate::event::{Emitter, Event, StageScope}; use crate::graph::WorkflowGraph; use crate::graph::WorkflowNode; use crate::outcome::{BilledModelUsage, FailureCategory, FailureDetail, Outcome, StageStatus}; -use fabro_types::{BilledTokenCounts, ParallelBranchId, RunId, StageId, StatusReason}; +use fabro_types::{BilledTokenCounts, RunId, StatusReason}; type WfRunState = ExecutionState>; type WfNodeResult = NodeResult>; @@ -85,11 +85,13 @@ fn stage_visit(state: &WfRunState, node_id: &str) -> u32 { u32::try_from(visits.max(1)).unwrap_or(u32::MAX) } -fn stage_parallel_ids(state: &WfRunState) -> (Option, Option) { - ( - state.context.parallel_group_id(), - state.context.parallel_branch_id(), - ) +pub(crate) fn stage_scope_for(state: &WfRunState, node_id: &str) -> StageScope { + StageScope { + node_id: node_id.to_string(), + visit: stage_visit(state, node_id), + parallel_group_id: state.context.parallel_group_id(), + parallel_branch_id: state.context.parallel_branch_id(), + } } #[async_trait] @@ -133,49 +135,48 @@ impl RunLifecycle for EventLifecycle { } let gv = node.inner(); let stage_index = state.stage_index; - let visit = stage_visit(state, &gv.id); - let (parallel_group_id, parallel_branch_id) = stage_parallel_ids(state); + let scope = stage_scope_for(state, &gv.id); let (loop_failure_signatures, restart_failure_signatures) = snapshot_failure_signatures(&self.circuit_breaker); - self.emitter.emit(&Event::StageStarted { - node_id: gv.id.clone(), - name: gv.label().to_string(), - index: stage_index, - visit, - parallel_group_id: parallel_group_id.clone(), - parallel_branch_id: parallel_branch_id.clone(), - handler_type: gv.handler_type().unwrap_or_default().to_string(), - attempt: 1, - max_attempts: 1, - }); - self.emitter.emit(&Event::StageCompleted { - node_id: gv.id.clone(), - name: gv.label().to_string(), - index: stage_index, - visit, - parallel_group_id, - parallel_branch_id, - duration_ms: 0, - status: StageStatus::Success.to_string(), - preferred_label: None, - suggested_next_ids: Vec::new(), - billing: None, - failure: None, - notes: None, - files_touched: Vec::new(), - context_updates: None, - jump_to_node: None, - context_values: None, - node_visits: None, - loop_failure_signatures, - restart_failure_signatures, - response: state - .context - .get(&context::keys::response_key(&gv.id)) - .and_then(|value| value.as_str().map(ToOwned::to_owned)), - attempt: 1, - max_attempts: 1, - }); + self.emitter.emit_scoped( + &Event::StageStarted { + node_id: gv.id.clone(), + name: gv.label().to_string(), + index: stage_index, + handler_type: gv.handler_type().unwrap_or_default().to_string(), + attempt: 1, + max_attempts: 1, + }, + &scope, + ); + self.emitter.emit_scoped( + &Event::StageCompleted { + node_id: gv.id.clone(), + name: gv.label().to_string(), + index: stage_index, + duration_ms: 0, + status: StageStatus::Success.to_string(), + preferred_label: None, + suggested_next_ids: Vec::new(), + billing: None, + failure: None, + notes: None, + files_touched: Vec::new(), + context_updates: None, + jump_to_node: None, + context_values: None, + node_visits: None, + loop_failure_signatures, + restart_failure_signatures, + response: state + .context + .get(&context::keys::response_key(&gv.id)) + .and_then(|value| value.as_str().map(ToOwned::to_owned)), + attempt: 1, + max_attempts: 1, + }, + &scope, + ); } async fn before_attempt( @@ -184,18 +185,18 @@ impl RunLifecycle for EventLifecycle { state: &WfRunState, ) -> CoreResult>> { let gv = ctx.node.inner(); - let (parallel_group_id, parallel_branch_id) = stage_parallel_ids(state); - self.emitter.emit(&Event::StageStarted { - node_id: gv.id.clone(), - name: gv.label().to_string(), - index: state.stage_index, - visit: stage_visit(state, &gv.id), - parallel_group_id, - parallel_branch_id, - handler_type: gv.handler_type().unwrap_or_default().to_string(), - attempt: ctx.attempt as usize, - max_attempts: ctx.max_attempts as usize, - }); + let scope = stage_scope_for(state, &gv.id); + self.emitter.emit_scoped( + &Event::StageStarted { + node_id: gv.id.clone(), + name: gv.label().to_string(), + index: state.stage_index, + handler_type: gv.handler_type().unwrap_or_default().to_string(), + attempt: ctx.attempt as usize, + max_attempts: ctx.max_attempts as usize, + }, + &scope, + ); Ok(NodeDecision::Continue) } @@ -208,35 +209,34 @@ impl RunLifecycle for EventLifecycle { let gv = ctx.node.inner(); let outcome = &ctx.result.outcome; let stage_index = state.stage_index; - let visit = stage_visit(state, &gv.id); - let (parallel_group_id, parallel_branch_id) = stage_parallel_ids(state); + let scope = stage_scope_for(state, &gv.id); - self.emitter.emit(&Event::StageFailed { - node_id: gv.id.clone(), - name: gv.label().to_string(), - index: stage_index, - visit, - parallel_group_id: parallel_group_id.clone(), - parallel_branch_id: parallel_branch_id.clone(), - failure: outcome.failure.clone().unwrap_or_else(|| { - FailureDetail::new("handler failed", FailureCategory::TransientInfra) - }), - will_retry: true, - }); + self.emitter.emit_scoped( + &Event::StageFailed { + node_id: gv.id.clone(), + name: gv.label().to_string(), + index: stage_index, + failure: outcome.failure.clone().unwrap_or_else(|| { + FailureDetail::new("handler failed", FailureCategory::TransientInfra) + }), + will_retry: true, + }, + &scope, + ); - self.emitter.emit(&Event::StageRetrying { - node_id: gv.id.clone(), - name: gv.label().to_string(), - index: stage_index, - visit, - parallel_group_id, - parallel_branch_id, - attempt: ctx.attempt as usize, - max_attempts: ctx.result.max_attempts as usize, - delay_ms: ctx - .backoff_delay - .map_or(0, |d| u64::try_from(d.as_millis()).unwrap()), - }); + self.emitter.emit_scoped( + &Event::StageRetrying { + node_id: gv.id.clone(), + name: gv.label().to_string(), + index: stage_index, + attempt: ctx.attempt as usize, + max_attempts: ctx.result.max_attempts as usize, + delay_ms: ctx + .backoff_delay + .map_or(0, |d| u64::try_from(d.as_millis()).unwrap()), + }, + &scope, + ); } Ok(()) } @@ -254,66 +254,66 @@ impl RunLifecycle for EventLifecycle { } let gv = node.inner(); let stage_index = state.stage_index; - let visit = stage_visit(state, &gv.id); - let (parallel_group_id, parallel_branch_id) = stage_parallel_ids(state); + let scope = stage_scope_for(state, &gv.id); let duration_ms = u64::try_from(result.duration.as_millis()).unwrap(); let (loop_failure_signatures, restart_failure_signatures) = snapshot_failure_signatures(&self.circuit_breaker); if outcome.status == StageStatus::Fail { - self.emitter.emit(&Event::StageFailed { - node_id: gv.id.clone(), - name: gv.label().to_string(), - index: stage_index, - visit, - parallel_group_id, - parallel_branch_id, - failure: outcome.failure.clone().unwrap_or_else(|| { - FailureDetail::new("handler failed", FailureCategory::Deterministic) - }), - will_retry: false, - }); - } else { - self.emitter.emit(&Event::StageCompleted { - node_id: gv.id.clone(), - name: gv.label().to_string(), - index: stage_index, - visit, - parallel_group_id, - parallel_branch_id, - duration_ms, - status: outcome.status.to_string(), - preferred_label: outcome.preferred_label.clone(), - suggested_next_ids: outcome.suggested_next_ids.clone(), - billing: outcome.usage.clone(), - failure: outcome.failure.clone(), - notes: outcome.notes.clone(), - files_touched: outcome.files_touched.clone(), - context_updates: (!outcome.context_updates.is_empty()).then(|| { - outcome - .context_updates - .clone() - .into_iter() - .collect::>() - }), - jump_to_node: outcome.jump_to_node.clone(), - context_values: { - let snapshot = state.context.snapshot(); - (!snapshot.is_empty()).then(|| snapshot.into_iter().collect::>()) + self.emitter.emit_scoped( + &Event::StageFailed { + node_id: gv.id.clone(), + name: gv.label().to_string(), + index: stage_index, + failure: outcome.failure.clone().unwrap_or_else(|| { + FailureDetail::new("handler failed", FailureCategory::Deterministic) + }), + will_retry: false, }, - node_visits: (!state.node_visits.is_empty()).then(|| { - state - .node_visits - .clone() - .into_iter() - .collect::>() - }), - loop_failure_signatures, - restart_failure_signatures, - response: response_from_outcome(&gv.id, outcome), - attempt: result.attempts as usize, - max_attempts: result.max_attempts as usize, - }); + &scope, + ); + } else { + self.emitter.emit_scoped( + &Event::StageCompleted { + node_id: gv.id.clone(), + name: gv.label().to_string(), + index: stage_index, + duration_ms, + status: outcome.status.to_string(), + preferred_label: outcome.preferred_label.clone(), + suggested_next_ids: outcome.suggested_next_ids.clone(), + billing: outcome.usage.clone(), + failure: outcome.failure.clone(), + notes: outcome.notes.clone(), + files_touched: outcome.files_touched.clone(), + context_updates: (!outcome.context_updates.is_empty()).then(|| { + outcome + .context_updates + .clone() + .into_iter() + .collect::>() + }), + jump_to_node: outcome.jump_to_node.clone(), + context_values: { + let snapshot = state.context.snapshot(); + (!snapshot.is_empty()) + .then(|| snapshot.into_iter().collect::>()) + }, + node_visits: (!state.node_visits.is_empty()).then(|| { + state + .node_visits + .clone() + .into_iter() + .collect::>() + }), + loop_failure_signatures, + restart_failure_signatures, + response: response_from_outcome(&gv.id, outcome), + attempt: result.attempts as usize, + max_attempts: result.max_attempts as usize, + }, + &scope, + ); } Ok(()) } @@ -367,37 +367,44 @@ impl RunLifecycle for EventLifecycle { node_outcomes.insert(node.id().to_string(), result.outcome.clone()); artifact::normalize_durable_outcomes(&mut node_outcomes); - self.emitter.emit(&Event::CheckpointCompleted { - node_id: node.id().to_string(), - status, - current_node: node.id().to_string(), - completed_nodes: state.completed_nodes.clone(), - node_retries: state - .node_retries - .clone() - .into_iter() - .collect::>(), - context_values: context_values.into_iter().collect::>(), - node_outcomes: node_outcomes.into_iter().collect::>(), - next_node_id: next_node_id.map(ToOwned::to_owned), - git_commit_sha: git_sha.clone(), - loop_failure_signatures: loop_failure_signatures.unwrap_or_default(), - restart_failure_signatures: restart_failure_signatures.unwrap_or_default(), - node_visits: state - .node_visits - .clone() - .into_iter() - .collect::>(), - diff, - }); + let scope = stage_scope_for(state, node.id()); + self.emitter.emit_scoped( + &Event::CheckpointCompleted { + node_id: node.id().to_string(), + status, + current_node: node.id().to_string(), + completed_nodes: state.completed_nodes.clone(), + node_retries: state + .node_retries + .clone() + .into_iter() + .collect::>(), + context_values: context_values.into_iter().collect::>(), + node_outcomes: node_outcomes.into_iter().collect::>(), + next_node_id: next_node_id.map(ToOwned::to_owned), + git_commit_sha: git_sha.clone(), + loop_failure_signatures: loop_failure_signatures.unwrap_or_default(), + restart_failure_signatures: restart_failure_signatures.unwrap_or_default(), + node_visits: state + .node_visits + .clone() + .into_iter() + .collect::>(), + diff, + }, + &scope, + ); // Emit GitCommit + GitPush events if git produced results if let Some(ref result) = git_result { if let Some(ref sha) = result.commit_sha { - self.emitter.emit(&Event::GitCommit { - node_id: Some(node.id().to_string()), - sha: sha.clone(), - }); + self.emitter.emit_scoped( + &Event::GitCommit { + node_id: Some(node.id().to_string()), + sha: sha.clone(), + }, + &scope, + ); } for (branch, success) in &result.push_results { self.emitter.emit(&Event::GitPush { diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs index 1b5ba6b4c..f2d38ee6d 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/git.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs @@ -15,6 +15,7 @@ use crate::event::{Emitter, Event, RunNoticeLevel}; use crate::git::MetadataStore; use crate::graph::WorkflowGraph; use crate::graph::WorkflowNode; +use crate::lifecycle::event::stage_scope_for; use crate::outcome::{BilledModelUsage, Outcome, StageStatus}; use crate::run_dump::RunDump; use crate::run_options::RunOptions; @@ -283,10 +284,14 @@ impl RunLifecycle for GitLifecycle { } Err(e) => { // Emit CheckpointFailed and return error - self.emitter.emit(&Event::CheckpointFailed { - node_id: node_id.to_string(), - error: e.clone(), - }); + let scope = stage_scope_for(state, node_id); + self.emitter.emit_scoped( + &Event::CheckpointFailed { + node_id: node_id.to_string(), + error: e.clone(), + }, + &scope, + ); return Err(CoreError::Other(format!( "git checkpoint commit failed for node '{node_id}': {e}" ))); diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index f0886f405..fe22570be 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -219,6 +219,7 @@ async fn persist_created_run( manifest_blob, }, record.run_id.created_at(), + None, ); let payload = fabro_store::EventPayload::new( serde_json::to_value(&stored).map_err(|err| FabroError::engine(err.to_string()))?, diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 33c5bd1e8..914a9daf5 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -1197,9 +1197,6 @@ mod tests { node_id: "plan".to_string(), name: "plan".to_string(), index: 0, - visit: 1, - parallel_group_id: None, - parallel_branch_id: None, duration_ms: 1, status: "success".to_string(), preferred_label: None, diff --git a/lib/crates/fabro-workflow/src/pipeline/retro.rs b/lib/crates/fabro-workflow/src/pipeline/retro.rs index 7073e4cf7..1511c0ced 100644 --- a/lib/crates/fabro-workflow/src/pipeline/retro.rs +++ b/lib/crates/fabro-workflow/src/pipeline/retro.rs @@ -76,8 +76,6 @@ pub async fn run_retro(options: &RetroOptions, dry_run: bool) -> Option { event: event.event.clone(), session_id: Some(event.session_id.clone()), parent_session_id: event.parent_session_id.clone(), - parallel_group_id: None, - parallel_branch_id: None, }); } }) From 8097c224ecc450ad0b6f8aa839267bb3daa62bfd Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 16:42:24 -0400 Subject: [PATCH 11/13] feat(events): populate actor on control-action events Per the schema v2 spec (docs-internal/fabro-event-schema-v2-concrete-shape.md:208-229), `actor` is expected on control actions like `run.cancel.requested` to identify the user who initiated the request. Before this commit, the three Event::Run{Cancel,Pause,Unpause}Requested variants were bare unit variants and the cancel/pause/unpause HTTP handlers used the _auth: AuthenticatedService ZST extractor which discards user identity. - fabro-workflow/src/event.rs: add `actor: Option` to Event::RunCancelRequested, Event::RunPauseRequested, Event::RunUnpauseRequested. Add a stored_event_fields_for_variant match arm that copies the actor into the envelope. Update event_body_from_event, event_name, and the trace! debug arm to ignore the new field via `{ .. }`. - fabro-server/src/server.rs: switch cancel_run, pause_run, unpause_run from _auth: AuthenticatedService to subject: AuthenticatedSubject (which handles cookie/JWT/mTLS identity uniformly via lib/crates/fabro-server/src/jwt_auth.rs). Add an actor_from_subject helper that mirrors the existing actor_from_provenance in fabro-workflow -- both produce an ActorRef { kind: User, id: login, display: login }. append_control_request takes a new Option argument and constructs the variants with it. Test call sites pass None. Test: new unit test control_action_events_carry_actor_in_envelope in event.rs covering cancel/pause/unpause with Some(actor) and unpause with None. Run mode AuthMode::Disabled returns subject.login = None, so actor ends up None in that path -- matches the spec's "actor is optional" guidance. Wire format is backward compatible: actor uses #[serde(default, skip_serializing_if = "Option::is_none")] so old persisted events without the field still parse cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-server/src/server.rs | 60 +++++++++++++++------ lib/crates/fabro-workflow/src/event.rs | 73 +++++++++++++++++++++----- 2 files changed, 105 insertions(+), 28 deletions(-) diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 93f6bfde4..80d872997 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -34,9 +34,9 @@ use fabro_store::{ ArtifactStore, Database, EventEnvelope, EventPayload, PendingInterviewRecord, StageId, }; use fabro_types::{ - EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, - RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, RunSubjectProvenance, - Settings, + ActorKind, ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, + RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, + RunSubjectProvenance, Settings, }; use fabro_util::redact::redact_jsonl_line; use fabro_util::version::FABRO_VERSION; @@ -5143,16 +5143,26 @@ async fn append_control_request( state: &AppState, run_id: RunId, action: RunControlAction, + actor: Option, ) -> anyhow::Result<()> { let run_store = state.store.open_run(&run_id).await?; let event = match action { - RunControlAction::Cancel => workflow_event::Event::RunCancelRequested, - RunControlAction::Pause => workflow_event::Event::RunPauseRequested, - RunControlAction::Unpause => workflow_event::Event::RunUnpauseRequested, + RunControlAction::Cancel => workflow_event::Event::RunCancelRequested { actor }, + RunControlAction::Pause => workflow_event::Event::RunPauseRequested { actor }, + RunControlAction::Unpause => workflow_event::Event::RunUnpauseRequested { actor }, }; workflow_event::append_event(&run_store, &run_id, &event).await } +fn actor_from_subject(subject: &AuthenticatedSubject) -> Option { + let login = subject.login.clone()?; + Some(ActorRef { + kind: ActorKind::User, + id: Some(login.clone()), + display: Some(login), + }) +} + fn schedule_worker_kill(state: Arc, run_id: RunId, worker_pid: u32) { tokio::spawn(async move { sleep(WORKER_CANCEL_GRACE).await; @@ -5168,7 +5178,7 @@ fn schedule_worker_kill(state: Arc, run_id: RunId, worker_pid: u32) { } async fn cancel_run( - _auth: AuthenticatedService, + subject: AuthenticatedSubject, State(state): State>, Path(id): Path, ) -> Response { @@ -5234,7 +5244,13 @@ async fn cancel_run( }; if pending_control != Some(RunControlAction::Cancel) { - if let Err(err) = append_control_request(state.as_ref(), id, RunControlAction::Cancel).await + if let Err(err) = append_control_request( + state.as_ref(), + id, + RunControlAction::Cancel, + actor_from_subject(&subject), + ) + .await { return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) .into_response(); @@ -5286,7 +5302,7 @@ async fn cancel_run( } async fn pause_run( - _auth: AuthenticatedService, + subject: AuthenticatedSubject, State(state): State>, Path(id): Path, ) -> Response { @@ -5324,7 +5340,14 @@ async fn pause_run( let Some(worker_pid) = worker_pid else { return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.").into_response(); }; - if let Err(err) = append_control_request(state.as_ref(), id, RunControlAction::Pause).await { + if let Err(err) = append_control_request( + state.as_ref(), + id, + RunControlAction::Pause, + actor_from_subject(&subject), + ) + .await + { return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); } #[cfg(unix)] @@ -5347,7 +5370,7 @@ async fn pause_run( } async fn unpause_run( - _auth: AuthenticatedService, + subject: AuthenticatedSubject, State(state): State>, Path(id): Path, ) -> Response { @@ -5385,7 +5408,14 @@ async fn unpause_run( let Some(worker_pid) = worker_pid else { return ApiError::new(StatusCode::CONFLICT, "Run worker is not available.").into_response(); }; - if let Err(err) = append_control_request(state.as_ref(), id, RunControlAction::Unpause).await { + if let Err(err) = append_control_request( + state.as_ref(), + id, + RunControlAction::Unpause, + actor_from_subject(&subject), + ) + .await + { return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); } #[cfg(unix)] @@ -7448,7 +7478,7 @@ mod tests { managed_run.status = RunStatus::Running; managed_run.worker_pid = Some(u32::MAX); } - append_control_request(state.as_ref(), run_id, RunControlAction::Pause) + append_control_request(state.as_ref(), run_id, RunControlAction::Pause, None) .await .unwrap(); @@ -7479,7 +7509,7 @@ mod tests { managed_run.status = RunStatus::Running; managed_run.worker_pid = Some(u32::MAX); } - append_control_request(state.as_ref(), run_id, RunControlAction::Cancel) + append_control_request(state.as_ref(), run_id, RunControlAction::Cancel, None) .await .unwrap(); @@ -7613,7 +7643,7 @@ mod tests { workflow_event::Event::RunStarting { reason: None }, workflow_event::Event::RunRunning { reason: None }, workflow_event::Event::RunPaused, - workflow_event::Event::RunCancelRequested, + workflow_event::Event::RunCancelRequested { actor: None }, ], ) .await; diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 060f7d116..2f299a7e3 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -93,9 +93,18 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] reason: Option, }, - RunCancelRequested, - RunPauseRequested, - RunUnpauseRequested, + RunCancelRequested { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, + RunPauseRequested { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, + RunUnpauseRequested { + #[serde(default, skip_serializing_if = "Option::is_none")] + actor: Option, + }, RunPaused, RunUnpaused, RunRewound { @@ -575,13 +584,13 @@ impl Event { Self::RunRemoving { reason } => { info!(?reason, "Run removing"); } - Self::RunCancelRequested => { + Self::RunCancelRequested { .. } => { info!("Run cancel requested"); } - Self::RunPauseRequested => { + Self::RunPauseRequested { .. } => { info!("Run pause requested"); } - Self::RunUnpauseRequested => { + Self::RunUnpauseRequested { .. } => { info!("Run unpause requested"); } Self::RunPaused => { @@ -1140,9 +1149,9 @@ pub fn event_name(event: &Event) -> &'static str { Event::RunStarting { .. } => "run.starting", Event::RunRunning { .. } => "run.running", Event::RunRemoving { .. } => "run.removing", - Event::RunCancelRequested => "run.cancel.requested", - Event::RunPauseRequested => "run.pause.requested", - Event::RunUnpauseRequested => "run.unpause.requested", + Event::RunCancelRequested { .. } => "run.cancel.requested", + Event::RunPauseRequested { .. } => "run.pause.requested", + Event::RunUnpauseRequested { .. } => "run.unpause.requested", Event::RunPaused => "run.paused", Event::RunUnpaused => "run.unpaused", Event::RunRewound { .. } => "run.rewound", @@ -1329,6 +1338,12 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields { actor: provenance.as_ref().and_then(actor_from_provenance), ..StoredEventFields::default() }, + Event::RunCancelRequested { actor } + | Event::RunPauseRequested { actor } + | Event::RunUnpauseRequested { actor } => StoredEventFields { + actor: actor.clone(), + ..StoredEventFields::default() + }, Event::StageCompleted { node_id, name, .. } | Event::StageFailed { node_id, name, .. } | Event::StageStarted { node_id, name, .. } @@ -1513,17 +1528,17 @@ fn event_body_from_event(event: &Event) -> EventBody { Event::RunRemoving { reason } => { EventBody::RunRemoving(fabro_types::RunStatusTransitionProps { reason: *reason }) } - Event::RunCancelRequested => { + Event::RunCancelRequested { .. } => { EventBody::RunCancelRequested(fabro_types::RunControlRequestedProps { action: RunControlAction::Cancel, }) } - Event::RunPauseRequested => { + Event::RunPauseRequested { .. } => { EventBody::RunPauseRequested(fabro_types::RunControlRequestedProps { action: RunControlAction::Pause, }) } - Event::RunUnpauseRequested => { + Event::RunUnpauseRequested { .. } => { EventBody::RunUnpauseRequested(fabro_types::RunControlRequestedProps { action: RunControlAction::Unpause, }) @@ -3071,7 +3086,7 @@ mod tests { let (writer, reader) = tokio::io::duplex(4096); let sink = RunEventSink::json_lines(writer); - let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested); + let event = to_run_event(&fixtures::RUN_7, &Event::RunPauseRequested { actor: None }); sink.write_run_event(&event).await.unwrap(); @@ -3315,6 +3330,38 @@ mod tests { assert!(stored.parallel_branch_id.is_none()); } + #[test] + fn control_action_events_carry_actor_in_envelope() { + let actor = ActorRef { + kind: ActorKind::User, + id: Some("alice".to_string()), + display: Some("alice".to_string()), + }; + + let cancel = to_run_event( + &fixtures::RUN_1, + &Event::RunCancelRequested { + actor: Some(actor.clone()), + }, + ); + assert_eq!(cancel.event_name(), "run.cancel.requested"); + assert_eq!(cancel.actor.as_ref().expect("actor set"), &actor); + + let pause = to_run_event( + &fixtures::RUN_1, + &Event::RunPauseRequested { + actor: Some(actor.clone()), + }, + ); + assert_eq!(pause.actor.as_ref().expect("actor set"), &actor); + + let unpause = to_run_event( + &fixtures::RUN_1, + &Event::RunUnpauseRequested { actor: None }, + ); + assert!(unpause.actor.is_none()); + } + #[test] fn agent_assistant_message_populates_agent_actor() { let stored = to_run_event( From eae89a6f5395fdb6ea8e3ae83d31240ceffa1c40 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 17:23:54 -0400 Subject: [PATCH 12/13] refactor(events): dedupe schema v2 plumbing Mostly consolidation of code added in the recent schema v2 work: - Share a single ActorRef::user() constructor between server control actions and workflow provenance conversions. - Share StageScope::from_context() between current_stage_scope and StageScope::for_handler so the 4-field construction lives in one place. - Collapse RunEvent::to_value's if-let chain into an insert_opt helper. - Use Value::String(id.to_string()) instead of serde_json::to_value for StageId/ParallelBranchId when seeding the parallel branch context. - Share parse_event_envelopes via tests/it/support/mod.rs instead of duplicating the parsing block in two CLI run_events helpers. Also fix parallel-branch git.commit to emit via emit_scoped with a branch-specific StageScope so it carries stage_id / parallel_group_id / parallel_branch_id alongside the other stage-scoped events. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/crates/fabro-cli/tests/it/cmd/support.rs | 10 +-- lib/crates/fabro-cli/tests/it/support/mod.rs | 12 +++ lib/crates/fabro-cli/tests/it/workflow/mod.rs | 10 +-- lib/crates/fabro-server/src/server.rs | 9 +-- lib/crates/fabro-types/src/run_event/mod.rs | 81 ++++++++++--------- lib/crates/fabro-workflow/src/context.rs | 9 +-- lib/crates/fabro-workflow/src/event.rs | 35 ++++---- .../fabro-workflow/src/handler/parallel.rs | 25 +++--- 8 files changed, 97 insertions(+), 94 deletions(-) diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 53e75af42..3db026f63 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -661,15 +661,7 @@ pub(crate) fn run_events(run_dir: &Path) -> Vec { run_dir, &format!("/api/v1/runs/{run_id}/events"), )); - let items = response["data"] - .as_array() - .cloned() - .expect("event list response should contain a data array"); - items - .into_iter() - .map(serde_json::from_value) - .collect::, _>>() - .expect("wire event envelope list should parse") + crate::support::parse_event_envelopes(&response) } pub(crate) fn wait_for_event_names(run_dir: &Path, expected: &[&str]) { diff --git a/lib/crates/fabro-cli/tests/it/support/mod.rs b/lib/crates/fabro-cli/tests/it/support/mod.rs index 5e965f312..5fcd359e0 100644 --- a/lib/crates/fabro-cli/tests/it/support/mod.rs +++ b/lib/crates/fabro-cli/tests/it/support/mod.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; use assert_cmd::Command; +use fabro_store::EventEnvelope; use fabro_test::TestContext; use fabro_types::RunId; macro_rules! fabro_json_snapshot { @@ -60,6 +61,17 @@ pub(crate) fn unique_run_id() -> String { RunId::new().to_string() } +pub(crate) fn parse_event_envelopes(response: &serde_json::Value) -> Vec { + response["data"] + .as_array() + .expect("event list response should contain a data array") + .iter() + .cloned() + .map(serde_json::from_value) + .collect::, _>>() + .expect("wire event envelope list should parse") +} + pub(crate) struct LightweightCli { home_dir: tempfile::TempDir, } diff --git a/lib/crates/fabro-cli/tests/it/workflow/mod.rs b/lib/crates/fabro-cli/tests/it/workflow/mod.rs index b07bed9e2..5ada38260 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/mod.rs @@ -173,15 +173,7 @@ fn run_events(run_dir: &Path) -> Vec { storage_dir, &format!("/api/v1/runs/{run_id}/events"), )); - let items = response["data"] - .as_array() - .cloned() - .expect("event list response should contain a data array"); - items - .into_iter() - .map(serde_json::from_value) - .collect::, _>>() - .expect("wire event envelope list should parse") + crate::support::parse_event_envelopes(&response) } macro_rules! sandbox_tests { diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 80d872997..63db2baad 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -34,7 +34,7 @@ use fabro_store::{ ArtifactStore, Database, EventEnvelope, EventPayload, PendingInterviewRecord, StageId, }; use fabro_types::{ - ActorKind, ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, + ActorRef, EventBody, InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance, RunServerProvenance, RunSubjectProvenance, Settings, }; @@ -5155,12 +5155,7 @@ async fn append_control_request( } fn actor_from_subject(subject: &AuthenticatedSubject) -> Option { - let login = subject.login.clone()?; - Some(ActorRef { - kind: ActorKind::User, - id: Some(login.clone()), - display: Some(login), - }) + subject.login.clone().map(ActorRef::user) } fn schedule_worker_kill(state: Arc, run_id: RunId, worker_pid: u32) { diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs index 80fe77849..b9949bbd6 100644 --- a/lib/crates/fabro-types/src/run_event/mod.rs +++ b/lib/crates/fabro-types/src/run_event/mod.rs @@ -44,6 +44,17 @@ pub struct ActorRef { pub display: Option, } +impl ActorRef { + #[must_use] + pub fn user(login: String) -> Self { + Self { + kind: ActorKind::User, + id: Some(login.clone()), + display: Some(login), + } + } +} + #[derive(Debug, Clone, PartialEq)] pub struct RunEvent { pub id: String, @@ -677,50 +688,46 @@ impl RunEvent { } pub fn to_value(&self) -> serde_json::Result { + fn insert_opt( + map: &mut Map, + key: &str, + value: Option<&T>, + ) -> serde_json::Result<()> { + if let Some(v) = value { + map.insert(key.to_string(), serde_json::to_value(v)?); + } + Ok(()) + } + let mut map = Map::new(); - map.insert("id".to_string(), serde_json::to_value(&self.id)?); + map.insert("id".to_string(), Value::String(self.id.clone())); map.insert("ts".to_string(), serde_json::to_value(self.ts)?); map.insert("run_id".to_string(), serde_json::to_value(self.run_id)?); map.insert( "event".to_string(), Value::String(self.body.event_name().to_string()), ); - if let Some(value) = &self.session_id { - map.insert("session_id".to_string(), Value::String(value.clone())); - } - if let Some(value) = &self.parent_session_id { - map.insert( - "parent_session_id".to_string(), - Value::String(value.clone()), - ); - } - if let Some(value) = &self.node_id { - map.insert("node_id".to_string(), Value::String(value.clone())); - } - if let Some(value) = &self.node_label { - map.insert("node_label".to_string(), Value::String(value.clone())); - } - if let Some(value) = &self.stage_id { - map.insert("stage_id".to_string(), serde_json::to_value(value)?); - } - if let Some(value) = &self.parallel_group_id { - map.insert( - "parallel_group_id".to_string(), - serde_json::to_value(value)?, - ); - } - if let Some(value) = &self.parallel_branch_id { - map.insert( - "parallel_branch_id".to_string(), - serde_json::to_value(value)?, - ); - } - if let Some(value) = &self.tool_call_id { - map.insert("tool_call_id".to_string(), Value::String(value.clone())); - } - if let Some(actor) = &self.actor { - map.insert("actor".to_string(), serde_json::to_value(actor)?); - } + insert_opt(&mut map, "session_id", self.session_id.as_ref())?; + insert_opt( + &mut map, + "parent_session_id", + self.parent_session_id.as_ref(), + )?; + insert_opt(&mut map, "node_id", self.node_id.as_ref())?; + insert_opt(&mut map, "node_label", self.node_label.as_ref())?; + insert_opt(&mut map, "stage_id", self.stage_id.as_ref())?; + insert_opt( + &mut map, + "parallel_group_id", + self.parallel_group_id.as_ref(), + )?; + insert_opt( + &mut map, + "parallel_branch_id", + self.parallel_branch_id.as_ref(), + )?; + insert_opt(&mut map, "tool_call_id", self.tool_call_id.as_ref())?; + insert_opt(&mut map, "actor", self.actor.as_ref())?; map.insert("properties".to_string(), self.body.properties_value()?); Ok(Value::Object(map)) } diff --git a/lib/crates/fabro-workflow/src/context.rs b/lib/crates/fabro-workflow/src/context.rs index 482089f38..00eb7a861 100644 --- a/lib/crates/fabro-workflow/src/context.rs +++ b/lib/crates/fabro-workflow/src/context.rs @@ -136,7 +136,6 @@ pub mod keys { pub use fabro_core::Context; use crate::event::StageScope; -use crate::run_dir::visit_from_context; use fabro_graphviz::Fidelity; use fabro_types::{ParallelBranchId, StageId}; @@ -188,13 +187,7 @@ impl WorkflowContext for Context { let node_id = self .get(keys::CURRENT_NODE) .and_then(|value| value.as_str().map(String::from))?; - let visit = u32::try_from(visit_from_context(self)).unwrap_or(u32::MAX); - Some(StageScope { - node_id, - visit, - parallel_group_id: self.parallel_group_id(), - parallel_branch_id: self.parallel_branch_id(), - }) + Some(StageScope::from_context(self, node_id)) } } diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 2f299a7e3..c5eb56052 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -1435,12 +1435,12 @@ fn stored_event_fields_for_variant(event: &Event) -> StoredEventFields { } fn actor_from_provenance(provenance: &RunProvenance) -> Option { - let login = provenance.subject.as_ref()?.login.clone()?; - Some(ActorRef { - kind: ActorKind::User, - id: Some(login.clone()), - display: Some(login), - }) + provenance + .subject + .as_ref()? + .login + .clone() + .map(ActorRef::user) } fn agent_tool_call_id(event: &AgentEvent) -> Option<&str> { @@ -2475,18 +2475,25 @@ pub struct StageScope { } impl StageScope { - /// Build scope for a handler invocation. Prefers the current_stage_scope - /// set by the fidelity lifecycle before_attempt hook, but falls back to - /// a scope synthesized from the node id and the context's visit count - /// for tests and other direct-handler call sites that don't go through - /// the full lifecycle. - pub fn for_handler(context: &WfContext, node_id: impl Into) -> Self { - context.current_stage_scope().unwrap_or_else(|| Self { + /// Build a scope from the given node id, sourcing visit count and parallel + /// ids from the current context. + pub fn from_context(context: &WfContext, node_id: impl Into) -> Self { + Self { node_id: node_id.into(), visit: u32::try_from(visit_from_context(context)).unwrap_or(u32::MAX), parallel_group_id: context.parallel_group_id(), parallel_branch_id: context.parallel_branch_id(), - }) + } + } + + /// Build scope for a handler invocation. Prefers the `current_stage_scope` + /// seeded by the fidelity lifecycle before_attempt hook, and falls back to + /// synthesizing one from `node_id` for direct-handler call sites (tests, + /// etc.) that don't go through the full lifecycle. + pub fn for_handler(context: &WfContext, node_id: impl Into) -> Self { + context + .current_stage_scope() + .unwrap_or_else(|| Self::from_context(context, node_id)) } } diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index 0592595b9..acbb8fc7c 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -214,12 +214,11 @@ impl Handler for ParallelHandler { ); branch_context.set( keys::INTERNAL_PARALLEL_GROUP_ID, - serde_json::to_value(¶llel_group_id).expect("StageId serializes as string"), + serde_json::Value::String(parallel_group_id.to_string()), ); branch_context.set( keys::INTERNAL_PARALLEL_BRANCH_ID, - serde_json::to_value(¶llel_branch_id) - .expect("ParallelBranchId serializes as string"), + serde_json::Value::String(parallel_branch_id.to_string()), ); let (branch_sandbox, worktree_path): (Arc, Option) = if let ( @@ -280,8 +279,6 @@ impl Handler for ParallelHandler { }); } - let parent_scope = StageScope::for_handler(context, &node.id); - // --- Fan out: concurrent execution --- let mut handles = Vec::new(); for setup in branch_setups { @@ -304,7 +301,12 @@ impl Handler for ParallelHandler { .map(|gs| gs.git_author.clone()) .unwrap_or_default(); let group_id = parallel_group_id.clone(); - let branch_scope = parent_scope.clone(); + let branch_scope = StageScope { + node_id: setup.target_id.clone(), + visit: 1, + parallel_group_id: Some(group_id.clone()), + parallel_branch_id: Some(setup.parallel_branch_id.clone()), + }; let handle = tokio::spawn(async move { let _permit = sem @@ -405,10 +407,13 @@ impl Handler for ParallelHandler { match sha_result { Ok(r) if r.exit_code == 0 => { let sha = r.stdout.trim().to_string(); - emitter.emit(&Event::GitCommit { - node_id: Some(setup.target_id.clone()), - sha: sha.clone(), - }); + emitter.emit_scoped( + &Event::GitCommit { + node_id: Some(setup.target_id.clone()), + sha: sha.clone(), + }, + &branch_scope, + ); Some(sha) } _ => None, From cbbbc6f90f592bca338e77fad4c7da0f74d2f002 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Thu, 9 Apr 2026 17:24:30 -0400 Subject: [PATCH 13/13] docs --- .../event-schema-competitive-analysis.md | 376 +++++++++++ .../fabro-event-schema-v2-concrete-shape.md | 484 +++++++++++++ .../fabro-event-schema-v2-proposal.md | 633 ++++++++++++++++++ .../2026-04-08-fabro-event-schema-ideation.md | 134 ++++ 4 files changed, 1627 insertions(+) create mode 100644 docs-internal/event-schema-competitive-analysis.md create mode 100644 docs-internal/fabro-event-schema-v2-concrete-shape.md create mode 100644 docs-internal/fabro-event-schema-v2-proposal.md create mode 100644 docs/ideation/2026-04-08-fabro-event-schema-ideation.md diff --git a/docs-internal/event-schema-competitive-analysis.md b/docs-internal/event-schema-competitive-analysis.md new file mode 100644 index 000000000..9763fdf30 --- /dev/null +++ b/docs-internal/event-schema-competitive-analysis.md @@ -0,0 +1,376 @@ +# Event Schema Competitive Analysis + +Date: 2026-04-08 + +This report compares the event schemas used by: + +- Claude Sessions API +- Claude Code +- Goose +- OpenAI Codex +- OpenCode +- pi-mono + +Goal: identify patterns Fabro should copy, avoid, or formalize more clearly. + +## Executive Summary + +Fabro's current event model is already ahead of most of the field on one important point: it has a canonical envelope with stable metadata (`id`, `ts`, `run_id`, `event`, optional `session_id`, `parent_session_id`, `node_id`, `node_label`) and a typed internal-to-external mapping. + +The biggest improvement opportunities are not "more events." They are: + +1. Keep transport concerns separate from domain events, but document them as part of the contract. +2. Make every streamed event part of one explicit public schema. Avoid opaque blobs and server-injected fields that the schema does not admit. +3. Add more first-class correlation fields where the UI or downstream systems need them, especially `turn_id`, `message_id`, `tool_call_id`, `request_id`, and retry/attempt IDs. +4. Make retry, stop, idle, and requires-action states machine-readable unions instead of loose strings. +5. Be explicit about delta vs snapshot semantics and replay behavior. + +## Fabro Baseline + +Fabro's current strategy is documented in `docs-internal/events-strategy.md`. The canonical external shape is: + +```json +{ + "id": "uuidv7", + "ts": "2026-03-30T12:00:01.000Z", + "run_id": "01JQ...", + "event": "agent.tool.started", + "session_id": "ses_child", + "parent_session_id": "ses_parent", + "node_id": "code", + "node_label": "Code", + "properties": { "...": "..." } +} +``` + +That envelope is stronger than most comparator systems. It gives Fabro stable top-level metadata, keeps event-specific data inside `properties`, and avoids flattening arbitrary fields into the root. + +Relevant current Fabro sources: + +- `docs-internal/events-strategy.md` +- `lib/crates/fabro-workflow/src/event.rs` +- `lib/crates/fabro-types/src/run_event/mod.rs` +- `lib/crates/fabro-agent/src/types.rs` + +## Comparison Matrix + +| System | Public event surface | Discriminator | Universal envelope fields on every event | Replay / ordering story | Main strength | Main weakness | +| --- | --- | --- | --- | --- | --- | --- | +| Claude Sessions | 20-event public union | `type` | `id`, `processed_at` | Event IDs exist; replay semantics are not part of the event payload | Very explicit, stable union with typed nested states | Less transport detail and fewer workflow-specific events | +| Claude Code | 24 core SDK messages, 31 stdout/control variants | `type`, often `subtype` | Usually `uuid`, `session_id`; no universal timestamp | Streaming is batched/coalesced; control and domain share the same channel | Rich task, hook, status, and tool progress | Nested stream payload is opaque at runtime; control and data are mixed | +| Goose | 7 `MessageEvent` variants | `type` | None in JSON payload; SSE `id` is outside payload | Strong SSE replay via monotonic seq + `Last-Event-ID` + replay buffer | Reattach/replay semantics are clear | Transport fields are injected outside schema; payload typing is shallow | +| Codex SDK | 8 thread events | `type` | No universal ID/timestamp | Ordered stream, no replay contract in payload | Very simple client model | Too minimal for rich UIs and analytics | +| Codex app-server | 49 notification methods | `method` + `params` | No universal ID/timestamp in params | Ordered notifications, no seq/replay field | Richest low-level protocol in the set | Fragmented event story; transport shape leaks into the schema | +| OpenCode | 45 generated event variants | `type` + `properties` | No universal ID/timestamp | Plain SSE; client supports SSE IDs but server does not emit them | Broadest app/runtime event coverage | Wire/schema drift and no universal envelope | +| pi-mono | 12 assistant stream events, 10 agent events, 14 session events | `type` | Session header only; not per event | JSONL stream, no replay contract | Excellent streaming lifecycle grammar | No durable universal envelope for downstream consumers | + +## System Notes + +### Claude Sessions + +What it does well: + +- One explicit public union. +- Every event has `id`, `type`, and `processed_at`. +- Tool confirmation, custom tool results, MCP tool use, session errors, session status, and model-span events are all first-class. +- Terminal and waiting states are structured. `session.status_idle.stop_reason` is not a loose string; it is a small union. +- Error reporting is structured. `session.error.error` is a tagged union, not just a message. + +Why it matters for Fabro: + +- This is the cleanest example of a public agent-session event API that is still small enough to understand. +- The main idea to copy is not the exact event list. It is the discipline: explicit tagged unions for stop reasons, errors, and status transitions. + +Sources: + +- `https://platform.claude.com/docs/en/api/beta/sessions/events/stream` +- `https://platform.claude.com/docs/specs/merged.53db30dfcc06f431.json.gz` + +### Claude Code + +Schema shape: + +- `SDKMessageSchema` contains 24 core message variants. +- `StdoutMessageSchema` expands the stdout protocol to 31 variants once control messages and keep-alives are included. +- Many events use `type: "system"` plus a `subtype`, for example `init`, `status`, `api_retry`, `hook_started`, `task_progress`, and `session_state_changed`. +- Streaming assistant output is wrapped as `type: "stream_event"`. + +What it does well: + +- It covers more than just model output: task lifecycle, hook lifecycle, compaction boundaries, retries, authentication state, file persistence, tool progress, prompt suggestions. +- It carries `uuid` and `session_id` widely, which is useful for correlation. +- It includes explicit session-state transitions (`idle`, `running`, `requires_action`). + +What is weak: + +- The nested streaming payload is not explicitly validated at runtime. `RawMessageStreamEventPlaceholder` is `z.unknown()`. +- Control protocol messages live in the same stream as domain messages. +- `type: "system"` plus `subtype` is workable, but less ergonomic than a flatter public union. +- There is no universal top-level timestamp on every event. + +Why it matters for Fabro: + +- Copy the breadth, not the shape. +- Avoid opaque inner payloads in public schemas. +- Avoid mixing keep-alive/control/config traffic into the same schema that product consumers use for analytics and UI rendering. + +Sources: + +- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/entrypoints/sdk/coreSchemas.ts` +- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/entrypoints/sdk/controlSchemas.ts` +- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/remote/sdkMessageAdapter.ts` +- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/cli/transports/ccrClient.ts` +- `/Users/bhelmkamp/p/AnkanMisra/claude-code/src/utils/sdkEventQueue.ts` + +### Goose + +Schema shape: + +- One small SSE payload union: `Message`, `Error`, `Finish`, `Notification`, `UpdateConversation`, `ActiveRequests`, `Ping`. +- SSE `id:` carries a monotonic sequence number. +- Session replay uses `Last-Event-ID` plus a replay buffer. +- `request_id` and `chat_request_id` are injected at the SSE framing layer, not modeled in the payload type. + +What it does well: + +- Clear reattach story. +- Monotonic sequence numbers are transport-level, not payload-level. +- `ActiveRequests` lets the client discover in-flight work when reconnecting. + +What is weak: + +- The public event payload omits fields the client actually consumes. +- `Notification.message` is effectively an untyped object. +- The session stream also emits comment heartbeats outside the schema, and there is a separate `Ping` payload variant in the shared enum. That split is easy to drift. + +Why it matters for Fabro: + +- Goose is the best example here for replay and reconnect semantics. +- The lesson is not "put sequence numbers in the payload." The lesson is "formalize replay outside the payload, and do not rely on undocumented injected fields." + +Sources: + +- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/routes/reply.rs` +- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/routes/session_events.rs` +- `/Users/bhelmkamp/p/block/goose/crates/goose-server/src/session_event_bus.rs` +- `/Users/bhelmkamp/p/block/goose/ui/desktop/openapi.json` +- `/Users/bhelmkamp/p/block/goose/ui/desktop/src/hooks/useSessionEvents.ts` + +### OpenAI Codex + +There are really two event systems: + +1. The TypeScript SDK `ThreadEvent` surface. +2. The app-server `ServerNotification` protocol. + +SDK shape: + +- 8 high-level events: thread started, turn started/completed/failed, item started/updated/completed, fatal stream error. +- Rich detail is pushed down into `ThreadItem`, which includes `agent_message`, `reasoning`, `command_execution`, `file_change`, `mcp_tool_call`, `web_search`, `todo_list`, and `error`. + +App-server shape: + +- 49 server notification methods. +- Notifications are discriminated by `method`, with a typed `params` object. +- Coverage includes thread lifecycle, turn lifecycle, item lifecycle, deltas, token usage, command output, MCP progress, model reroutes, config warnings, and experimental realtime notifications. + +What it does well: + +- Good separation of a simple developer-facing SDK from a richer system protocol. +- The low-level protocol is broad and explicit. +- Experimental notifications are clearly labeled as experimental. + +What is weak: + +- The event story is fragmented. "Which schema should I build against?" depends on which integration layer you pick. +- There is no universal timestamp or universal event ID in the event bodies. +- `method` + `params` is transport-shaped. It works well for JSON-RPC, but it is not as clean as a transport-agnostic event envelope. + +Why it matters for Fabro: + +- If Fabro needs both a high-level SDK and a low-level protocol, document the layering explicitly. +- If Fabro only needs one event stream, a single canonical envelope is simpler than method-shaped notifications. + +Sources: + +- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/events.ts` +- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/items.ts` +- `/Users/bhelmkamp/p/openai/codex/sdk/typescript/src/thread.ts` +- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts` +- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/src/protocol/common.rs` +- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartedNotification.ts` +- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/TurnCompletedNotification.ts` +- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/AgentMessageDeltaNotification.ts` +- `/Users/bhelmkamp/p/openai/codex/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecOutputDeltaNotification.ts` + +### OpenCode + +Schema shape: + +- One generated `Event` union with 45 variants. +- `GlobalEvent` adds `directory` plus `payload: Event`. +- Events use `type` plus a `properties` object. +- Coverage includes questions, permissions, messages, message parts, session status/idle/compacted/error/diff, workspace readiness, PTYs, worktrees, VCS, file edits, MCP, TUI commands, and more. + +What it does well: + +- Broad coverage. +- Generated API types from the server surface. +- Clear split between session-scoped events and global events. +- Status is partly structured. `SessionStatus` is a union of `idle`, `retry`, and `busy`. + +What is weak: + +- No universal event ID. +- No universal timestamp. +- No replay cursor or sequence field. +- The wire stream emits `server.heartbeat`, but the generated `Event` union does not include it. +- The global event schema says `directory` is present, but the initial global `server.connected` and heartbeat frames omit it. + +Why it matters for Fabro: + +- OpenCode shows how far a generated event surface can go. +- It also shows the cost of not having a canonical envelope: clients must reconstruct correlation from nested `sessionID`, `messageID`, `partID`, and route-specific wrappers. + +Sources: + +- `/Users/bhelmkamp/p/anomalyco/opencode/packages/sdk/js/src/v2/gen/types.gen.ts` +- `/Users/bhelmkamp/p/anomalyco/opencode/packages/sdk/js/src/v2/gen/core/serverSentEvents.gen.ts` +- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/server.ts` +- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/routes/global.ts` +- `/Users/bhelmkamp/p/anomalyco/opencode/packages/opencode/src/server/event.ts` +- `/Users/bhelmkamp/p/anomalyco/opencode/packages/web/src/content/docs/server.mdx` + +### pi-mono + +Schema shape: + +- `AssistantMessageEvent` has 12 streaming variants: `start`, block start/delta/end for text, thinking, and tool calls, then `done` or `error`. +- `AgentEvent` has 10 lifecycle variants across agent, turn, message, and tool execution. +- `AgentSessionEvent` extends `AgentEvent` with 4 session-only retry/compaction events. +- JSON mode starts with a session header, then emits JSONL events. + +What it does well: + +- Excellent streaming lifecycle grammar. +- Strong layering: + - low-level assistant stream events + - mid-level agent lifecycle events + - high-level session events +- The proxy mode has a bandwidth-optimized streaming shape that intentionally strips partial message snapshots and reconstructs them client-side. + +What is weak: + +- There is no universal per-event envelope. +- There are no event IDs or replay semantics. +- Timestamping is inconsistent. The session header has a timestamp, and some embedded message objects have timestamps, but not every event line does. + +Why it matters for Fabro: + +- pi-mono is the best example here for event layering and start/delta/end/done grammar. +- Fabro should borrow that lifecycle discipline if it expands live agent streaming, but keep Fabro's stronger envelope. + +Sources: + +- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/ai/src/types.ts` +- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/agent/src/types.ts` +- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/agent/src/proxy.ts` +- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/coding-agent/src/core/agent-session.ts` +- `/Users/bhelmkamp/p/badlogic/pi-mono/packages/coding-agent/docs/json.md` + +## Cross-System Patterns + +### Patterns worth copying + +- One obvious discriminator per public event. +- Explicit unions for error state, stop reason, retry state, and requires-action state. +- Stable correlation IDs for session/thread/turn/message/tool levels. +- A documented replay story for long-running streams. +- Generated public schemas from one source of truth. +- A clear distinction between snapshot events and delta events. + +### Patterns worth avoiding + +- Opaque `unknown` payloads inside otherwise typed events. +- Server-injected fields that the public schema does not model. +- Mixing keep-alives, control RPCs, and domain events in one event contract. +- Event systems that only make sense in the context of one transport, for example JSON-RPC `method`/`params`, when the real need is a transport-agnostic event log. +- No universal ID or timestamp on durable events. + +## Recommendations For Fabro + +### 1. Keep the canonical envelope + +Fabro should keep `id`, `ts`, `run_id`, `event`, `session_id`, `parent_session_id`, `node_id`, and `node_label` exactly as the backbone of the public schema. That is already better than every comparator except Claude Sessions on consistency. + +### 2. Do not let transport metadata leak informally + +If Fabro supports SSE replay or live reattach, define transport rules explicitly: + +- SSE `id` +- replay cursor semantics +- comment heartbeat vs payload heartbeat +- reconnect guarantees + +Do not make clients depend on extra fields injected by one server path that are absent from the formal schema. + +### 3. Add deeper correlation IDs where the product needs them + +Fabro already has run/session/node metadata. The next likely additions are: + +- `turn_id` +- `message_id` +- `tool_call_id` +- `request_id` +- `attempt` + +Those should be explicit schema fields, not encoded into ad hoc strings. + +### 4. Prefer unions over stringly terminal state + +If Fabro expands live session or agent events, model terminal and waiting states like Claude Sessions does: + +- `stop_reason` +- `retry_status` +- `requires_action` +- `error_kind` + +Avoid free-form strings when a small tagged union will do. + +### 5. Standardize lifecycle families + +If Fabro emits live agent output, choose one lifecycle grammar and document it: + +- `.started` +- `.delta` +- `.completed` +- `.failed` + +If snapshot replacement events also exist, mark them clearly and document when clients should treat them as authoritative replacement vs append-only updates. + +### 6. Keep domain events separate from control and keep-alive traffic + +Claude Code shows the downside of multiplexing control requests, control responses, keep-alives, and domain messages in one stream contract. Fabro's durable run events should stay product-facing and analyzable. + +### 7. Add schema-drift tests for streamed events + +OpenCode and Goose both show how easy it is for the wire stream to diverge from the published schema. Fabro should keep tests that validate: + +- every emitted streamed payload is representable by the public schema +- no consumer-visible fields are injected outside the schema +- replay/heartbeat frames are documented and tested separately + +## Bottom Line + +Fabro does not need to copy any one competitor's schema wholesale. + +The best composite design is: + +- Claude Sessions' explicit unions for status and errors +- Goose's replay semantics +- Codex's separation between a simple high-level client view and a richer low-level view, if Fabro ever needs both +- OpenCode's breadth of runtime events +- pi-mono's streaming lifecycle grammar +- Fabro's existing canonical envelope as the foundation + +That combination would produce an event model that is both durable and ergonomic: good for live UI streaming, replay, analytics, tests, and long-term compatibility. diff --git a/docs-internal/fabro-event-schema-v2-concrete-shape.md b/docs-internal/fabro-event-schema-v2-concrete-shape.md new file mode 100644 index 000000000..b4032a495 --- /dev/null +++ b/docs-internal/fabro-event-schema-v2-concrete-shape.md @@ -0,0 +1,484 @@ +# Fabro Event Schema V2: Concrete Shape + +Date: 2026-04-09 + +Status: implemented + +This document turns the settled design decisions from the event-schema discussion into a concrete wire-contract proposal. + +It intentionally supersedes the earlier framing in [fabro-event-schema-v2-proposal.md](/Users/bhelmkamp/p/fabro-sh/fabro/docs-internal/fabro-event-schema-v2-proposal.md) for: + +- proposal 1: one canonical persisted log, not two truths +- proposal 2: formalize and generalize the existing `since_seq` replay contract, rather than inventing replay from scratch + +## Design Decisions Carried Forward + +- one canonical persisted event log +- plain hand-coded Rust structs are the authoritative source of truth for the event contract +- `RunEvent` remains the canonical semantic event type +- `seq` remains outside `RunEvent`, in the store/API envelope +- replay stays built around ordered `since_seq` cursors +- typed Rust consumers matching on `EventBody` remain the primary consumer model +- the envelope widens only modestly for execution topology and tool-call correlation: `stage_id`, `parallel_group_id`, `parallel_branch_id`, `tool_call_id` +- existing durable event families stay broadly intact +- live token/delta noise does not become part of the durable persisted Rust event contract +- snapshots are out of scope for both the durable event contract and the attach API + +## Contract Source Of Truth + +V2 does not adopt schema generation or a registry-first workflow. + +The authoritative source of truth for the event contract should be plain, hand-coded Rust structs and enums that model the public wire shape directly. + +Implications: + +- the Rust event types are the canonical contract +- this document describes that contract and should stay aligned with the Rust types +- any TypeScript types, JSON Schema, or OpenAPI fragments are secondary artifacts, not the source of truth +- codegen is explicitly out of scope for the initial V2 implementation + +## Why Evolve The Current Model + +V2 should evolve Fabro's existing event architecture rather than replace it with a generic event platform. + +Earlier drafts of this document proposed a generic reducer contract, a larger ontology-first envelope, and a narrower replacement event catalog. V2 walks that back. The current code's boundary between internal workflow events, `RunEvent`, and `EventEnvelope` is stronger and simpler than it first appeared, so evolving that model is cheaper and clearer than replacing it. + +The current code already has a strong separation of concerns: + +- internal workflow/runtime events in `fabro-workflow` +- one canonical semantic `RunEvent` +- a store/API envelope that carries `seq` outside the event payload + +That separation is worth preserving. The main V2 changes should be: + +- modest envelope widening for execution topology +- cleanup and clarification of event-family boundaries +- keeping the durable event catalog semantic and typed + +V2 should not introduce: + +- a generic reducer contract based on `entity_type` / `event_role` +- canonical persisted token deltas +- snapshot events as a second truth layer + +## Capability Coverage Decisions + +V2 is evolutionary over the current `RunEvent` surface. It keeps the existing durable event families broadly intact rather than replacing them with a new ontology. + +The main additions are: + +- `stage_id` in the envelope for concrete stage execution identity +- `parallel_group_id` in the envelope for one execution of a parallel node +- `parallel_branch_id` in the envelope for one branch inside a parallel execution +- `tool_call_id` in the envelope for agent tool lifecycle events that need a stable cross-family join key + +Everything else should remain in typed `EventBody` props unless there is a strong cross-family reason to promote it. `session_id` already exists in the envelope today and stays as-is. `tool_call_id` is promoted now because `agent.tool.*` events already carry a stable tool-call identity that other durable families can reference when needed. `turn_id` is deferred because Fabro does not yet have a durable turn identity that spans the families that would need to join on it. + +## Exact Delta From Current Code + +This is the implementation delta from the current Rust codebase, not the full history of how the design was reached. + +### Add + +- add `stage_id: Option` to `RunEvent` +- add `parallel_group_id: Option` to `RunEvent` +- add `parallel_branch_id: Option` to `RunEvent` +- add `tool_call_id: Option` to `RunEvent` +- add `actor: Option` to `RunEvent` +- extend envelope extraction in `stored_event_fields()` to populate the new execution-topology fields when known +- extend envelope extraction in `stored_event_fields()` to populate `tool_call_id` on tool-lifecycle events when known +- update `RunEvent` serialization and parsing so the new optional envelope fields round-trip cleanly + +### Keep As-Is + +- `RunEvent` remains the canonical semantic event type +- `EventBody` remains the typed tagged union of durable event families +- `EventBody::Unknown` remains the compatibility valve for unknown event names on read +- `EventEnvelope` remains the ordered outer wrapper with `seq` outside the event payload +- `EventEnvelope.payload` remains `EventPayload`, not `RunEvent` +- the internal/store `EventEnvelope` Rust type stays wrapped as `{ seq, payload }` +- attach/replay remains exact ordered replay from `since_seq`, followed by live tailing +- current durable event families stay broadly intact +- live token/delta noise remains outside the durable persisted contract +- snapshots remain out of scope + +### Do Not Do + +- do not inline `seq` into `RunEvent` +- do not introduce `entity_type`, `entity_id`, or `event_role` +- do not replace typed Rust consumers with a generic reducer model +- do not redesign the store envelope +- do not add snapshot events or attach-time synthetic snapshots +- do not persist token deltas or other live UI noise as durable `RunEvent`s + +## Canonical Rust Shapes + +V2 should model the public contract directly as hand-coded Rust types, following the existing architecture. + +```rust +pub struct RunEvent { + pub id: String, + pub ts: DateTime, + pub run_id: RunId, + pub node_id: Option, + pub node_label: Option, + pub stage_id: Option, + pub parallel_group_id: Option, + pub parallel_branch_id: Option, + pub session_id: Option, + pub parent_session_id: Option, + pub tool_call_id: Option, + pub actor: Option, + pub body: EventBody, +} + +pub struct EventEnvelope { + pub seq: u32, + pub payload: EventPayload, +} + +pub struct ActorRef { + pub kind: ActorKind, + pub id: Option, + pub display: Option, +} + +pub enum ActorKind { + User, + Agent, + System, +} +``` + +`RunEvent` remains the semantic product event. `EventEnvelope` remains the ordered store/API wrapper. The store continues to persist validated JSON `EventPayload`, not typed `RunEvent` structs. + +For wire JSON, `EventEnvelope` should serialize in flattened form so clients see: + +```json +{ + "seq": 4861, + "id": "...", + "ts": "...", + "run_id": "...", + "event": "...", + "properties": { ... } +} +``` + +That flattening is a wire concern only. It does not move `seq` into `RunEvent`, and it does not change the internal/store Rust shape of `EventEnvelope`. + +`EventBody` remains a hand-coded tagged enum serialized as: + +```json +{ + "event": "stage.completed", + "properties": { "...": "..." } +} +``` + +V2 should also preserve the current unknown-event fallback shape: + +```rust +EventBody::Unknown { + name: String, + properties: serde_json::Value, +} +``` + +This fallback already exists in the current code and should be kept. + +### Envelope Rules + +- `id`, `ts`, `run_id`, and `event` are always present on the serialized `RunEvent`. +- `seq` is not part of `RunEvent`. It stays in the outer `EventEnvelope`. +- Optional envelope fields are omitted, never serialized as `null`. +- The existing top-level envelope fields remain: + - `node_id` + - `node_label` + - `session_id` + - `parent_session_id` +- V2 adds only these new optional envelope fields: + - `stage_id` + - `parallel_group_id` + - `parallel_branch_id` + - `tool_call_id` +- Other relationship identifiers stay inside typed `properties`. +- `turn_id` remains in typed `properties`; see the deferral decision in `Capability Coverage Decisions`. +- `actor` is optional. When present, it identifies the primary actor for the event. +- Set `actor` on human- or agent-initiated events where that identity matters to consumers. Example: `run.cancel.requested` should identify the user who initiated the cancel. +- Set `actor` on durable agent output when the producing session identity matters. Example: `agent.message` should identify the agent session. +- Omit `actor` for routine runtime events with no meaningful primary actor. Example: `stage.started`. + +### ID Format Conventions + +- `run_id` keeps Fabro's current format: an unprefixed ULID string. +- `stage_id` keeps Fabro's current format: `"{node_id}@{visit}"`. +- `node_id` is the stable graph node identifier from the workflow definition. +- `parallel_group_id` should be the durable identity of one execution of a parallel node. The default format should be `"{node_id}@{visit}"`. +- `parallel_branch_id` should be the durable identity of one branch within a parallel execution. The default format should be `"{parallel_group_id}:{index}"`. +- Consumers should otherwise treat IDs as opaque strings. + +### Presence Expectations + +- `stage_id` is present on events tied to a concrete stage execution. +- `parallel_group_id` is present on `parallel.*` events and on events emitted inside a parallel execution when that scope is known. +- `parallel_branch_id` is present on `parallel.branch.*` events and on nested events emitted inside a specific branch when that scope is known. +- `session_id` and `parent_session_id` keep their current meaning for forwarded agent/session activity. +- `tool_call_id` is present on `agent.tool.*` events and on other durable events that directly describe the same tool call. +- `node_label` remains in the envelope for display-oriented consumers. +- `actor` is expected on control actions and durable agent output when there is a meaningful user or agent identity to expose. It is usually omitted on routine runtime lifecycle events. + +## Consumer Model + +Rust consumers should keep matching on `RunEvent.body` using typed `EventBody` variants. + +This document does not adopt: + +- `entity_type` +- `entity_id` +- `event_role` +- a generic reducer contract + +External JSON consumers should continue to: + +- match on `"event"` +- read event-specific values from `"properties"` +- read `"seq"` from the flattened outer event envelope on API/SSE responses +- use envelope metadata only for cross-cutting context such as stage, session, execution topology, and tool-call correlation + +## Replay Contract + +Fabro keeps the current replay model: + +- ordered events are stored as `EventEnvelope { seq, payload }` +- API/SSE serialization of `EventEnvelope` should flatten `seq` into the top-level JSON object returned to clients +- attach starts from `since_seq` +- the server replays exact persisted envelopes and then tails live envelopes while the run is active +- SSE keepalive comments are transport frames, not events + +V2 does not introduce: + +- `run.snapshot` +- `session.snapshot` +- API-level attach snapshots +- persisted snapshot events of any kind + +The durable model remains simple: replay ordered events, no duplicate truth layer. + +## Implementation Checklist + +An engineer implementing this proposal should make only these structural changes unless a later section explicitly says otherwise. + +1. Update [`RunEvent`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/mod.rs) to add: + - `stage_id` + - `parallel_group_id` + - `parallel_branch_id` + - `tool_call_id` + - `actor` +2. Update `RunEvent::to_value()` and `RunEvent` parsing in [`run_event/mod.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/mod.rs) so the new envelope fields serialize and deserialize. +3. Extend `StoredEventFields` and `stored_event_fields()` in [`event.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-workflow/src/event.rs) to populate: + - `stage_id` + - `parallel_group_id` + - `parallel_branch_id` + - `tool_call_id` on tool-lifecycle events + - `actor` when there is a clear primary actor + These values should come from the emitter's current execution context for stage and parallel scope, and from event-specific payloads for `tool_call_id`. +4. Leave [`EventEnvelope`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-store/src/types.rs) structurally unchanged: + - `seq: u32` + - `payload: EventPayload` +5. Update API/SSE envelope serialization so wire JSON is flattened: + - top-level `seq` + - then the `RunEvent` payload fields alongside it + - no `"payload": { ... }` wrapper in JSON responses +6. Leave the replay/attach flow unchanged in behavior: + - persisted replay from `since_seq` + - live tail after replay + - no snapshots +7. Keep the current `EventBody` family surface unless there is an explicit product reason to change a specific family. +8. Keep streaming-noise agent events out of durable `RunEvent` conversion. +9. Update the HTTP/API schema docs to reflect both: + - new `RunEvent` envelope fields + - flattened JSON serialization of `EventEnvelope` + +## EventBody And Property Model + +V2 should keep the current hand-coded domain split for prop structs: + +- run props in [`run.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/run.rs) +- stage and checkpoint props in [`stage.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/stage.rs) +- agent props in [`agent.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/agent.rs) +- infra/setup/devcontainer props in [`infra.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/infra.rs) +- parallel/interview/git/misc props in [`misc.rs`](/Users/bhelmkamp/p/fabro-sh/fabro/lib/crates/fabro-types/src/run_event/misc.rs) + +That split is part of the design quality. V2 should keep adding hand-coded prop structs, not collapse everything into generic maps. + +## Durable Event Surface + +V2 keeps the current durable family surface broadly intact. + +### Run + +- `run.created` +- `run.started` +- `run.submitted` +- `run.starting` +- `run.running` +- `run.removing` +- `run.cancel.requested` +- `run.pause.requested` +- `run.unpause.requested` +- `run.paused` +- `run.unpaused` +- `run.rewound` +- `run.completed` +- `run.failed` +- `run.notice` + +### Stage And Prompt + +- `stage.started` +- `stage.completed` +- `stage.failed` +- `stage.retrying` +- `stage.prompt` +- `prompt.completed` + +### Parallel + +- `parallel.started` +- `parallel.branch.started` +- `parallel.branch.completed` +- `parallel.completed` + +### Interview / Human Input + +- `interview.started` +- `interview.completed` +- `interview.timeout` +- `interview.interrupted` + +### Checkpoint + +- `checkpoint.completed` +- `checkpoint.failed` + +### Agent Durable Events + +- `agent.session.started` +- `agent.session.ended` +- `agent.processing.end` +- `agent.input` +- `agent.message` +- `agent.tool.started` +- `agent.tool.completed` +- `agent.error` +- `agent.warning` +- `agent.loop.detected` +- `agent.turn.limit` +- `agent.steering.injected` +- `agent.compaction.started` +- `agent.compaction.completed` +- `agent.llm.retry` +- `agent.sub.spawned` +- `agent.sub.completed` +- `agent.sub.failed` +- `agent.sub.closed` +- `agent.mcp.ready` +- `agent.mcp.failed` +- `agent.failover` + +### Git + +- `git.commit` +- `git.push` +- `git.branch` +- `git.worktree.added` +- `git.worktree.removed` +- `git.fetch` +- `git.reset` + +### Infra And Execution + +- `sandbox.*` +- `setup.*` +- `cli.ensure.*` +- `command.*` +- `agent.cli.*` +- `devcontainer.*` +- `pull_request.*` +- `artifact.captured` +- `ssh.ready` +- `subgraph.*` +- `edge.selected` +- `loop.restart` +- `retro.*` + +## Explicitly Non-Durable Streaming Noise + +The current boundary that keeps live token/delta noise out of `RunEvent` should remain in place. + +These stay outside the durable persisted contract: + +- `agent.output.start` +- `agent.output.replace` +- `agent.text.delta` +- `agent.reasoning.delta` +- `agent.tool.output.delta` +- `agent.skill.expanded` + +`agent.skill.expanded` stays in this non-durable bucket because it is display-oriented expansion metadata, not a durable workflow fact. + +If Fabro needs those for UI, they belong in a separate transient stream, not in the canonical persisted Rust event contract. + +## Example Shapes + +### Flattened Wire JSON + +```json +{ + "seq": 4861, + "id": "evt_01JSE1N7RJD1NW2JSDT3W0YQ92", + "ts": "2026-04-08T16:21:11.106Z", + "run_id": "01JSE1M0Q0P8P6KQW9Q6D58Q0E", + "event": "agent.tool.completed", + "stage_id": "code@1", + "node_id": "code", + "node_label": "Code", + "session_id": "ses_child", + "tool_call_id": "call_1", + "parent_session_id": "ses_parent", + "properties": { + "tool_name": "read_file", + "output": { + "summary": "Read docs-internal/events-strategy.md" + }, + "is_error": false, + "visit": 1 + } +} +``` + +In Rust, `EventEnvelope` still remains `{ seq, payload: EventPayload }`. The example above is only the flattened API/SSE JSON form of that envelope. + +## Practical Guidance + +- Preserve the current one-time canonicalization boundary from internal `Event` to external `RunEvent`. +- Keep `RunEvent` semantic and typed. Do not turn it into a generic reducer envelope. +- Keep `seq` outside the event payload. +- Widen the envelope only modestly: `stage_id`, `parallel_group_id`, `parallel_branch_id`, and `tool_call_id`. +- Keep `session_id` as the existing top-level session field. +- Keep event-specific detail inside typed props. +- Preserve `EventBody::Unknown` as the compatibility valve for unknown event names on read. +- Do not store token deltas or other live UI noise as durable `RunEvent`s. +- Do not add snapshot events or attach-time synthetic snapshots. +- When adding a new durable event, update the current Rust boundary cleanly: + - internal `Event` + - `event_name()` + - envelope extraction + - `EventBody` + - typed props + - affected consumers + +## Open Follow-Up + +- `correlation_id`-style cross-entity grouping remains deferred until Fabro has a concrete consumer and explicit propagation rules diff --git a/docs-internal/fabro-event-schema-v2-proposal.md b/docs-internal/fabro-event-schema-v2-proposal.md new file mode 100644 index 000000000..ee8714766 --- /dev/null +++ b/docs-internal/fabro-event-schema-v2-proposal.md @@ -0,0 +1,633 @@ +# Fabro Event Schema V2 Proposal + +Date: 2026-04-08 + +Status: proposal + +Assumptions: + +- greenfield redesign +- no production deployments +- no backward-compatibility constraints +- optimize for the best long-term public event contract + +This proposal turns the earlier ideation into concrete schema changes. + +## Design Goal + +Fabro should expose: + +1. a durable, append-only event log for audit, storage, replay, and projections +2. a separate live stream for UI-oriented snapshots, deltas, and fast progress + +They should share IDs and correlation fields, but they should not be the same contract. + +## Top 10 Concrete Improvements + +### 1. Split the single event story into two concrete public APIs + +#### Proposal + +Introduce two top-level event contracts: + +- `DurableEvent` +- `LiveEvent` + +Endpoints: + +- `GET /runs/:run_id/events` + - append-only durable events + - replayable + - no keep-alive payload events +- `GET /runs/:run_id/live` + - live UI stream + - snapshots + deltas + keep-alives + - resumable with cursor + +#### Durable event shape + +```json +{ + "kind": "durable", + "id": "evt_01960d0c...", + "seq": 182, + "ts": "2026-04-08T15:01:02.123Z", + "run_id": "run_01JQ...", + "event": "agent.tool.started", + "session_id": "ses_123", + "node_id": "code", + "properties": { + "tool_call_id": "tool_abc", + "tool_name": "read_file", + "arguments": { "path": "src/main.rs" } + } +} +``` + +#### Live event shape + +```json +{ + "kind": "live", + "id": "levt_01960d0d...", + "seq": 991, + "ts": "2026-04-08T15:01:03.000Z", + "run_id": "run_01JQ...", + "event": "message.delta", + "session_id": "ses_123", + "message_id": "msg_456", + "part_id": "part_1", + "properties": { + "block_type": "text", + "delta": "Let me check that file..." + } +} +``` + +#### Why this is better + +- Durable events stay stable and analyzable. +- Live events can be noisy and UI-oriented without polluting projections. +- Keeps Fabro from repeating the Claude Code / OpenCode problem of mixing control, transport, and product semantics. + +### 2. Add explicit stream ordering, replay, and recovery semantics + +#### Proposal + +Every durable and live stream event gets: + +- `seq: u64` +- SSE `id:` = `seq` +- replay semantics based on `Last-Event-ID` + +Server rules: + +- if `Last-Event-ID` is present and still buffered, replay `seq > cursor` +- if cursor is too old, return a structured reset event in live streams and `409 replay_reset_required` in durable streams +- durable streams never emit synthetic snapshots +- live streams may start with a `*.snapshot` event after reconnect + +#### New live-only events + +- `stream.heartbeat` +- `run.snapshot` +- `session.snapshot` +- `node.snapshot` +- `stream.reset_required` + +#### Example `stream.reset_required` + +```json +{ + "kind": "live", + "id": "levt_01960d0e...", + "seq": 1200, + "ts": "2026-04-08T15:02:00.000Z", + "run_id": "run_01JQ...", + "event": "stream.reset_required", + "properties": { + "reason": "cursor_too_old", + "expected_from_seq": 1170 + } +} +``` + +#### Why this is better + +- Reattach behavior becomes deterministic. +- Clients no longer guess whether they missed data. +- Replay is part of the contract, not an implementation detail. + +### 3. Expand the envelope into a first-class correlation model + +#### Proposal + +Extend the shared envelope with these optional fields: + +- `workflow_id` +- `stage_id` +- `branch_id` +- `checkpoint_id` +- `session_id` +- `parent_session_id` +- `turn_id` +- `message_id` +- `part_id` +- `tool_call_id` +- `request_id` +- `causation_id` +- `correlation_id` + +Rules: + +- `id` is the event's own identity +- `causation_id` points to the immediate triggering event, if any +- `correlation_id` groups a whole logical operation, for example one user request or one retry attempt tree +- `request_id` is transport/API request scoped, not workflow scoped + +#### Concrete change + +Move these IDs out of ad hoc `properties` payloads when they are structural identifiers. + +Good: + +```json +{ + "event": "agent.tool.completed", + "tool_call_id": "tool_abc", + "message_id": "msg_456", + "properties": { + "tool_name": "read_file", + "is_error": false + } +} +``` + +Bad: + +```json +{ + "event": "agent.tool.completed", + "properties": { + "tool_call_id": "tool_abc", + "message_id": "msg_456", + "tool_name": "read_file" + } +} +``` + +#### Why this is better + +- Correlation becomes universal instead of event-family-specific. +- UI and analytics consumers can join without parsing `properties`. +- Parent/child agent and retry trees become much easier to reason about. + +### 4. Replace stringly state with concrete tagged unions + +#### Proposal + +Define explicit union types for stateful fields. + +Examples: + +```ts +type StopReason = + | { type: "completed" } + | { type: "requires_input"; request_id: string } + | { type: "interrupted"; interrupt_reason: InterruptReason } + | { type: "failed"; error_kind: ErrorKind } + | { type: "retries_exhausted"; attempts: number }; + +type RetryStatus = + | { type: "not_retrying" } + | { type: "retry_scheduled"; attempt: number; next_retry_at: string } + | { type: "retrying"; attempt: number } + | { type: "retries_exhausted"; attempts: number }; + +type ApprovalStatus = + | { type: "not_required" } + | { type: "requested"; approval_id: string } + | { type: "approved"; approval_id: string; actor: string } + | { type: "denied"; approval_id: string; actor: string; reason?: string }; +``` + +#### Concrete fields to replace + +- `status` +- `reason` +- `failure_class` +- `interrupt_reason` +- `stop_reason` +- `approval_status` + +#### Why this is better + +- Eliminates string drift. +- Makes reducers and policy engines much safer. +- Makes test fixtures much more stable. + +### 5. Standardize event family grammar across the entire product + +#### Proposal + +Use one lifecycle vocabulary: + +- `.created` +- `.started` +- `.snapshot` +- `.delta` +- `.updated` +- `.completed` +- `.failed` +- `.cancelled` +- `.interrupted` +- `.deleted` + +Apply it consistently to the same kinds of things: + +- `run.*` +- `stage.*` +- `session.*` +- `turn.*` +- `message.*` +- `message.part.*` +- `tool.*` +- `command.*` +- `checkpoint.*` +- `parallel.branch.*` +- `retro.*` + +#### Concrete renames + +Current style is already decent, but V2 should be stricter. + +Examples: + +- `agent.output.start` -> `message.part.started` +- `agent.text.delta` -> `message.part.delta` +- `agent.tool.output.delta` -> `tool.output.delta` +- `agent.processing.end` -> `turn.completed` or `session.idle`, depending on actual semantics + +#### Why this is better + +- Consumers can infer behavior from naming alone. +- Reduces one-off event families that encode bespoke lifecycle semantics. + +### 6. Introduce typed content blocks and block-level deltas + +#### Proposal + +Represent streamable content as typed message parts. + +Base union: + +```ts +type MessagePart = + | { type: "text"; part_id: string; text: string } + | { type: "reasoning"; part_id: string; text: string } + | { type: "tool_call"; part_id: string; tool_call_id: string; tool_name: string; input: unknown } + | { type: "tool_result"; part_id: string; tool_call_id: string; output: unknown; is_error: boolean } + | { type: "patch"; part_id: string; patch_ref: string } + | { type: "file_ref"; part_id: string; file_id: string; path: string } + | { type: "artifact_ref"; part_id: string; artifact_id: string; label: string } + | { type: "plan"; part_id: string; items: PlanItem[] } + | { type: "todo"; part_id: string; items: TodoItem[] } + | { type: "command_output"; part_id: string; command_id: string; stream: "stdout" | "stderr"; text: string }; +``` + +Live delta event: + +```json +{ + "event": "message.part.delta", + "message_id": "msg_456", + "part_id": "part_1", + "properties": { + "part_type": "text", + "delta": "checking src/main.rs" + } +} +``` + +Durable completion event: + +```json +{ + "event": "message.completed", + "message_id": "msg_456", + "properties": { + "parts": [ + { "type": "text", "part_id": "part_1", "text": "checking src/main.rs" } + ] + } +} +``` + +#### Why this is better + +- Supports rich UI without reparsing free-form text. +- Supports structured summarization, compaction, and retro generation. +- Aligns Fabro with the best parts of Claude Sessions and pi-mono. + +### 7. Make approvals, questions, and operator interventions first-class durable events + +#### Proposal + +Add explicit event families: + +- `approval.requested` +- `approval.responded` +- `question.asked` +- `question.answered` +- `interrupt.requested` +- `interrupt.applied` +- `resume.required` +- `resume.applied` + +#### Example `approval.requested` + +```json +{ + "kind": "durable", + "id": "evt_01960d0f...", + "seq": 201, + "ts": "2026-04-08T15:03:00.000Z", + "run_id": "run_01JQ...", + "session_id": "ses_123", + "tool_call_id": "tool_abc", + "event": "approval.requested", + "properties": { + "approval_id": "apr_1", + "scope": "tool_call", + "tool_name": "exec_command", + "request": { + "cmd": "git push origin branch" + } + } +} +``` + +#### Example `approval.responded` + +```json +{ + "kind": "durable", + "id": "evt_01960d10...", + "seq": 202, + "ts": "2026-04-08T15:03:10.000Z", + "run_id": "run_01JQ...", + "event": "approval.responded", + "properties": { + "approval_id": "apr_1", + "result": { + "type": "approved", + "actor": "user" + } + } +} +``` + +#### Why this is better + +- Human-in-loop behavior becomes queryable and replayable. +- Workflow interruption is no longer hidden in transport or UI state. + +### 8. Add real snapshot events instead of relying on ad hoc reconstruction + +#### Proposal + +Define explicit snapshot events for live attach and projection recovery: + +- `run.snapshot` +- `session.snapshot` +- `node.snapshot` +- `checkpoint.saved` + +#### Example `session.snapshot` + +```json +{ + "kind": "live", + "id": "levt_01960d11...", + "seq": 1500, + "ts": "2026-04-08T15:04:00.000Z", + "run_id": "run_01JQ...", + "session_id": "ses_123", + "event": "session.snapshot", + "properties": { + "state": { "type": "running" }, + "turn_id": "turn_9", + "messages": [ + { + "message_id": "msg_456", + "role": "assistant", + "parts": [ + { "type": "text", "part_id": "part_1", "text": "checking src/main.rs" } + ] + } + ], + "active_tool_calls": [ + { + "tool_call_id": "tool_abc", + "tool_name": "read_file", + "status": "running" + } + ] + } +} +``` + +#### Concrete rule + +- snapshots are authoritative replacement state for live consumers +- snapshots are optional in durable streams +- checkpoints are durable domain snapshots, not just UI snapshots + +#### Why this is better + +- Fast attach becomes trivial. +- Projections can self-heal from snapshots. +- Checkpoint semantics become explicit rather than emergent. + +### 9. Make model, tool, command, and MCP work first-class span families + +#### Proposal + +Create event families with shared semantics: + +- `model.request.started` +- `model.request.completed` +- `model.request.failed` +- `tool.started` +- `tool.output.delta` +- `tool.completed` +- `tool.failed` +- `command.started` +- `command.output.delta` +- `command.completed` +- `command.failed` +- `mcp.call.started` +- `mcp.call.progress` +- `mcp.call.completed` +- `mcp.call.failed` + +#### Example `model.request.completed` + +```json +{ + "kind": "durable", + "id": "evt_01960d12...", + "seq": 220, + "ts": "2026-04-08T15:05:00.000Z", + "run_id": "run_01JQ...", + "session_id": "ses_123", + "turn_id": "turn_9", + "request_id": "req_llm_1", + "event": "model.request.completed", + "properties": { + "provider": "anthropic", + "model": "claude-sonnet-4", + "latency_ms": 1834, + "usage": { + "input_tokens": 1400, + "output_tokens": 380, + "reasoning_tokens": 120, + "cache_read_tokens": 900, + "cache_write_tokens": 0 + }, + "retry_status": { "type": "not_retrying" } + } +} +``` + +#### Why this is better + +- Cost and latency analysis become first-class. +- Policy engines can reason about real operations, not just stage summaries. +- Cross-provider comparison gets much easier. + +### 10. Generate and enforce the public schema, docs, and examples from one registry + +#### Proposal + +Build a single `event_schema_registry` source that defines: + +- envelope fields +- event families +- payload types +- union types +- versioning +- example payloads + +Artifacts generated from it: + +- Rust types +- TypeScript types +- JSON Schema +- OpenAPI / SSE docs +- sample event fixtures +- validation tests + +#### Concrete rules + +- every public event must have: + - one schema definition + - one example payload + - one validation test +- no endpoint may inject extra consumer-visible fields outside the schema +- keep-alive frames are documented separately from payload events + +#### Why this is better + +- Prevents the OpenCode and Goose class of drift. +- Makes Fabro's event API publishable and stable from day one. + +## Recommended V2 Event Families + +If Fabro were starting from scratch, I would structure the public families like this: + +- `run.*` +- `stage.*` +- `checkpoint.*` +- `parallel.branch.*` +- `session.*` +- `turn.*` +- `message.*` +- `message.part.*` +- `model.request.*` +- `tool.*` +- `command.*` +- `mcp.call.*` +- `approval.*` +- `question.*` +- `interrupt.*` +- `resume.*` +- `compaction.*` +- `retro.*` +- `artifact.*` +- `stream.*` (live only) + +## Recommended Field Placement Rules + +Top-level envelope: + +- identity and correlation +- ordering +- timestamps +- scope + +`properties`: + +- event-family-specific payload +- business data +- structured state payloads + +Never in `properties` if they are structural: + +- `run_id` +- `seq` +- `event` +- `session_id` +- `message_id` +- `tool_call_id` +- `request_id` +- `causation_id` +- `correlation_id` + +## Bottom Line + +The best greenfield version of Fabro is not "the current schema plus more events." + +It is: + +- separate durable and live contracts +- replayable ordered streams +- a richer envelope +- typed state unions +- typed content blocks +- explicit snapshots +- first-class HITL events +- first-class span families +- generated schema/docs/tests from one registry + +That would give Fabro a better event platform than any of the compared systems. diff --git a/docs/ideation/2026-04-08-fabro-event-schema-ideation.md b/docs/ideation/2026-04-08-fabro-event-schema-ideation.md new file mode 100644 index 000000000..d3cb82638 --- /dev/null +++ b/docs/ideation/2026-04-08-fabro-event-schema-ideation.md @@ -0,0 +1,134 @@ +--- +date: 2026-04-08 +topic: fabro-event-schema +focus: greenfield redesign of Fabro's event schemas and streaming contract +--- + +# Ideation: Fabro Event Schema Redesign + +Assumption: greenfield reset. No production deployments, no backward-compatibility constraints, optimize for the best long-term event model rather than incremental migration cost. + +These are not ten unrelated features. They are the ten strongest changes to make as one coherent event-platform redesign. + +## Codebase Context + +- Fabro already has the strongest core envelope in the comparison set: `id`, `ts`, `run_id`, `event`, optional `session_id`, `parent_session_id`, `node_id`, `node_label`, plus `properties` +- `docs-internal/events-strategy.md` already treats events as the durable audit trail that powers storage, SSE, CLI progress, retro analysis, and JSONL sinks +- `RunEvent` and `EventBody` already give Fabro a typed internal model, but the public contract is still more convention-driven than schema-first +- Recent internal plans already push Fabro toward events as source of truth, checkpoint derivation, and simplified `RunEvent`, so the repo is directionally aligned with a stronger event platform +- The biggest remaining gaps are cross-cutting ones: replay, correlation depth, public schema generation, live-stream semantics, and structured status/error/approval states + +## Ranked Ideas + +### 1. Split Fabro into two event products: a durable event log and a live UI stream +**Description:** Define two first-class streams instead of one overloaded one. The durable log contains immutable domain facts suitable for audit, replay, storage, and projections. The live stream contains UI-oriented deltas, snapshots, keep-alives, and fast-changing progress. They share IDs and correlation fields, but they are different contracts. +**Rationale:** This is the highest-leverage fix. Most competitors get into trouble by mixing audit events, high-frequency streaming deltas, control frames, and reconnect machinery into one schema. Fabro should not. Durable events should be boring and trustworthy. Live events should be optimized for interactivity. +**Downsides:** Two contracts are more work than one. Emitters must decide whether an event is durable, live-only, or both. +**Confidence:** 98% +**Complexity:** High +**Status:** Recommended + +### 2. Add a formal replay and resume contract with ordered cursors and snapshot handshakes +**Description:** Every run and session stream gets a monotonic `seq` plus a documented resume protocol: subscribe from cursor, receive the latest snapshot if needed, then apply deltas after `seq > cursor`. Define SSE `id` semantics, dedupe rules, replay buffer guarantees, and failure behavior when a client falls too far behind. +**Rationale:** Goose is the clearest proof that reconnect semantics need to be part of the design, not a side effect. Greenfield Fabro should make "reattach to a long-running workflow" a first-class use case. +**Downsides:** The server needs replay buffers or snapshot storage, and clients need to implement cursor logic correctly. +**Confidence:** 96% +**Complexity:** High +**Status:** Recommended + +### 3. Expand the envelope into a real correlation graph +**Description:** Keep the existing strong envelope and add the IDs Fabro will actually want long term: `workflow_id`, `branch_id`, `checkpoint_id`, `turn_id`, `message_id`, `tool_call_id`, `request_id`, `causation_id`, and `correlation_id`. Not every event sets every field, but the contract makes those slots explicit. +**Rationale:** Current Fabro is strong at run/session/node identity, but still thin below that. The next generation of debugging, UI, projection, and analytics work will want to join events by turn, message, tool call, branch, checkpoint, and request without reconstructing those edges from payloads. +**Downsides:** Emitters and handlers must be stricter about ID ownership and propagation. +**Confidence:** 95% +**Complexity:** Medium +**Status:** Recommended + +### 4. Make the public event contract schema-first and generated from one registry +**Description:** Keep Fabro's `event` + event-specific payload shape, but stop treating the public wire contract as implicit. Generate JSON Schema, TypeScript types, Rust validators, docs, and streaming examples from one source of truth. Every public event family gets an explicit schema version from day one. +**Rationale:** This is the cleanest fix for drift. Claude Sessions benefits from having a clear public union. OpenCode shows how useful generated event types are. Fabro should combine both while preserving its stronger envelope. +**Downsides:** Codegen and schema governance add process overhead. Some engineers will fight the discipline. +**Confidence:** 94% +**Complexity:** High +**Status:** Recommended + +### 5. Normalize lifecycle grammar across all streamable entities +**Description:** Standardize event families around a small lifecycle vocabulary: `.started`, `.delta`, `.snapshot`, `.completed`, `.failed`, `.cancelled`, `.interrupted`. Apply it consistently to runs, stages, sessions, turns, messages, tool calls, commands, checkpoints, parallel branches, and retro work where relevant. +**Rationale:** pi-mono is strongest here. Clients get dramatically simpler when every streamable thing follows the same lifecycle rules instead of bespoke one-off semantics. +**Downsides:** Some event families will feel slightly unnatural if forced into the same lifecycle vocabulary. Discipline matters. +**Confidence:** 93% +**Complexity:** Medium +**Status:** Recommended + +### 6. Replace stringly status and failure fields with explicit tagged unions +**Description:** Stop representing terminal and waiting states as loosely-typed strings where possible. Add typed unions for `stop_reason`, `retry_status`, `approval_status`, `wait_reason`, `error_kind`, `failure_class`, and `interrupt_reason`. Preserve display strings separately when useful. +**Rationale:** Claude Sessions gets this exactly right. This is a major quality jump for policy engines, UIs, analytics, and test fixtures. It also reduces accidental schema drift where one code path emits `"timed_out"` and another emits `"timeout"`. +**Downsides:** More up-front schema design. Adding new states later requires more care. +**Confidence:** 96% +**Complexity:** Medium +**Status:** Recommended + +### 7. Introduce typed content blocks and block-level deltas for agent output +**Description:** Model agent-facing content as typed blocks instead of mostly strings: `text`, `reasoning`, `tool_call`, `tool_result`, `patch`, `file_ref`, `artifact_ref`, `plan`, `todo`, `command_output`, and `summary`. Live deltas target block IDs rather than appending ambiguous raw text. +**Rationale:** This is the difference between a transcript that only humans can read and one that both humans and tools can reason over. It unlocks richer UIs, selective re-rendering, better retro analysis, and much cleaner summarization and compaction. +**Downsides:** The model is more complex than "event name plus string payload." Poorly chosen block boundaries can make clients awkward. +**Confidence:** 92% +**Complexity:** High +**Status:** Recommended + +### 8. Make human-in-the-loop and control-plane semantics first-class events +**Description:** Promote approvals, questions, interrupts, resumes, compactions, and operator interventions into explicit event families: `approval.requested`, `approval.responded`, `question.asked`, `question.answered`, `interrupt.requested`, `interrupt.applied`, `resume.required`, `compaction.started`, `compaction.completed`, `compaction.failed`. +**Rationale:** This is one of the clearest wins from Claude Sessions and OpenCode. Human-in-loop behavior is not edge-case control traffic. It is core workflow state and deserves durable, typed representation. +**Downsides:** It increases surface area. Some flows that are currently implicit must become explicit state machines. +**Confidence:** 94% +**Complexity:** Medium +**Status:** Recommended + +### 9. Add first-class snapshot events for fast attach and projection repair +**Description:** Introduce self-contained snapshot events such as `run.snapshot`, `session.snapshot`, and `checkpoint.saved` that intentionally duplicate enough state to let clients and projectors reattach without replaying the full history. Treat these as part of the contract, not ad hoc recovery hacks. +**Rationale:** This complements replay. Durable facts remain append-only, but long-running workflows need efficient recovery points. Greenfield Fabro can design snapshots deliberately instead of letting checkpoint semantics and UI recovery drift apart. +**Downsides:** Snapshot compaction and retention rules must be explicit or the event system becomes harder to reason about. +**Confidence:** 90% +**Complexity:** High +**Status:** Recommended + +### 10. Promote model, tool, and command work into first-class span-style event families +**Description:** Treat model requests, tool execution, MCP calls, shell commands, and patch application as first-class event families with started/completed/error plus usage, latency, retries, provider, model, routing, approval outcome, and output references. Do not hide these behind generic stage completion summaries. +**Rationale:** Fabro is an AI workflow product. The event model should expose the actual unit economics and failure surfaces of AI work. This gives better debugging, cost analysis, policy enforcement, and product telemetry than aggregating everything back into stage summaries. +**Downsides:** More event volume. Care is needed to keep live deltas separate from durable summaries. +**Confidence:** 93% +**Complexity:** Medium +**Status:** Recommended + +## What This Adds Up To + +If Fabro adopted all ten, the resulting idealized model would look like this: + +- one durable event log for facts +- one live stream for interactive state +- one shared envelope with strong correlation IDs +- one generated public schema registry +- one consistent lifecycle grammar +- one explicit replay/snapshot story +- typed blocks and typed states instead of strings and ad hoc payloads + +That is materially better than any single comparator repo. + +## Rejection Summary + +| # | Idea | Reason Rejected | +|---|------|-----------------| +| 1 | Keep one stream and just document it better | Not enough — durable and live concerns have different optimization goals | +| 2 | Flatten all event-specific fields into the top level | Root churn would make the schema worse, not better | +| 3 | Switch to JSON-RPC-style `method`/`params` notifications | Too transport-shaped for Fabro's broader event-log use case | +| 4 | Use timestamps alone for replay ordering | Weak contract; reconnect needs explicit sequence semantics | +| 5 | Eventize binary artifacts and all large blobs directly | Expensive and noisy; use refs/metadata instead | +| 6 | Keep string errors but standardize message text | Still not machine-readable enough | +| 7 | Make snapshots the only source of truth | Loses auditability and event-sourced advantages | +| 8 | Encode every UI concern in the durable log | Durable logs should stay trustworthy and projection-friendly | +| 9 | Preserve current schema and only add more event names | Misses the deeper contract problems | +| 10 | Treat approvals and questions as transport-level control traffic | These are product-level workflow semantics and belong in the event model | + +## Session Log + +- 2026-04-08: Grounded ideation from current Fabro event docs and code, plus comparison against Claude Sessions, Claude Code, Goose, OpenAI Codex, OpenCode, and pi-mono. Survivors intentionally optimized for greenfield quality rather than migration ease.