Skip to main content

rightkit_agent_engine_protocol/event/
envelope.rs

1use super::ProtocolEvent;
2use crate::TranscriptMessage;
3use serde::{Deserialize, Serialize};
4
5pub const EVENT_ENVELOPE_SCHEMA_V1: u16 = 1;
6
7/// Optional causal/cross-runtime correlation carried beside a canonical event.
8/// These fields identify relationships only; session/sequence/kind remain owned
9/// by `ProtocolEvent` and SQLite rather than being duplicated in the envelope.
10#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12pub struct EventCorrelationV1 {
13    #[serde(default, skip_serializing_if = "Option::is_none")]
14    pub run_id: Option<String>,
15    #[serde(default, skip_serializing_if = "Option::is_none")]
16    pub turn_id: Option<String>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub task_id: Option<String>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub tool_call_id: Option<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub attempt_id: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub causal_parent_event_id: Option<String>,
25}
26
27impl EventCorrelationV1 {
28    /// Derive the normalized correlation dimensions already carried by the
29    /// canonical event. Routing identity (`session_id`, `seq`, kind) remains on
30    /// `ProtocolEvent`; this only exposes cross-event relationships without
31    /// duplicating the event payload.
32    pub fn from_event(event: &ProtocolEvent) -> Self {
33        let mut correlation = Self::default();
34        match event {
35            ProtocolEvent::SessionTurnFinished { turn_id, .. } => {
36                correlation.turn_id = Some(turn_id.clone());
37            }
38            ProtocolEvent::ToolCallStarted { call, .. }
39            | ProtocolEvent::ToolApprovalRequested { call, .. } => {
40                correlation.tool_call_id = Some(call.id.clone());
41            }
42            ProtocolEvent::ToolApprovalResolved { tool_call_id, .. }
43            | ProtocolEvent::ToolApprovalReviewed { tool_call_id, .. } => {
44                correlation.tool_call_id = Some(tool_call_id.clone());
45            }
46            ProtocolEvent::CredentialRequested { tool_call_id, .. } => {
47                correlation.tool_call_id = Some(tool_call_id.clone());
48            }
49            ProtocolEvent::CredentialResolved { tool_call_id, .. } => {
50                correlation.tool_call_id = Some(tool_call_id.clone());
51            }
52            ProtocolEvent::ToolResultAdded {
53                result: TranscriptMessage::Tool { tool_call_id, .. },
54                ..
55            } => {
56                correlation.tool_call_id = Some(tool_call_id.clone());
57            }
58            ProtocolEvent::TaskStatusUpdated { task_id, .. }
59            | ProtocolEvent::TaskArtifactUpdated { task_id, .. }
60            | ProtocolEvent::ArtifactLineageUpdated { task_id, .. } => {
61                correlation.task_id = Some(task_id.clone());
62            }
63            ProtocolEvent::SessionContextFolded { subtask_id, .. } => {
64                correlation.task_id = Some(subtask_id.clone());
65            }
66            ProtocolEvent::RunGovernanceRejected {
67                tool_call_id,
68                budget,
69                ownership,
70                origin,
71                ..
72            } => {
73                correlation.tool_call_id = Some(tool_call_id.clone());
74                correlation.run_id = budget
75                    .as_ref()
76                    .map(|value| value.run_id.clone())
77                    .or_else(|| origin.as_ref().map(|value| value.run_id.clone()))
78                    .or_else(|| ownership.as_ref().map(|value| value.target_run_id.clone()));
79            }
80            _ => {}
81        }
82        correlation
83    }
84}
85
86/// Stable metadata wrapper for one durable the host app event.
87///
88/// `event_id` is deterministically derived from the already-authoritative
89/// `(session_id, seq)` pair, so replay/reopen produces the same identity. The
90/// event payload remains unchanged and therefore backward-compatible.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct EventEnvelopeV1 {
94    pub schema_version: u16,
95    pub event_id: String,
96    #[serde(default)]
97    pub correlation: EventCorrelationV1,
98    pub event: ProtocolEvent,
99}
100
101impl EventEnvelopeV1 {
102    /// Build the canonical normalized representation for a durable fact using
103    /// correlation already present on the event. Because `event_id` derives
104    /// from `(session_id, seq)`, replay/reopen reconstructs the same identity
105    /// without a duplicate database column that could drift.
106    pub fn canonical(event: ProtocolEvent) -> Result<Self, String> {
107        let correlation = EventCorrelationV1::from_event(&event);
108        Self::for_durable_event(event, correlation)
109    }
110
111    pub fn for_durable_event(
112        event: ProtocolEvent,
113        correlation: EventCorrelationV1,
114    ) -> Result<Self, String> {
115        if !event.is_durable_fact() {
116            return Err("live projection events do not receive durable event envelopes".into());
117        }
118        let meta = event.meta();
119        if meta.session_id.trim().is_empty() {
120            return Err("durable event envelope requires session_id".into());
121        }
122        if meta.seq < 1 {
123            return Err("durable event envelope requires committed sequence >= 1".into());
124        }
125        Ok(Self {
126            schema_version: EVENT_ENVELOPE_SCHEMA_V1,
127            event_id: stable_event_id(&meta.session_id, meta.seq),
128            correlation,
129            event,
130        })
131    }
132}
133
134pub fn stable_event_id(session_id: &str, seq: i64) -> String {
135    format!("evt-v1:{session_id}:{seq}")
136}