|
| 1 | +use serde::{Deserialize, Serialize}; |
| 2 | +use serde_json::Value; |
| 3 | + |
| 4 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 5 | +pub enum LaneEventName { |
| 6 | + #[serde(rename = "lane.started")] |
| 7 | + Started, |
| 8 | + #[serde(rename = "lane.ready")] |
| 9 | + Ready, |
| 10 | + #[serde(rename = "lane.prompt_misdelivery")] |
| 11 | + PromptMisdelivery, |
| 12 | + #[serde(rename = "lane.blocked")] |
| 13 | + Blocked, |
| 14 | + #[serde(rename = "lane.red")] |
| 15 | + Red, |
| 16 | + #[serde(rename = "lane.green")] |
| 17 | + Green, |
| 18 | + #[serde(rename = "lane.commit.created")] |
| 19 | + CommitCreated, |
| 20 | + #[serde(rename = "lane.pr.opened")] |
| 21 | + PrOpened, |
| 22 | + #[serde(rename = "lane.merge.ready")] |
| 23 | + MergeReady, |
| 24 | + #[serde(rename = "lane.finished")] |
| 25 | + Finished, |
| 26 | + #[serde(rename = "lane.failed")] |
| 27 | + Failed, |
| 28 | + #[serde(rename = "lane.reconciled")] |
| 29 | + Reconciled, |
| 30 | + #[serde(rename = "lane.merged")] |
| 31 | + Merged, |
| 32 | + #[serde(rename = "lane.superseded")] |
| 33 | + Superseded, |
| 34 | + #[serde(rename = "lane.closed")] |
| 35 | + Closed, |
| 36 | + #[serde(rename = "branch.stale_against_main")] |
| 37 | + BranchStaleAgainstMain, |
| 38 | +} |
| 39 | + |
| 40 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 41 | +#[serde(rename_all = "snake_case")] |
| 42 | +pub enum LaneEventStatus { |
| 43 | + Running, |
| 44 | + Ready, |
| 45 | + Blocked, |
| 46 | + Red, |
| 47 | + Green, |
| 48 | + Completed, |
| 49 | + Failed, |
| 50 | + Reconciled, |
| 51 | + Merged, |
| 52 | + Superseded, |
| 53 | + Closed, |
| 54 | +} |
| 55 | + |
| 56 | +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] |
| 57 | +#[serde(rename_all = "snake_case")] |
| 58 | +pub enum LaneFailureClass { |
| 59 | + PromptDelivery, |
| 60 | + TrustGate, |
| 61 | + BranchDivergence, |
| 62 | + Compile, |
| 63 | + Test, |
| 64 | + PluginStartup, |
| 65 | + McpStartup, |
| 66 | + McpHandshake, |
| 67 | + GatewayRouting, |
| 68 | + ToolRuntime, |
| 69 | + Infra, |
| 70 | +} |
| 71 | + |
| 72 | +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 73 | +pub struct LaneEventBlocker { |
| 74 | + #[serde(rename = "failureClass")] |
| 75 | + pub failure_class: LaneFailureClass, |
| 76 | + pub detail: String, |
| 77 | +} |
| 78 | + |
| 79 | +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 80 | +pub struct LaneEvent { |
| 81 | + pub event: LaneEventName, |
| 82 | + pub status: LaneEventStatus, |
| 83 | + #[serde(rename = "emittedAt")] |
| 84 | + pub emitted_at: String, |
| 85 | + #[serde(rename = "failureClass", skip_serializing_if = "Option::is_none")] |
| 86 | + pub failure_class: Option<LaneFailureClass>, |
| 87 | + #[serde(skip_serializing_if = "Option::is_none")] |
| 88 | + pub detail: Option<String>, |
| 89 | + #[serde(skip_serializing_if = "Option::is_none")] |
| 90 | + pub data: Option<Value>, |
| 91 | +} |
| 92 | + |
| 93 | +impl LaneEvent { |
| 94 | + #[must_use] |
| 95 | + pub fn new( |
| 96 | + event: LaneEventName, |
| 97 | + status: LaneEventStatus, |
| 98 | + emitted_at: impl Into<String>, |
| 99 | + ) -> Self { |
| 100 | + Self { |
| 101 | + event, |
| 102 | + status, |
| 103 | + emitted_at: emitted_at.into(), |
| 104 | + failure_class: None, |
| 105 | + detail: None, |
| 106 | + data: None, |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + #[must_use] |
| 111 | + pub fn started(emitted_at: impl Into<String>) -> Self { |
| 112 | + Self::new(LaneEventName::Started, LaneEventStatus::Running, emitted_at) |
| 113 | + } |
| 114 | + |
| 115 | + #[must_use] |
| 116 | + pub fn finished(emitted_at: impl Into<String>, detail: Option<String>) -> Self { |
| 117 | + Self::new(LaneEventName::Finished, LaneEventStatus::Completed, emitted_at) |
| 118 | + .with_optional_detail(detail) |
| 119 | + } |
| 120 | + |
| 121 | + #[must_use] |
| 122 | + pub fn blocked(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self { |
| 123 | + Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at) |
| 124 | + .with_failure_class(blocker.failure_class) |
| 125 | + .with_detail(blocker.detail.clone()) |
| 126 | + } |
| 127 | + |
| 128 | + #[must_use] |
| 129 | + pub fn failed(emitted_at: impl Into<String>, blocker: &LaneEventBlocker) -> Self { |
| 130 | + Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at) |
| 131 | + .with_failure_class(blocker.failure_class) |
| 132 | + .with_detail(blocker.detail.clone()) |
| 133 | + } |
| 134 | + |
| 135 | + #[must_use] |
| 136 | + pub fn with_failure_class(mut self, failure_class: LaneFailureClass) -> Self { |
| 137 | + self.failure_class = Some(failure_class); |
| 138 | + self |
| 139 | + } |
| 140 | + |
| 141 | + #[must_use] |
| 142 | + pub fn with_detail(mut self, detail: impl Into<String>) -> Self { |
| 143 | + self.detail = Some(detail.into()); |
| 144 | + self |
| 145 | + } |
| 146 | + |
| 147 | + #[must_use] |
| 148 | + pub fn with_optional_detail(mut self, detail: Option<String>) -> Self { |
| 149 | + self.detail = detail; |
| 150 | + self |
| 151 | + } |
| 152 | + |
| 153 | + #[must_use] |
| 154 | + pub fn with_data(mut self, data: Value) -> Self { |
| 155 | + self.data = Some(data); |
| 156 | + self |
| 157 | + } |
| 158 | +} |
| 159 | + |
| 160 | +#[cfg(test)] |
| 161 | +mod tests { |
| 162 | + use serde_json::json; |
| 163 | + |
| 164 | + use super::{ |
| 165 | + LaneEvent, LaneEventBlocker, LaneEventName, LaneEventStatus, LaneFailureClass, |
| 166 | + }; |
| 167 | + |
| 168 | + #[test] |
| 169 | + fn canonical_lane_event_names_serialize_to_expected_wire_values() { |
| 170 | + let cases = [ |
| 171 | + (LaneEventName::Started, "lane.started"), |
| 172 | + (LaneEventName::Ready, "lane.ready"), |
| 173 | + ( |
| 174 | + LaneEventName::PromptMisdelivery, |
| 175 | + "lane.prompt_misdelivery", |
| 176 | + ), |
| 177 | + (LaneEventName::Blocked, "lane.blocked"), |
| 178 | + (LaneEventName::Red, "lane.red"), |
| 179 | + (LaneEventName::Green, "lane.green"), |
| 180 | + (LaneEventName::CommitCreated, "lane.commit.created"), |
| 181 | + (LaneEventName::PrOpened, "lane.pr.opened"), |
| 182 | + (LaneEventName::MergeReady, "lane.merge.ready"), |
| 183 | + (LaneEventName::Finished, "lane.finished"), |
| 184 | + (LaneEventName::Failed, "lane.failed"), |
| 185 | + (LaneEventName::Reconciled, "lane.reconciled"), |
| 186 | + (LaneEventName::Merged, "lane.merged"), |
| 187 | + (LaneEventName::Superseded, "lane.superseded"), |
| 188 | + (LaneEventName::Closed, "lane.closed"), |
| 189 | + ( |
| 190 | + LaneEventName::BranchStaleAgainstMain, |
| 191 | + "branch.stale_against_main", |
| 192 | + ), |
| 193 | + ]; |
| 194 | + |
| 195 | + for (event, expected) in cases { |
| 196 | + assert_eq!(serde_json::to_value(event).expect("serialize event"), json!(expected)); |
| 197 | + } |
| 198 | + } |
| 199 | + |
| 200 | + #[test] |
| 201 | + fn failure_classes_cover_canonical_taxonomy_wire_values() { |
| 202 | + let cases = [ |
| 203 | + (LaneFailureClass::PromptDelivery, "prompt_delivery"), |
| 204 | + (LaneFailureClass::TrustGate, "trust_gate"), |
| 205 | + (LaneFailureClass::BranchDivergence, "branch_divergence"), |
| 206 | + (LaneFailureClass::Compile, "compile"), |
| 207 | + (LaneFailureClass::Test, "test"), |
| 208 | + (LaneFailureClass::PluginStartup, "plugin_startup"), |
| 209 | + (LaneFailureClass::McpStartup, "mcp_startup"), |
| 210 | + (LaneFailureClass::McpHandshake, "mcp_handshake"), |
| 211 | + (LaneFailureClass::GatewayRouting, "gateway_routing"), |
| 212 | + (LaneFailureClass::ToolRuntime, "tool_runtime"), |
| 213 | + (LaneFailureClass::Infra, "infra"), |
| 214 | + ]; |
| 215 | + |
| 216 | + for (failure_class, expected) in cases { |
| 217 | + assert_eq!( |
| 218 | + serde_json::to_value(failure_class).expect("serialize failure class"), |
| 219 | + json!(expected) |
| 220 | + ); |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + #[test] |
| 225 | + fn blocked_and_failed_events_reuse_blocker_details() { |
| 226 | + let blocker = LaneEventBlocker { |
| 227 | + failure_class: LaneFailureClass::McpStartup, |
| 228 | + detail: "broken server".to_string(), |
| 229 | + }; |
| 230 | + |
| 231 | + let blocked = LaneEvent::blocked("2026-04-04T00:00:00Z", &blocker); |
| 232 | + let failed = LaneEvent::failed("2026-04-04T00:00:01Z", &blocker); |
| 233 | + |
| 234 | + assert_eq!(blocked.event, LaneEventName::Blocked); |
| 235 | + assert_eq!(blocked.status, LaneEventStatus::Blocked); |
| 236 | + assert_eq!(blocked.failure_class, Some(LaneFailureClass::McpStartup)); |
| 237 | + assert_eq!(failed.event, LaneEventName::Failed); |
| 238 | + assert_eq!(failed.status, LaneEventStatus::Failed); |
| 239 | + assert_eq!(failed.detail.as_deref(), Some("broken server")); |
| 240 | + } |
| 241 | +} |
0 commit comments