#![allow(clippy::similar_names, clippy::cast_possible_truncation)] use serde::{Deserialize, Serialize}; use serde_json::Value; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LaneEventName { #[serde(rename = "lane.started")] Started, #[serde(rename = "lane.ready")] Ready, #[serde(rename = "lane.prompt_misdelivery")] PromptMisdelivery, #[serde(rename = "lane.blocked")] Blocked, #[serde(rename = "lane.red")] Red, #[serde(rename = "lane.green")] Green, #[serde(rename = "lane.commit.created")] CommitCreated, #[serde(rename = "lane.pr.opened")] PrOpened, #[serde(rename = "lane.merge.ready")] MergeReady, #[serde(rename = "lane.finished")] Finished, #[serde(rename = "lane.failed")] Failed, #[serde(rename = "lane.reconciled")] Reconciled, #[serde(rename = "lane.merged")] Merged, #[serde(rename = "lane.superseded")] Superseded, #[serde(rename = "lane.closed")] Closed, #[serde(rename = "branch.stale_against_main")] BranchStaleAgainstMain, #[serde(rename = "branch.workspace_mismatch")] BranchWorkspaceMismatch, /// Ship/provenance events — §4.44.5 #[serde(rename = "ship.prepared")] ShipPrepared, #[serde(rename = "ship.commits_selected")] ShipCommitsSelected, #[serde(rename = "ship.merged")] ShipMerged, #[serde(rename = "ship.pushed_main")] ShipPushedMain, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LaneEventStatus { Running, Ready, Blocked, Red, Green, Completed, Failed, Reconciled, Merged, Superseded, Closed, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum LaneFailureClass { PromptDelivery, TrustGate, BranchDivergence, Compile, Test, PluginStartup, McpStartup, McpHandshake, GatewayRouting, ToolRuntime, WorkspaceMismatch, Infra, } /// Provenance labels for event source classification. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum EventProvenance { /// Event from a live, active lane LiveLane, /// Event from a synthetic test Test, /// Event from a healthcheck probe Healthcheck, /// Event from a replay/log replay Replay, /// Event from the transport layer itself Transport, } /// Session identity metadata captured at creation time. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionIdentity { /// Stable title for the session pub title: String, /// Workspace/worktree path pub workspace: String, /// Lane/session purpose pub purpose: String, /// Placeholder reason if any field is unknown #[serde(skip_serializing_if = "Option::is_none")] pub placeholder_reason: Option, } impl SessionIdentity { /// Create complete session identity #[must_use] pub fn new( title: impl Into, workspace: impl Into, purpose: impl Into, ) -> Self { Self { title: title.into(), workspace: workspace.into(), purpose: purpose.into(), placeholder_reason: None, } } /// Create session identity with placeholder for missing fields #[must_use] pub fn with_placeholder( title: impl Into, workspace: impl Into, purpose: impl Into, reason: impl Into, ) -> Self { Self { title: title.into(), workspace: workspace.into(), purpose: purpose.into(), placeholder_reason: Some(reason.into()), } } } /// Lane ownership and workflow scope binding. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LaneOwnership { /// Owner/assignee identity pub owner: String, /// Workflow scope (e.g., claw-code-dogfood, external-git-maintenance) pub workflow_scope: String, /// Whether the watcher is expected to act, observe, or ignore pub watcher_action: WatcherAction, } /// Watcher action expectation for a lane event. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum WatcherAction { /// Watcher should take action on this event Act, /// Watcher should only observe Observe, /// Watcher should ignore this event Ignore, } /// Event metadata for ordering, provenance, deduplication, and ownership. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LaneEventMetadata { /// Monotonic sequence number for event ordering pub seq: u64, /// Event provenance source pub provenance: EventProvenance, /// Session identity at creation #[serde(skip_serializing_if = "Option::is_none")] pub session_identity: Option, /// Lane ownership and scope #[serde(skip_serializing_if = "Option::is_none")] pub ownership: Option, /// Nudge ID for deduplication cycles #[serde(skip_serializing_if = "Option::is_none")] pub nudge_id: Option, /// Event fingerprint for terminal event deduplication #[serde(skip_serializing_if = "Option::is_none")] pub event_fingerprint: Option, /// Timestamp when event was observed/created pub timestamp_ms: u64, } impl LaneEventMetadata { /// Create new event metadata #[must_use] pub fn new(seq: u64, provenance: EventProvenance) -> Self { Self { seq, provenance, session_identity: None, ownership: None, nudge_id: None, event_fingerprint: None, timestamp_ms: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64, } } /// Add session identity #[must_use] pub fn with_session_identity(mut self, identity: SessionIdentity) -> Self { self.session_identity = Some(identity); self } /// Add ownership info #[must_use] pub fn with_ownership(mut self, ownership: LaneOwnership) -> Self { self.ownership = Some(ownership); self } /// Add nudge ID for dedupe #[must_use] pub fn with_nudge_id(mut self, nudge_id: impl Into) -> Self { self.nudge_id = Some(nudge_id.into()); self } /// Compute and add event fingerprint for terminal events #[must_use] pub fn with_fingerprint(mut self, fingerprint: impl Into) -> Self { self.event_fingerprint = Some(fingerprint.into()); self } } /// Builder for constructing [`LaneEvent`]s with proper metadata. #[derive(Debug, Clone)] pub struct LaneEventBuilder { event: LaneEventName, status: LaneEventStatus, emitted_at: String, metadata: LaneEventMetadata, detail: Option, failure_class: Option, data: Option, } impl LaneEventBuilder { /// Start building a new lane event #[must_use] pub fn new( event: LaneEventName, status: LaneEventStatus, emitted_at: impl Into, seq: u64, provenance: EventProvenance, ) -> Self { Self { event, status, emitted_at: emitted_at.into(), metadata: LaneEventMetadata::new(seq, provenance), detail: None, failure_class: None, data: None, } } /// Add session identity #[must_use] pub fn with_session_identity(mut self, identity: SessionIdentity) -> Self { self.metadata = self.metadata.with_session_identity(identity); self } /// Add ownership info #[must_use] pub fn with_ownership(mut self, ownership: LaneOwnership) -> Self { self.metadata = self.metadata.with_ownership(ownership); self } /// Add nudge ID #[must_use] pub fn with_nudge_id(mut self, nudge_id: impl Into) -> Self { self.metadata = self.metadata.with_nudge_id(nudge_id); self } /// Add detail #[must_use] pub fn with_detail(mut self, detail: impl Into) -> Self { self.detail = Some(detail.into()); self } /// Add failure class #[must_use] pub fn with_failure_class(mut self, failure_class: LaneFailureClass) -> Self { self.failure_class = Some(failure_class); self } /// Add data payload #[must_use] pub fn with_data(mut self, data: serde_json::Value) -> Self { self.data = Some(data); self } /// Compute fingerprint and build terminal event #[must_use] pub fn build_terminal(mut self) -> LaneEvent { let fingerprint = compute_event_fingerprint(&self.event, &self.status, self.data.as_ref()); self.metadata = self.metadata.with_fingerprint(fingerprint); self.build() } /// Build the event #[must_use] pub fn build(self) -> LaneEvent { LaneEvent { event: self.event, status: self.status, emitted_at: self.emitted_at, failure_class: self.failure_class, detail: self.detail, data: self.data, metadata: self.metadata, } } } /// Check if an event kind is terminal (completed, failed, superseded, closed). #[must_use] pub fn is_terminal_event(event: LaneEventName) -> bool { matches!( event, LaneEventName::Finished | LaneEventName::Failed | LaneEventName::Superseded | LaneEventName::Closed | LaneEventName::Merged ) } /// Compute a fingerprint for terminal event deduplication. #[must_use] pub fn compute_event_fingerprint( event: &LaneEventName, status: &LaneEventStatus, data: Option<&serde_json::Value>, ) -> String { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); format!("{event:?}").hash(&mut hasher); format!("{status:?}").hash(&mut hasher); if let Some(d) = data { serde_json::to_string(d) .unwrap_or_default() .hash(&mut hasher); } format!("{:016x}", hasher.finish()) } /// Deduplicate terminal events within a reconciliation window. /// Returns only the first occurrence of each terminal fingerprint. #[must_use] pub fn dedupe_terminal_events(events: &[LaneEvent]) -> Vec { let mut seen_fingerprints = std::collections::HashSet::new(); let mut result = Vec::new(); for event in events { if is_terminal_event(event.event) { if let Some(fp) = &event.metadata.event_fingerprint { if seen_fingerprints.contains(fp) { continue; // Skip duplicate terminal event } seen_fingerprints.insert(fp.clone()); } } result.push(event.clone()); } result } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum BlockedSubphase { #[serde(rename = "blocked.trust_prompt")] TrustPrompt { gate_repo: String }, #[serde(rename = "blocked.prompt_delivery")] PromptDelivery { attempt: u32 }, #[serde(rename = "blocked.plugin_init")] PluginInit { plugin_name: String }, #[serde(rename = "blocked.mcp_handshake")] McpHandshake { server_name: String, attempt: u32 }, #[serde(rename = "blocked.branch_freshness")] BranchFreshness { behind_main: u32 }, #[serde(rename = "blocked.test_hang")] TestHang { elapsed_secs: u32, test_name: Option }, #[serde(rename = "blocked.report_pending")] ReportPending { since_secs: u32 }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LaneEventBlocker { #[serde(rename = "failureClass")] pub failure_class: LaneFailureClass, pub detail: String, #[serde(skip_serializing_if = "Option::is_none")] pub subphase: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LaneCommitProvenance { pub commit: String, pub branch: String, #[serde(skip_serializing_if = "Option::is_none")] pub worktree: Option, #[serde(rename = "canonicalCommit", skip_serializing_if = "Option::is_none")] pub canonical_commit: Option, #[serde(rename = "supersededBy", skip_serializing_if = "Option::is_none")] pub superseded_by: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub lineage: Vec, } /// Ship/provenance metadata — §4.44.5 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ShipProvenance { pub source_branch: String, pub base_commit: String, pub commit_count: u32, pub commit_range: String, pub merge_method: ShipMergeMethod, pub actor: String, #[serde(skip_serializing_if = "Option::is_none")] pub pr_number: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ShipMergeMethod { DirectPush, FastForward, MergeCommit, SquashMerge, RebaseMerge, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LaneEvent { pub event: LaneEventName, pub status: LaneEventStatus, #[serde(rename = "emittedAt")] pub emitted_at: String, #[serde(rename = "failureClass", skip_serializing_if = "Option::is_none")] pub failure_class: Option, #[serde(skip_serializing_if = "Option::is_none")] pub detail: Option, #[serde(skip_serializing_if = "Option::is_none")] pub data: Option, /// Event metadata for ordering, provenance, dedupe, and ownership pub metadata: LaneEventMetadata, } impl LaneEvent { /// Create a new lane event with minimal metadata (seq=0, provenance=LiveLane) /// Use `LaneEventBuilder` for events requiring full metadata. #[must_use] pub fn new( event: LaneEventName, status: LaneEventStatus, emitted_at: impl Into, ) -> Self { Self { event, status, emitted_at: emitted_at.into(), failure_class: None, detail: None, data: None, metadata: LaneEventMetadata::new(0, EventProvenance::LiveLane), } } #[must_use] pub fn started(emitted_at: impl Into) -> Self { Self::new(LaneEventName::Started, LaneEventStatus::Running, emitted_at) } #[must_use] pub fn finished(emitted_at: impl Into, detail: Option) -> Self { Self::new( LaneEventName::Finished, LaneEventStatus::Completed, emitted_at, ) .with_optional_detail(detail) } #[must_use] pub fn commit_created( emitted_at: impl Into, detail: Option, provenance: LaneCommitProvenance, ) -> Self { Self::new( LaneEventName::CommitCreated, LaneEventStatus::Completed, emitted_at, ) .with_optional_detail(detail) .with_data(serde_json::to_value(provenance).expect("commit provenance should serialize")) } #[must_use] pub fn superseded( emitted_at: impl Into, detail: Option, provenance: LaneCommitProvenance, ) -> Self { Self::new( LaneEventName::Superseded, LaneEventStatus::Superseded, emitted_at, ) .with_optional_detail(detail) .with_data(serde_json::to_value(provenance).expect("commit provenance should serialize")) } #[must_use] pub fn blocked(emitted_at: impl Into, blocker: &LaneEventBlocker) -> Self { let mut event = Self::new(LaneEventName::Blocked, LaneEventStatus::Blocked, emitted_at) .with_failure_class(blocker.failure_class) .with_detail(blocker.detail.clone()); if let Some(ref subphase) = blocker.subphase { event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize")); } event } #[must_use] pub fn failed(emitted_at: impl Into, blocker: &LaneEventBlocker) -> Self { let mut event = Self::new(LaneEventName::Failed, LaneEventStatus::Failed, emitted_at) .with_failure_class(blocker.failure_class) .with_detail(blocker.detail.clone()); if let Some(ref subphase) = blocker.subphase { event = event.with_data(serde_json::to_value(subphase).expect("subphase should serialize")); } event } /// Ship prepared — §4.44.5 #[must_use] pub fn ship_prepared(emitted_at: impl Into, provenance: &ShipProvenance) -> Self { Self::new(LaneEventName::ShipPrepared, LaneEventStatus::Ready, emitted_at) .with_data(serde_json::to_value(provenance).expect("ship provenance should serialize")) } /// Ship commits selected — §4.44.5 #[must_use] pub fn ship_commits_selected( emitted_at: impl Into, commit_count: u32, commit_range: impl Into, ) -> Self { Self::new(LaneEventName::ShipCommitsSelected, LaneEventStatus::Ready, emitted_at) .with_detail(format!("{} commits: {}", commit_count, commit_range.into())) } /// Ship merged — §4.44.5 #[must_use] pub fn ship_merged(emitted_at: impl Into, provenance: &ShipProvenance) -> Self { Self::new(LaneEventName::ShipMerged, LaneEventStatus::Completed, emitted_at) .with_data(serde_json::to_value(provenance).expect("ship provenance should serialize")) } /// Ship pushed to main — §4.44.5 #[must_use] pub fn ship_pushed_main(emitted_at: impl Into, provenance: &ShipProvenance) -> Self { Self::new(LaneEventName::ShipPushedMain, LaneEventStatus::Completed, emitted_at) .with_data(serde_json::to_value(provenance).expect("ship provenance should serialize")) } #[must_use] pub fn with_failure_class(mut self, failure_class: LaneFailureClass) -> Self { self.failure_class = Some(failure_class); self } #[must_use] pub fn with_detail(mut self, detail: impl Into) -> Self { self.detail = Some(detail.into()); self } #[must_use] pub fn with_optional_detail(mut self, detail: Option) -> Self { self.detail = detail; self } #[must_use] pub fn with_data(mut self, data: Value) -> Self { self.data = Some(data); self } } #[must_use] pub fn dedupe_superseded_commit_events(events: &[LaneEvent]) -> Vec { let mut keep = vec![true; events.len()]; let mut latest_by_key = std::collections::BTreeMap::::new(); for (index, event) in events.iter().enumerate() { if event.event != LaneEventName::CommitCreated { continue; } let Some(data) = event.data.as_ref() else { continue; }; let key = data .get("canonicalCommit") .or_else(|| data.get("commit")) .and_then(serde_json::Value::as_str) .map(str::to_string); let superseded = data .get("supersededBy") .and_then(serde_json::Value::as_str) .is_some(); if superseded { keep[index] = false; continue; } if let Some(key) = key { if let Some(previous) = latest_by_key.insert(key, index) { keep[previous] = false; } } } events .iter() .cloned() .zip(keep) .filter_map(|(event, retain)| retain.then_some(event)) .collect() } #[cfg(test)] mod tests { use serde_json::json; use super::{ compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events, is_terminal_event, BlockedSubphase, EventProvenance, LaneCommitProvenance, LaneEvent, LaneEventBlocker, LaneEventBuilder, LaneEventMetadata, LaneEventName, LaneEventStatus, LaneFailureClass, LaneOwnership, SessionIdentity, ShipMergeMethod, ShipProvenance, WatcherAction, }; #[test] fn canonical_lane_event_names_serialize_to_expected_wire_values() { let cases = [ (LaneEventName::Started, "lane.started"), (LaneEventName::Ready, "lane.ready"), (LaneEventName::PromptMisdelivery, "lane.prompt_misdelivery"), (LaneEventName::Blocked, "lane.blocked"), (LaneEventName::Red, "lane.red"), (LaneEventName::Green, "lane.green"), (LaneEventName::CommitCreated, "lane.commit.created"), (LaneEventName::PrOpened, "lane.pr.opened"), (LaneEventName::MergeReady, "lane.merge.ready"), (LaneEventName::Finished, "lane.finished"), (LaneEventName::Failed, "lane.failed"), (LaneEventName::Reconciled, "lane.reconciled"), (LaneEventName::Merged, "lane.merged"), (LaneEventName::Superseded, "lane.superseded"), (LaneEventName::Closed, "lane.closed"), ( LaneEventName::BranchStaleAgainstMain, "branch.stale_against_main", ), ( LaneEventName::BranchWorkspaceMismatch, "branch.workspace_mismatch", ), (LaneEventName::ShipPrepared, "ship.prepared"), (LaneEventName::ShipCommitsSelected, "ship.commits_selected"), (LaneEventName::ShipMerged, "ship.merged"), (LaneEventName::ShipPushedMain, "ship.pushed_main"), ]; for (event, expected) in cases { assert_eq!( serde_json::to_value(event).expect("serialize event"), json!(expected) ); } } #[test] fn failure_classes_cover_canonical_taxonomy_wire_values() { let cases = [ (LaneFailureClass::PromptDelivery, "prompt_delivery"), (LaneFailureClass::TrustGate, "trust_gate"), (LaneFailureClass::BranchDivergence, "branch_divergence"), (LaneFailureClass::Compile, "compile"), (LaneFailureClass::Test, "test"), (LaneFailureClass::PluginStartup, "plugin_startup"), (LaneFailureClass::McpStartup, "mcp_startup"), (LaneFailureClass::McpHandshake, "mcp_handshake"), (LaneFailureClass::GatewayRouting, "gateway_routing"), (LaneFailureClass::ToolRuntime, "tool_runtime"), (LaneFailureClass::WorkspaceMismatch, "workspace_mismatch"), (LaneFailureClass::Infra, "infra"), ]; for (failure_class, expected) in cases { assert_eq!( serde_json::to_value(failure_class).expect("serialize failure class"), json!(expected) ); } } #[test] fn blocked_and_failed_events_reuse_blocker_details() { let blocker = LaneEventBlocker { failure_class: LaneFailureClass::McpStartup, detail: "broken server".to_string(), subphase: Some(BlockedSubphase::McpHandshake { server_name: "test-server".to_string(), attempt: 1, }), }; let blocked = LaneEvent::blocked("2026-04-04T00:00:00Z", &blocker); let failed = LaneEvent::failed("2026-04-04T00:00:01Z", &blocker); assert_eq!(blocked.event, LaneEventName::Blocked); assert_eq!(blocked.status, LaneEventStatus::Blocked); assert_eq!(blocked.failure_class, Some(LaneFailureClass::McpStartup)); assert_eq!(failed.event, LaneEventName::Failed); assert_eq!(failed.status, LaneEventStatus::Failed); assert_eq!(failed.detail.as_deref(), Some("broken server")); } #[test] fn workspace_mismatch_failure_class_round_trips_in_branch_event_payloads() { let mismatch = LaneEvent::new( LaneEventName::BranchWorkspaceMismatch, LaneEventStatus::Blocked, "2026-04-04T00:00:02Z", ) .with_failure_class(LaneFailureClass::WorkspaceMismatch) .with_detail("session belongs to /tmp/repo-a but current workspace is /tmp/repo-b") .with_data(json!({ "expectedWorkspaceRoot": "/tmp/repo-a", "actualWorkspaceRoot": "/tmp/repo-b", "sessionId": "sess-123", })); let mismatch_json = serde_json::to_value(&mismatch).expect("lane event should serialize"); assert_eq!(mismatch_json["event"], "branch.workspace_mismatch"); assert_eq!(mismatch_json["failureClass"], "workspace_mismatch"); assert_eq!( mismatch_json["data"]["expectedWorkspaceRoot"], "/tmp/repo-a" ); let round_trip: LaneEvent = serde_json::from_value(mismatch_json).expect("lane event should deserialize"); assert_eq!(round_trip.event, LaneEventName::BranchWorkspaceMismatch); assert_eq!( round_trip.failure_class, Some(LaneFailureClass::WorkspaceMismatch) ); } #[test] fn ship_provenance_events_serialize_to_expected_wire_values() { let provenance = ShipProvenance { source_branch: "feature/provenance".to_string(), base_commit: "dd73962".to_string(), commit_count: 6, commit_range: "dd73962..c956f78".to_string(), merge_method: ShipMergeMethod::DirectPush, actor: "Jobdori".to_string(), pr_number: None, }; let prepared = LaneEvent::ship_prepared("2026-04-20T14:30:00Z", &provenance); let prepared_json = serde_json::to_value(&prepared).expect("ship event should serialize"); assert_eq!(prepared_json["event"], "ship.prepared"); assert_eq!(prepared_json["data"]["commit_count"], 6); assert_eq!(prepared_json["data"]["source_branch"], "feature/provenance"); let pushed = LaneEvent::ship_pushed_main("2026-04-20T14:35:00Z", &provenance); let pushed_json = serde_json::to_value(&pushed).expect("ship event should serialize"); assert_eq!(pushed_json["event"], "ship.pushed_main"); assert_eq!(pushed_json["data"]["merge_method"], "direct_push"); let round_trip: LaneEvent = serde_json::from_value(pushed_json).expect("ship event should deserialize"); assert_eq!(round_trip.event, LaneEventName::ShipPushedMain); } #[test] fn commit_events_can_carry_worktree_and_supersession_metadata() { let event = LaneEvent::commit_created( "2026-04-04T00:00:00Z", Some("commit created".to_string()), LaneCommitProvenance { commit: "abc123".to_string(), branch: "feature/provenance".to_string(), worktree: Some("wt-a".to_string()), canonical_commit: Some("abc123".to_string()), superseded_by: None, lineage: vec!["abc123".to_string()], }, ); let event_json = serde_json::to_value(&event).expect("lane event should serialize"); assert_eq!(event_json["event"], "lane.commit.created"); assert_eq!(event_json["data"]["branch"], "feature/provenance"); assert_eq!(event_json["data"]["worktree"], "wt-a"); } #[test] fn dedupes_superseded_commit_events_by_canonical_commit() { let retained = dedupe_superseded_commit_events(&[ LaneEvent::commit_created( "2026-04-04T00:00:00Z", Some("old".to_string()), LaneCommitProvenance { commit: "old123".to_string(), branch: "feature/provenance".to_string(), worktree: Some("wt-a".to_string()), canonical_commit: Some("canon123".to_string()), superseded_by: Some("new123".to_string()), lineage: vec!["old123".to_string(), "new123".to_string()], }, ), LaneEvent::commit_created( "2026-04-04T00:00:01Z", Some("new".to_string()), LaneCommitProvenance { commit: "new123".to_string(), branch: "feature/provenance".to_string(), worktree: Some("wt-b".to_string()), canonical_commit: Some("canon123".to_string()), superseded_by: None, lineage: vec!["old123".to_string(), "new123".to_string()], }, ), ]); assert_eq!(retained.len(), 1); assert_eq!(retained[0].detail.as_deref(), Some("new")); } #[test] fn lane_event_metadata_includes_monotonic_sequence() { let meta1 = LaneEventMetadata::new(0, EventProvenance::LiveLane); let meta2 = LaneEventMetadata::new(1, EventProvenance::LiveLane); let meta3 = LaneEventMetadata::new(2, EventProvenance::Test); assert_eq!(meta1.seq, 0); assert_eq!(meta2.seq, 1); assert_eq!(meta3.seq, 2); assert!(meta1.timestamp_ms <= meta2.timestamp_ms); } #[test] fn event_provenance_round_trips_through_serialization() { let cases = [ (EventProvenance::LiveLane, "live_lane"), (EventProvenance::Test, "test"), (EventProvenance::Healthcheck, "healthcheck"), (EventProvenance::Replay, "replay"), (EventProvenance::Transport, "transport"), ]; for (provenance, expected) in cases { let json = serde_json::to_value(provenance).expect("should serialize"); assert_eq!(json, serde_json::json!(expected)); let round_trip: EventProvenance = serde_json::from_value(json).expect("should deserialize"); assert_eq!(round_trip, provenance); } } #[test] fn session_identity_is_complete_at_creation() { let identity = SessionIdentity::new("my-lane", "/tmp/repo", "implement feature X"); assert_eq!(identity.title, "my-lane"); assert_eq!(identity.workspace, "/tmp/repo"); assert_eq!(identity.purpose, "implement feature X"); assert!(identity.placeholder_reason.is_none()); // Test with placeholder let with_placeholder = SessionIdentity::with_placeholder( "untitled", "/tmp/unknown", "unknown", "session created before title was known", ); assert_eq!( with_placeholder.placeholder_reason, Some("session created before title was known".to_string()) ); } #[test] fn lane_ownership_binding_includes_workflow_scope() { let ownership = LaneOwnership { owner: "claw-1".to_string(), workflow_scope: "claw-code-dogfood".to_string(), watcher_action: WatcherAction::Act, }; assert_eq!(ownership.owner, "claw-1"); assert_eq!(ownership.workflow_scope, "claw-code-dogfood"); assert_eq!(ownership.watcher_action, WatcherAction::Act); } #[test] fn watcher_action_round_trips_through_serialization() { let cases = [ (WatcherAction::Act, "act"), (WatcherAction::Observe, "observe"), (WatcherAction::Ignore, "ignore"), ]; for (action, expected) in cases { let json = serde_json::to_value(action).expect("should serialize"); assert_eq!(json, serde_json::json!(expected)); let round_trip: WatcherAction = serde_json::from_value(json).expect("should deserialize"); assert_eq!(round_trip, action); } } #[test] fn is_terminal_event_detects_terminal_states() { assert!(is_terminal_event(LaneEventName::Finished)); assert!(is_terminal_event(LaneEventName::Failed)); assert!(is_terminal_event(LaneEventName::Superseded)); assert!(is_terminal_event(LaneEventName::Closed)); assert!(is_terminal_event(LaneEventName::Merged)); assert!(!is_terminal_event(LaneEventName::Started)); assert!(!is_terminal_event(LaneEventName::Ready)); assert!(!is_terminal_event(LaneEventName::Blocked)); } #[test] fn compute_event_fingerprint_is_deterministic() { let fp1 = compute_event_fingerprint( &LaneEventName::Finished, &LaneEventStatus::Completed, Some(&json!({"commit": "abc123"})), ); let fp2 = compute_event_fingerprint( &LaneEventName::Finished, &LaneEventStatus::Completed, Some(&json!({"commit": "abc123"})), ); assert_eq!(fp1, fp2, "same inputs should produce same fingerprint"); assert!(!fp1.is_empty()); assert_eq!(fp1.len(), 16, "fingerprint should be 16 hex chars"); } #[test] fn compute_event_fingerprint_differs_for_different_inputs() { let fp1 = compute_event_fingerprint(&LaneEventName::Finished, &LaneEventStatus::Completed, None); let fp2 = compute_event_fingerprint(&LaneEventName::Failed, &LaneEventStatus::Failed, None); let fp3 = compute_event_fingerprint( &LaneEventName::Finished, &LaneEventStatus::Completed, Some(&json!({"commit": "abc123"})), ); assert_ne!(fp1, fp2, "different event/status should differ"); assert_ne!(fp1, fp3, "different data should differ"); } #[test] fn dedupe_terminal_events_suppresses_duplicates() { let event1 = LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .build_terminal(); let event2 = LaneEventBuilder::new( LaneEventName::Started, LaneEventStatus::Running, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .build(); let event3 = LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:02Z", 2, EventProvenance::LiveLane, ) .build_terminal(); // Same fingerprint as event1 let deduped = dedupe_terminal_events(&[event1.clone(), event2.clone(), event3.clone()]); assert_eq!(deduped.len(), 2, "should have 2 events after dedupe"); assert_eq!(deduped[0].event, LaneEventName::Finished); assert_eq!(deduped[1].event, LaneEventName::Started); // event3 should be suppressed as duplicate of event1 } #[test] fn lane_event_builder_constructs_event_with_metadata() { let event = LaneEventBuilder::new( LaneEventName::Started, LaneEventStatus::Running, "2026-04-04T00:00:00Z", 42, EventProvenance::Test, ) .with_session_identity(SessionIdentity::new("test-lane", "/tmp", "test")) .with_ownership(LaneOwnership { owner: "bot-1".to_string(), workflow_scope: "test-suite".to_string(), watcher_action: WatcherAction::Observe, }) .with_nudge_id("nudge-123") .with_detail("starting test run") .build(); assert_eq!(event.event, LaneEventName::Started); assert_eq!(event.metadata.seq, 42); assert_eq!(event.metadata.provenance, EventProvenance::Test); assert_eq!( event.metadata.session_identity.as_ref().unwrap().title, "test-lane" ); assert_eq!(event.metadata.ownership.as_ref().unwrap().owner, "bot-1"); assert_eq!(event.metadata.nudge_id, Some("nudge-123".to_string())); assert_eq!(event.detail, Some("starting test run".to_string())); } #[test] fn lane_event_metadata_round_trips_through_serialization() { let meta = LaneEventMetadata::new(5, EventProvenance::Healthcheck) .with_session_identity(SessionIdentity::new("lane-1", "/tmp", "purpose")) .with_nudge_id("nudge-abc"); let json = serde_json::to_value(&meta).expect("should serialize"); assert_eq!(json["seq"], 5); assert_eq!(json["provenance"], "healthcheck"); assert_eq!(json["nudge_id"], "nudge-abc"); assert!(json["timestamp_ms"].as_u64().is_some()); let round_trip: LaneEventMetadata = serde_json::from_value(json).expect("should deserialize"); assert_eq!(round_trip.seq, 5); assert_eq!(round_trip.provenance, EventProvenance::Healthcheck); assert_eq!(round_trip.nudge_id, Some("nudge-abc".to_string())); } }