#![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()), } } /// Reconcile enriched metadata onto this session identity. /// Updates fields with new information while preserving the session identity. /// Clears placeholder reason once real values are provided. #[must_use] pub fn reconcile_enriched( self, title: Option, workspace: Option, purpose: Option, ) -> Self { // Check if any new values are provided before consuming options let has_new_data = title.is_some() || workspace.is_some() || purpose.is_some(); Self { title: title.unwrap_or(self.title), workspace: workspace.unwrap_or(self.workspace), purpose: purpose.unwrap_or(self.purpose), // Clear placeholder if any real values were provided placeholder_reason: if has_new_data { None } else { self.placeholder_reason }, } } } /// 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, } /// Confidence/trust level for downstream automation decisions. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ConfidenceLevel { /// High confidence - suitable for automated action High, /// Medium confidence - may require verification Medium, /// Low confidence - likely requires human review Low, /// Unknown confidence level Unknown, } /// Event metadata for ordering, provenance, deduplication, ownership, and confidence. #[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, /// Environment/channel label (e.g., production, staging, dev) #[serde(skip_serializing_if = "Option::is_none")] pub environment_label: Option, /// Emitter identity (e.g., clawd, plugin-name, operator-id) #[serde(skip_serializing_if = "Option::is_none")] pub emitter_identity: Option, /// Confidence/trust level for downstream automation #[serde(skip_serializing_if = "Option::is_none")] pub confidence_level: Option, } 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, environment_label: None, emitter_identity: None, confidence_level: None, } } /// 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 } /// Add environment/channel label #[must_use] pub fn with_environment(mut self, label: impl Into) -> Self { self.environment_label = Some(label.into()); self } /// Add emitter identity #[must_use] pub fn with_emitter(mut self, emitter: impl Into) -> Self { self.emitter_identity = Some(emitter.into()); self } /// Add confidence/trust level #[must_use] pub fn with_confidence(mut self, level: ConfidenceLevel) -> Self { self.confidence_level = Some(level); 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 } /// Add environment/channel label #[must_use] pub fn with_environment(mut self, label: impl Into) -> Self { self.metadata = self.metadata.with_environment(label); self } /// Add emitter identity #[must_use] pub fn with_emitter(mut self, emitter: impl Into) -> Self { self.metadata = self.metadata.with_emitter(emitter); self } /// Add confidence level #[must_use] pub fn with_confidence(mut self, level: ConfidenceLevel) -> Self { self.metadata = self.metadata.with_confidence(level); 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()) } /// Classification of event terminality for reconciliation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[allow(dead_code)] pub enum EventTerminality { /// Terminal event - represents final session outcome (completed, failed, etc.) Terminal, /// Advisory event - informational, not final outcome Advisory, /// Uncertainty event - transport died, terminal state unknown Uncertainty, } /// Determine the terminality classification of an event. #[must_use] #[allow(dead_code)] pub fn classify_event_terminality(event: LaneEventName) -> EventTerminality { match event { LaneEventName::Finished | LaneEventName::Failed | LaneEventName::Merged | LaneEventName::Superseded | LaneEventName::Closed => EventTerminality::Terminal, LaneEventName::Reconciled => EventTerminality::Uncertainty, _ => EventTerminality::Advisory, } } /// Reconcile a burst of potentially contradictory events into one canonical outcome. /// /// Handles: /// - Out-of-order events (sorts by monotonic sequence) /// - Duplicate terminal events (deduplicates by fingerprint) /// - Transport death after terminal event (classifies as Uncertainty) /// - `completed -> idle -> error -> completed` noise #[must_use] #[allow(dead_code)] pub fn reconcile_terminal_events(events: &[LaneEvent]) -> Option<(LaneEvent, Vec)> { if events.is_empty() { return None; } // Sort by monotonic sequence number for deterministic ordering let mut sorted: Vec = events.to_vec(); sorted.sort_by_key(|e| e.metadata.seq); // Track the last terminal event and any transport/uncertainty events after it let mut last_terminal: Option = None; let mut post_terminal_uncertainty = false; let mut reconciled_events = Vec::new(); for event in &sorted { match classify_event_terminality(event.event) { EventTerminality::Terminal => { // Check if this is a duplicate of an already-seen terminal event if let Some(ref terminal) = last_terminal { if let (Some(fp1), Some(fp2)) = ( &event.metadata.event_fingerprint, &terminal.metadata.event_fingerprint, ) { if fp1 == fp2 { // Same fingerprint - skip as duplicate continue; } } // Different terminal payload - check if materially different if events_materially_differ(terminal, event) { // Materially different terminal event - update to latest last_terminal = Some(event.clone()); } } else { last_terminal = Some(event.clone()); } } EventTerminality::Uncertainty => { // Transport/server-down after terminal event creates uncertainty if last_terminal.is_some() { post_terminal_uncertainty = true; } reconciled_events.push(event.clone()); } EventTerminality::Advisory => { reconciled_events.push(event.clone()); } } } // If there's post-terminal uncertainty, wrap the terminal event in uncertainty let final_terminal = if post_terminal_uncertainty { last_terminal.map(|mut t| { t.event = LaneEventName::Reconciled; t.status = LaneEventStatus::Reconciled; t.detail = Some( "Session terminal state uncertain: transport died after terminal event".to_string(), ); t }) } else { last_terminal }; final_terminal.map(|t| (t, reconciled_events)) } /// Check if two terminal events are materially different. /// Used to determine if a later duplicate should override an earlier one. #[must_use] #[allow(dead_code)] pub fn events_materially_differ(a: &LaneEvent, b: &LaneEvent) -> bool { // Different event type is material if a.event != b.event { return true; } // Different status is material if a.status != b.status { return true; } // Different failure class is material if a.failure_class != b.failure_class { return true; } // Different data payload is material if a.data != b.data { return true; } false } /// Filter events by provenance source. #[must_use] #[allow(dead_code)] pub fn filter_by_provenance(events: &[LaneEvent], provenance: EventProvenance) -> Vec { events .iter() .filter(|e| e.metadata.provenance == provenance) .cloned() .collect() } /// Filter events by environment label. #[must_use] #[allow(dead_code)] pub fn filter_by_environment(events: &[LaneEvent], environment: &str) -> Vec { events .iter() .filter(|e| { e.metadata .environment_label .as_ref() .is_some_and(|label| label == environment) }) .cloned() .collect() } /// Filter events by minimum confidence level. #[must_use] #[allow(dead_code)] pub fn filter_by_confidence( events: &[LaneEvent], min_confidence: ConfidenceLevel, ) -> Vec { let confidence_order = |c: ConfidenceLevel| match c { ConfidenceLevel::High => 3, ConfidenceLevel::Medium => 2, ConfidenceLevel::Low => 1, ConfidenceLevel::Unknown => 0, }; let min_level = confidence_order(min_confidence); events .iter() .filter(|e| { e.metadata .confidence_level .is_some_and(|c| confidence_order(c) >= min_level) }) .cloned() .collect() } /// Check if an event is from a test or synthetic source. #[must_use] #[allow(dead_code)] pub fn is_test_event(event: &LaneEvent) -> bool { matches!( event.metadata.provenance, EventProvenance::Test | EventProvenance::Healthcheck | EventProvenance::Replay ) } /// Check if an event is from a live production lane. #[must_use] #[allow(dead_code)] pub fn is_live_lane_event(event: &LaneEvent) -> bool { event.metadata.provenance == EventProvenance::LiveLane } /// Nudge state tracking for acknowledgment and deduplication. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[allow(dead_code)] pub struct NudgeTracking { /// Unique nudge/cycle identifier pub nudge_id: String, /// Timestamp when nudge was first delivered pub delivered_at: String, /// Whether this nudge has been acknowledged pub acknowledged: bool, /// Timestamp when acknowledged (if applicable) #[serde(skip_serializing_if = "Option::is_none")] pub acknowledged_at: Option, /// Whether this is a retry of a previous nudge pub is_retry: bool, /// Original nudge ID if this is a retry #[serde(skip_serializing_if = "Option::is_none")] pub original_nudge_id: Option, } #[allow(dead_code)] impl NudgeTracking { /// Create a new nudge tracking record #[must_use] pub fn new(nudge_id: impl Into, delivered_at: impl Into) -> Self { Self { nudge_id: nudge_id.into(), delivered_at: delivered_at.into(), acknowledged: false, acknowledged_at: None, is_retry: false, original_nudge_id: None, } } /// Create a nudge tracking record for a retry #[must_use] pub fn retry( nudge_id: impl Into, delivered_at: impl Into, original_nudge_id: impl Into, ) -> Self { Self { nudge_id: nudge_id.into(), delivered_at: delivered_at.into(), acknowledged: false, acknowledged_at: None, is_retry: true, original_nudge_id: Some(original_nudge_id.into()), } } /// Mark this nudge as acknowledged #[must_use] pub fn acknowledge(mut self, at: impl Into) -> Self { self.acknowledged = true; self.acknowledged_at = Some(at.into()); self } } /// Classification of nudge types for deduplication logic. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NudgeClassification { /// Brand new nudge - first delivery New, /// Retry of a previous nudge (same content, new delivery) Retry, /// Stale duplicate - should be ignored StaleDuplicate, } /// Classify a nudge based on existing tracking records. #[must_use] #[allow(dead_code)] pub fn classify_nudge( nudge_id: &str, existing_tracking: &[NudgeTracking], acknowledged_nudge_ids: &[String], ) -> NudgeClassification { // Check if already acknowledged - stale duplicate if acknowledged_nudge_ids.iter().any(|id| id == nudge_id) { return NudgeClassification::StaleDuplicate; } // Check if this is a retry of an existing nudge for tracking in existing_tracking { if tracking.nudge_id == nudge_id { // Same ID already seen - check if acknowledged if tracking.acknowledged { return NudgeClassification::StaleDuplicate; } // Not acknowledged yet - could be a retry with same ID return NudgeClassification::Retry; } // Check if this nudge is a retry of a tracked nudge if tracking.original_nudge_id.as_ref() == Some(&nudge_id.to_string()) { return NudgeClassification::StaleDuplicate; } } NudgeClassification::New } /// Stable roadmap ID assignment for newly filed pinpoints. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[allow(dead_code)] pub struct RoadmapId { /// Canonical unique identifier pub id: String, /// Timestamp when first filed pub filed_at: String, /// Whether this is a new filing or update to existing pub is_new_filing: bool, /// Previous ID if this supersedes or merges another item #[serde(skip_serializing_if = "Option::is_none")] pub supersedes: Option, } #[allow(dead_code)] impl RoadmapId { /// Create a new roadmap ID at filing time #[must_use] pub fn new_filing(id: impl Into, filed_at: impl Into) -> Self { Self { id: id.into(), filed_at: filed_at.into(), is_new_filing: true, supersedes: None, } } /// Create an update to an existing roadmap item #[must_use] pub fn update(id: impl Into, filed_at: impl Into) -> Self { Self { id: id.into(), filed_at: filed_at.into(), is_new_filing: false, supersedes: None, } } /// Create a roadmap ID that supersedes another #[must_use] pub fn supersedes( id: impl Into, filed_at: impl Into, previous_id: impl Into, ) -> Self { Self { id: id.into(), filed_at: filed_at.into(), is_new_filing: true, supersedes: Some(previous_id.into()), } } } /// Lifecycle state for roadmap items. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] #[allow(dead_code)] pub enum RoadmapLifecycleState { /// Newly filed, awaiting acknowledgment Filed, /// Acknowledged by responsible party Acknowledged, /// Currently being worked on InProgress, /// Blocked on external dependency Blocked, /// Completed successfully Done, /// No longer relevant, replaced by another item Superseded, } /// Roadmap item lifecycle state with timestamp tracking. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[allow(dead_code)] pub struct RoadmapLifecycle { /// Current lifecycle state pub state: RoadmapLifecycleState, /// Timestamp of last state change pub state_changed_at: String, /// Timestamp when first filed pub filed_at: String, /// Lineage for superseded/merged items #[serde(skip_serializing_if = "Vec::is_empty", default)] pub lineage: Vec, } #[allow(dead_code)] impl RoadmapLifecycle { /// Create a new roadmap lifecycle starting at "filed" #[must_use] pub fn new_filed(filed_at: impl Into) -> Self { let filed_at = filed_at.into(); Self { state: RoadmapLifecycleState::Filed, state_changed_at: filed_at.clone(), filed_at, lineage: Vec::new(), } } /// Transition to a new state #[must_use] pub fn transition(mut self, new_state: RoadmapLifecycleState, at: impl Into) -> Self { self.state = new_state; self.state_changed_at = at.into(); self } /// Mark as superseded by another item #[must_use] pub fn superseded_by(mut self, new_item_id: impl Into, at: impl Into) -> Self { let new_item_id = new_item_id.into(); self.lineage.push(new_item_id.clone()); self.state = RoadmapLifecycleState::Superseded; self.state_changed_at = at.into(); self } /// Check if this item is in a terminal state (done or superseded) #[must_use] pub fn is_terminal(&self) -> bool { matches!( self.state, RoadmapLifecycleState::Done | RoadmapLifecycleState::Superseded ) } /// Check if this item is active (not terminal) #[must_use] pub fn is_active(&self) -> bool { !self.is_terminal() } } /// 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::{ classify_event_terminality, compute_event_fingerprint, dedupe_superseded_commit_events, dedupe_terminal_events, events_materially_differ, filter_by_confidence, filter_by_environment, filter_by_provenance, is_live_lane_event, is_terminal_event, is_test_event, reconcile_terminal_events, BlockedSubphase, ConfidenceLevel, EventProvenance, EventTerminality, 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); } #[test] fn classify_event_terminality_correctly() { assert_eq!( classify_event_terminality(LaneEventName::Finished), EventTerminality::Terminal ); assert_eq!( classify_event_terminality(LaneEventName::Failed), EventTerminality::Terminal ); assert_eq!( classify_event_terminality(LaneEventName::Reconciled), EventTerminality::Uncertainty ); assert_eq!( classify_event_terminality(LaneEventName::Started), EventTerminality::Advisory ); assert_eq!( classify_event_terminality(LaneEventName::Ready), EventTerminality::Advisory ); } #[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 session_identity_reconcile_enriched_updates_fields() { // Start with placeholder identity let initial = SessionIdentity::with_placeholder( "untitled", "/tmp/unknown", "unknown", "awaiting title from user", ); assert!(initial.placeholder_reason.is_some()); // Enrich with real title - workspace/purpose still unknown let enriched = initial.reconcile_enriched(Some("feature-branch-123".to_string()), None, None); assert_eq!(enriched.title, "feature-branch-123"); assert_eq!(enriched.workspace, "/tmp/unknown"); // preserved assert_eq!(enriched.purpose, "unknown"); // preserved // Placeholder cleared because we got a real title assert!(enriched.placeholder_reason.is_none()); // Further enrichment with workspace and purpose let final_identity = enriched.reconcile_enriched( None, // keep existing title Some("/home/user/projects/my-app".to_string()), Some("implement user authentication".to_string()), ); assert_eq!(final_identity.title, "feature-branch-123"); assert_eq!(final_identity.workspace, "/home/user/projects/my-app"); assert_eq!(final_identity.purpose, "implement user authentication"); assert!(final_identity.placeholder_reason.is_none()); } #[test] fn session_identity_reconcile_preserves_placeholder_if_no_new_data() { let initial = SessionIdentity::with_placeholder( "untitled", "/tmp/unknown", "unknown", "still waiting for info", ); // Reconcile with no new data let reconciled = initial.reconcile_enriched(None, None, None); // Should preserve original values and placeholder assert_eq!(reconciled.title, "untitled"); assert_eq!(reconciled.workspace, "/tmp/unknown"); assert_eq!(reconciled.purpose, "unknown"); assert_eq!( reconciled.placeholder_reason, Some("still waiting for info".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())); } // US-013: Session event ordering + terminal-state reconciliation tests #[test] fn reconcile_terminal_events_sorts_by_monotonic_sequence() { let events = vec![ LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:02Z", 2, EventProvenance::LiveLane, ) .build(), LaneEventBuilder::new( LaneEventName::Started, LaneEventStatus::Running, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .build(), LaneEventBuilder::new( LaneEventName::Ready, LaneEventStatus::Ready, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .build(), ]; let (terminal, _) = reconcile_terminal_events(&events).expect("should have terminal event"); assert_eq!(terminal.event, LaneEventName::Finished); assert_eq!(terminal.metadata.seq, 2); // Highest sequence } #[test] fn reconcile_terminal_events_deduplicates_same_fingerprint() { let events = vec![ LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .build_terminal(), LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .build_terminal(), ]; let (terminal, _) = reconcile_terminal_events(&events).expect("should have terminal event"); // Both have same fingerprint (same event/status/data), so should dedupe assert_eq!(terminal.event, LaneEventName::Finished); } #[test] fn reconcile_terminal_events_detects_transport_death_uncertainty() { let events = vec![ LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .build_terminal(), LaneEventBuilder::new( LaneEventName::Reconciled, LaneEventStatus::Reconciled, "2026-04-04T00:00:01Z", 1, EventProvenance::Transport, ) .build(), ]; let (terminal, reconciled) = reconcile_terminal_events(&events).expect("should have result"); // Transport death after terminal creates uncertainty assert_eq!(terminal.event, LaneEventName::Reconciled); assert_eq!(terminal.status, LaneEventStatus::Reconciled); assert!(terminal .detail .as_ref() .unwrap() .contains("transport died after terminal event")); assert_eq!(reconciled.len(), 1); } #[test] fn reconcile_terminal_events_handles_completed_idle_error_completed_noise() { let events = vec![ LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .build_terminal(), LaneEventBuilder::new( LaneEventName::Started, LaneEventStatus::Running, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .build(), LaneEventBuilder::new( LaneEventName::Failed, LaneEventStatus::Failed, "2026-04-04T00:00:02Z", 2, EventProvenance::LiveLane, ) .with_failure_class(LaneFailureClass::Infra) .build_terminal(), LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:03Z", 3, EventProvenance::LiveLane, ) .build_terminal(), ]; let (terminal, _) = reconcile_terminal_events(&events).expect("should have terminal event"); // Latest terminal event wins assert_eq!(terminal.event, LaneEventName::Finished); assert_eq!(terminal.status, LaneEventStatus::Completed); } #[test] fn reconcile_terminal_events_returns_none_for_empty_input() { let result = reconcile_terminal_events(&[]); assert!(result.is_none()); } #[test] fn reconcile_terminal_events_preserves_advisory_events() { let events = vec![ LaneEventBuilder::new( LaneEventName::Started, LaneEventStatus::Running, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .build(), LaneEventBuilder::new( LaneEventName::Ready, LaneEventStatus::Ready, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .build(), LaneEventBuilder::new( LaneEventName::Green, LaneEventStatus::Green, "2026-04-04T00:00:02Z", 2, EventProvenance::LiveLane, ) .build(), ]; let result = reconcile_terminal_events(&events); // Only advisory events - no terminal event to reconcile assert!( result.is_none(), "should return None when no terminal events" ); } #[test] fn events_materially_differ_detects_real_differences() { let event_a = LaneEventBuilder::new( LaneEventName::Failed, LaneEventStatus::Failed, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .with_failure_class(LaneFailureClass::Compile) .build_terminal(); let event_b = LaneEventBuilder::new( LaneEventName::Failed, LaneEventStatus::Failed, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .with_failure_class(LaneFailureClass::Test) .build_terminal(); assert!(events_materially_differ(&event_a, &event_b)); } #[test] fn classify_event_terminality_correctly_classifies() { assert_eq!( classify_event_terminality(LaneEventName::Finished), EventTerminality::Terminal ); assert_eq!( classify_event_terminality(LaneEventName::Failed), EventTerminality::Terminal ); assert_eq!( classify_event_terminality(LaneEventName::Reconciled), EventTerminality::Uncertainty ); assert_eq!( classify_event_terminality(LaneEventName::Started), EventTerminality::Advisory ); } // US-014: Event provenance / environment labeling tests #[test] fn confidence_level_round_trips_through_serialization() { let cases = [ (ConfidenceLevel::High, "high"), (ConfidenceLevel::Medium, "medium"), (ConfidenceLevel::Low, "low"), (ConfidenceLevel::Unknown, "unknown"), ]; for (level, expected) in cases { let json = serde_json::to_value(level).expect("should serialize"); assert_eq!(json, serde_json::json!(expected)); let round_trip: ConfidenceLevel = serde_json::from_value(json).expect("should deserialize"); assert_eq!(round_trip, level); } } #[test] fn filter_by_provenance_selects_only_matching_events() { let events = vec![ LaneEventBuilder::new( LaneEventName::Started, LaneEventStatus::Running, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .build(), LaneEventBuilder::new( LaneEventName::Ready, LaneEventStatus::Ready, "2026-04-04T00:00:01Z", 1, EventProvenance::Test, ) .build(), LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:02Z", 2, EventProvenance::LiveLane, ) .build(), ]; let live_events = filter_by_provenance(&events, EventProvenance::LiveLane); assert_eq!(live_events.len(), 2); assert_eq!(live_events[0].event, LaneEventName::Started); assert_eq!(live_events[1].event, LaneEventName::Finished); let test_events = filter_by_provenance(&events, EventProvenance::Test); assert_eq!(test_events.len(), 1); assert_eq!(test_events[0].event, LaneEventName::Ready); } #[test] fn filter_by_environment_selects_only_matching_environment() { let events = vec![ LaneEventBuilder::new( LaneEventName::Started, LaneEventStatus::Running, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .with_environment("production") .build(), LaneEventBuilder::new( LaneEventName::Ready, LaneEventStatus::Ready, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .with_environment("staging") .build(), LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:02Z", 2, EventProvenance::LiveLane, ) .with_environment("production") .build(), ]; let prod_events = filter_by_environment(&events, "production"); assert_eq!(prod_events.len(), 2); assert_eq!(prod_events[0].event, LaneEventName::Started); assert_eq!(prod_events[1].event, LaneEventName::Finished); let staging_events = filter_by_environment(&events, "staging"); assert_eq!(staging_events.len(), 1); assert_eq!(staging_events[0].event, LaneEventName::Ready); } #[test] fn filter_by_confidence_selects_events_above_threshold() { let events = vec![ LaneEventBuilder::new( LaneEventName::Started, LaneEventStatus::Running, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .with_confidence(ConfidenceLevel::High) .build(), LaneEventBuilder::new( LaneEventName::Ready, LaneEventStatus::Ready, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .with_confidence(ConfidenceLevel::Medium) .build(), LaneEventBuilder::new( LaneEventName::Blocked, LaneEventStatus::Blocked, "2026-04-04T00:00:02Z", 2, EventProvenance::LiveLane, ) .with_confidence(ConfidenceLevel::Low) .build(), LaneEventBuilder::new( LaneEventName::Failed, LaneEventStatus::Failed, "2026-04-04T00:00:03Z", 3, EventProvenance::LiveLane, ) // No confidence level set .build(), ]; // High confidence filter should only return high confidence events let high_confidence = filter_by_confidence(&events, ConfidenceLevel::High); assert_eq!(high_confidence.len(), 1); assert_eq!(high_confidence[0].event, LaneEventName::Started); // Medium and above should return high and medium let medium_and_above = filter_by_confidence(&events, ConfidenceLevel::Medium); assert_eq!(medium_and_above.len(), 2); // Low and above should return high, medium, and low let low_and_above = filter_by_confidence(&events, ConfidenceLevel::Low); assert_eq!(low_and_above.len(), 3); } #[test] fn is_test_event_detects_synthetic_sources() { let test_event = LaneEventBuilder::new( LaneEventName::Started, LaneEventStatus::Running, "2026-04-04T00:00:00Z", 0, EventProvenance::Test, ) .build(); let healthcheck_event = LaneEventBuilder::new( LaneEventName::Ready, LaneEventStatus::Ready, "2026-04-04T00:00:01Z", 1, EventProvenance::Healthcheck, ) .build(); let live_event = LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:02Z", 2, EventProvenance::LiveLane, ) .build(); assert!(is_test_event(&test_event)); assert!(is_test_event(&healthcheck_event)); assert!(!is_test_event(&live_event)); } #[test] fn is_live_lane_event_detects_production_events() { let live_event = LaneEventBuilder::new( LaneEventName::Started, LaneEventStatus::Running, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .build(); let test_event = LaneEventBuilder::new( LaneEventName::Ready, LaneEventStatus::Ready, "2026-04-04T00:00:01Z", 1, EventProvenance::Test, ) .build(); assert!(is_live_lane_event(&live_event)); assert!(!is_live_lane_event(&test_event)); } #[test] fn lane_event_metadata_includes_us014_fields() { let meta = LaneEventMetadata::new(42, EventProvenance::LiveLane) .with_environment("production") .with_emitter("clawd-1") .with_confidence(ConfidenceLevel::High); assert_eq!(meta.environment_label, Some("production".to_string())); assert_eq!(meta.emitter_identity, Some("clawd-1".to_string())); assert_eq!(meta.confidence_level, Some(ConfidenceLevel::High)); } // US-016: Duplicate terminal-event suppression tests #[test] fn canonical_terminal_event_fingerprint_attached_to_metadata() { let event = LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .with_data(json!({"result": "success"})) .build_terminal(); // Fingerprint should be computed and attached assert!(event.metadata.event_fingerprint.is_some()); let fp = event.metadata.event_fingerprint.unwrap(); assert_eq!(fp.len(), 16); // 16 hex characters } #[test] fn dedupe_terminal_events_suppresses_repeated_fingerprints() { let event1 = LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .build_terminal(); let event2 = LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .build_terminal(); // Both should have the same fingerprint (same event/status/data) assert_eq!( event1.metadata.event_fingerprint, event2.metadata.event_fingerprint ); let deduped = dedupe_terminal_events(&[event1.clone(), event2.clone()]); // Should only keep first occurrence assert_eq!(deduped.len(), 1); assert_eq!(deduped[0].metadata.seq, 0); } #[test] fn dedupe_preserves_raw_event_history_separately() { // This test demonstrates that raw events can be preserved // while exposing deduplicated actionable events let raw_events = vec![ LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .build_terminal(), LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .build_terminal(), ]; // Raw history preserved (2 events) assert_eq!(raw_events.len(), 2); // Deduplicated actionable events (1 event) let deduped = dedupe_terminal_events(&raw_events); assert_eq!(deduped.len(), 1); } #[test] fn events_materially_differ_detects_payload_differences() { let event_a = LaneEventBuilder::new( LaneEventName::Failed, LaneEventStatus::Failed, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .with_failure_class(LaneFailureClass::Compile) .with_data(json!({"error": "compilation failed"})) .build_terminal(); let event_b = LaneEventBuilder::new( LaneEventName::Failed, LaneEventStatus::Failed, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .with_failure_class(LaneFailureClass::Compile) .with_data(json!({"error": "different error message"})) .build_terminal(); // Same event type, status, failure class - but different data payload assert!(events_materially_differ(&event_a, &event_b)); } #[test] fn reconcile_terminal_events_surfaces_latest_when_different() { // Events with different data payloads will have different fingerprints let event1 = LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .with_data(json!({"attempt": 1, "result": "success"})) .build_terminal(); let event2 = LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .with_data(json!({"attempt": 2, "result": "success", "extra": "data"})) .build_terminal(); // Fingerprints should differ due to different data assert_ne!( event1.metadata.event_fingerprint, event2.metadata.event_fingerprint ); let (terminal, _) = reconcile_terminal_events(&[event1.clone(), event2.clone()]) .expect("should have terminal"); // Latest terminal event wins (seq 1, not seq 0) - data is different so it's material assert_eq!(terminal.metadata.seq, 1); assert_eq!( terminal.data, Some(json!({"attempt": 2, "result": "success", "extra": "data"})) ); } // US-017: Lane ownership / scope binding tests #[test] fn lane_ownership_attached_to_metadata() { let ownership = LaneOwnership { owner: "bot-1".to_string(), workflow_scope: "claw-code-dogfood".to_string(), watcher_action: WatcherAction::Act, }; let event = LaneEventBuilder::new( LaneEventName::Started, LaneEventStatus::Running, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .with_ownership(ownership.clone()) .build(); assert_eq!(event.metadata.ownership.as_ref().unwrap().owner, "bot-1"); assert_eq!( event.metadata.ownership.as_ref().unwrap().workflow_scope, "claw-code-dogfood" ); assert_eq!( event.metadata.ownership.as_ref().unwrap().watcher_action, WatcherAction::Act ); } #[test] fn lane_ownership_preserved_through_lifecycle_events() { let ownership = LaneOwnership { owner: "operator-1".to_string(), workflow_scope: "external-git-maintenance".to_string(), watcher_action: WatcherAction::Observe, }; let start_event = LaneEventBuilder::new( LaneEventName::Started, LaneEventStatus::Running, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .with_ownership(ownership.clone()) .build(); let ready_event = LaneEventBuilder::new( LaneEventName::Ready, LaneEventStatus::Ready, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .with_ownership(ownership.clone()) .build(); let finished_event = LaneEventBuilder::new( LaneEventName::Finished, LaneEventStatus::Completed, "2026-04-04T00:00:02Z", 2, EventProvenance::LiveLane, ) .with_ownership(ownership.clone()) .build_terminal(); // All events preserve ownership through the lifecycle assert_eq!( start_event.metadata.ownership.as_ref().unwrap().owner, "operator-1" ); assert_eq!( ready_event.metadata.ownership.as_ref().unwrap().owner, "operator-1" ); assert_eq!( finished_event.metadata.ownership.as_ref().unwrap().owner, "operator-1" ); // Scope also preserved assert_eq!( start_event .metadata .ownership .as_ref() .unwrap() .workflow_scope, "external-git-maintenance" ); assert_eq!( finished_event .metadata .ownership .as_ref() .unwrap() .workflow_scope, "external-git-maintenance" ); } #[test] fn lane_ownership_watcher_action_variants() { let act_ownership = LaneOwnership { owner: "auto-bot".to_string(), workflow_scope: "infra-health".to_string(), watcher_action: WatcherAction::Act, }; let observe_ownership = LaneOwnership { owner: "monitor-bot".to_string(), workflow_scope: "claw-code-dogfood".to_string(), watcher_action: WatcherAction::Observe, }; let ignore_ownership = LaneOwnership { owner: "ignore-bot".to_string(), workflow_scope: "manual-operator".to_string(), watcher_action: WatcherAction::Ignore, }; let act_event = LaneEventBuilder::new( LaneEventName::Blocked, LaneEventStatus::Blocked, "2026-04-04T00:00:00Z", 0, EventProvenance::LiveLane, ) .with_ownership(act_ownership) .build(); let observe_event = LaneEventBuilder::new( LaneEventName::Ready, LaneEventStatus::Ready, "2026-04-04T00:00:01Z", 1, EventProvenance::LiveLane, ) .with_ownership(observe_ownership) .build(); let ignore_event = LaneEventBuilder::new( LaneEventName::Green, LaneEventStatus::Green, "2026-04-04T00:00:02Z", 2, EventProvenance::LiveLane, ) .with_ownership(ignore_ownership) .build(); assert_eq!( act_event .metadata .ownership .as_ref() .unwrap() .watcher_action, WatcherAction::Act ); assert_eq!( observe_event .metadata .ownership .as_ref() .unwrap() .watcher_action, WatcherAction::Observe ); assert_eq!( ignore_event .metadata .ownership .as_ref() .unwrap() .watcher_action, WatcherAction::Ignore ); } }