mirror of
https://github.com/ultraworkers/claw-code.git
synced 2026-04-25 05:38:10 +08:00
Adds structured ship provenance surface to eliminate delivery-path opacity: New lane events: - ship.prepared — intent to ship established - ship.commits_selected — commit range locked - ship.merged — merge completed with provenance - ship.pushed_main — delivery to main confirmed ShipProvenance struct carries: - source_branch, base_commit - commit_count, commit_range - merge_method (direct_push/fast_forward/merge_commit/squash_merge/rebase_merge) - actor, pr_number Constructor methods added to LaneEvent for all four ship events. Tests: - Wire value serialization for ship events - Round-trip deserialization - Canonical event name coverage Runtime: 465 tests pass ROADMAP updated with IMPLEMENTED status This closes the gap where 56 commits pushed to main had no structured provenance trail — now emits first-class events for clawhip consumption.
1088 lines
36 KiB
Rust
1088 lines
36 KiB
Rust
#![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<String>,
|
|
}
|
|
|
|
impl SessionIdentity {
|
|
/// Create complete session identity
|
|
#[must_use]
|
|
pub fn new(
|
|
title: impl Into<String>,
|
|
workspace: impl Into<String>,
|
|
purpose: impl Into<String>,
|
|
) -> 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<String>,
|
|
workspace: impl Into<String>,
|
|
purpose: impl Into<String>,
|
|
reason: impl Into<String>,
|
|
) -> 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<SessionIdentity>,
|
|
/// Lane ownership and scope
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub ownership: Option<LaneOwnership>,
|
|
/// Nudge ID for deduplication cycles
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub nudge_id: Option<String>,
|
|
/// Event fingerprint for terminal event deduplication
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub event_fingerprint: Option<String>,
|
|
/// 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<String>) -> 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<String>) -> 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<String>,
|
|
failure_class: Option<LaneFailureClass>,
|
|
data: Option<serde_json::Value>,
|
|
}
|
|
|
|
impl LaneEventBuilder {
|
|
/// Start building a new lane event
|
|
#[must_use]
|
|
pub fn new(
|
|
event: LaneEventName,
|
|
status: LaneEventStatus,
|
|
emitted_at: impl Into<String>,
|
|
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<String>) -> Self {
|
|
self.metadata = self.metadata.with_nudge_id(nudge_id);
|
|
self
|
|
}
|
|
|
|
/// Add detail
|
|
#[must_use]
|
|
pub fn with_detail(mut self, detail: impl Into<String>) -> 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<LaneEvent> {
|
|
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<String> },
|
|
#[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<BlockedSubphase>,
|
|
}
|
|
|
|
#[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<String>,
|
|
#[serde(rename = "canonicalCommit", skip_serializing_if = "Option::is_none")]
|
|
pub canonical_commit: Option<String>,
|
|
#[serde(rename = "supersededBy", skip_serializing_if = "Option::is_none")]
|
|
pub superseded_by: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub lineage: Vec<String>,
|
|
}
|
|
|
|
/// 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<u32>,
|
|
}
|
|
|
|
#[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<LaneFailureClass>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub detail: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub data: Option<Value>,
|
|
/// 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<String>,
|
|
) -> 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<String>) -> Self {
|
|
Self::new(LaneEventName::Started, LaneEventStatus::Running, emitted_at)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn finished(emitted_at: impl Into<String>, detail: Option<String>) -> Self {
|
|
Self::new(
|
|
LaneEventName::Finished,
|
|
LaneEventStatus::Completed,
|
|
emitted_at,
|
|
)
|
|
.with_optional_detail(detail)
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn commit_created(
|
|
emitted_at: impl Into<String>,
|
|
detail: Option<String>,
|
|
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<String>,
|
|
detail: Option<String>,
|
|
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<String>, 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<String>, 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<String>, 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<String>,
|
|
commit_count: u32,
|
|
commit_range: impl Into<String>,
|
|
) -> 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<String>, 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<String>, 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<String>) -> Self {
|
|
self.detail = Some(detail.into());
|
|
self
|
|
}
|
|
|
|
#[must_use]
|
|
pub fn with_optional_detail(mut self, detail: Option<String>) -> 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<LaneEvent> {
|
|
let mut keep = vec![true; events.len()];
|
|
let mut latest_by_key = std::collections::BTreeMap::<String, usize>::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()));
|
|
}
|
|
}
|