Skip to main content

driven/strategy/
handoff.rs

1use crate::{DrivenError, Result};
2use serde::{Deserialize, Serialize};
3
4use super::redaction::redact_secrets;
5use super::{
6    ClaimToken, LANE_HANDOFF_SCHEMA, LaneClaim, LaneId, OutcomeProof, PassNumber, ProofReceipt,
7    WorkerId, WorktreeIdentity,
8};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "snake_case")]
12pub enum PassOutcome {
13    Completed,
14    Partial,
15    Blocked,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19pub struct NextPassHandoff {
20    pub schema: String,
21    pub lane: LaneId,
22    pub completed_pass: PassNumber,
23    pub next_pass: PassNumber,
24    pub worker_id: WorkerId,
25    pub from_claim: ClaimToken,
26    pub receipt_id: String,
27    pub outcome: PassOutcome,
28    pub summary: String,
29    pub next_action: String,
30    pub blockers: Vec<String>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub payload_digest: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub worktree_identity: Option<WorktreeIdentity>,
35}
36
37impl LaneClaim {
38    pub fn next_pass_handoff(
39        &self,
40        receipt: ProofReceipt,
41        next_action: impl Into<String>,
42    ) -> Result<NextPassHandoff> {
43        self.validate()?;
44        receipt.validate()?;
45        if receipt.redacted {
46            return Err(DrivenError::Validation(
47                "next-pass handoff requires an unredacted proof receipt".to_string(),
48            ));
49        }
50        if receipt.claim.lane != self.lane
51            || receipt.claim.pass != self.pass
52            || receipt.claim.worker_id != self.worker_id
53            || receipt.claim.token != self.token
54        {
55            return Err(DrivenError::Validation(
56                "receipt claim does not match lane claim".to_string(),
57            ));
58        }
59
60        let next_action = next_action.into();
61        if next_action.trim().is_empty() {
62            return Err(DrivenError::Validation(
63                "next-pass handoff requires a next action".to_string(),
64            ));
65        }
66
67        let blockers = receipt.blockers();
68        let outcome = if !blockers.is_empty() {
69            PassOutcome::Blocked
70        } else if receipt
71            .outcomes
72            .iter()
73            .any(|outcome| matches!(outcome, OutcomeProof::Partial { .. }))
74        {
75            PassOutcome::Partial
76        } else {
77            PassOutcome::Completed
78        };
79
80        Ok(NextPassHandoff {
81            schema: LANE_HANDOFF_SCHEMA.to_string(),
82            lane: self.lane,
83            completed_pass: self.pass,
84            next_pass: self.pass.next()?,
85            worker_id: self.worker_id.clone(),
86            from_claim: self.token.clone(),
87            receipt_id: receipt.receipt_id()?,
88            outcome,
89            summary: receipt.summary().to_string(),
90            next_action,
91            blockers,
92            payload_digest: None,
93            worktree_identity: None,
94        })
95    }
96}
97
98impl NextPassHandoff {
99    pub fn validate(&self) -> Result<()> {
100        if self.schema != LANE_HANDOFF_SCHEMA {
101            return Err(DrivenError::Validation(format!(
102                "handoff schema must be {}",
103                LANE_HANDOFF_SCHEMA
104            )));
105        }
106        if self.receipt_id.trim().is_empty() {
107            return Err(DrivenError::Validation(
108                "handoff receipt id cannot be empty".to_string(),
109            ));
110        }
111        if self.summary.trim().is_empty() {
112            return Err(DrivenError::Validation(
113                "handoff summary cannot be empty".to_string(),
114            ));
115        }
116        if self.next_action.trim().is_empty() {
117            return Err(DrivenError::Validation(
118                "handoff next action cannot be empty".to_string(),
119            ));
120        }
121        if self.outcome == PassOutcome::Blocked && self.blockers.is_empty() {
122            return Err(DrivenError::Validation(
123                "blocked handoff requires at least one blocker".to_string(),
124            ));
125        }
126        if self.outcome == PassOutcome::Completed && !self.blockers.is_empty() {
127            return Err(DrivenError::Validation(
128                "completed handoff cannot include blockers".to_string(),
129            ));
130        }
131        if self.next_pass != self.completed_pass.next()? {
132            return Err(DrivenError::Validation(
133                "handoff next pass must increment exactly once".to_string(),
134            ));
135        }
136        if let Some(payload_digest) = &self.payload_digest
137            && payload_digest != &self.computed_payload_digest()?
138        {
139            return Err(DrivenError::Validation(
140                "handoff payload digest does not match payload".to_string(),
141            ));
142        }
143        Ok(())
144    }
145
146    pub(crate) fn validate_persisted(&self) -> Result<()> {
147        match &self.payload_digest {
148            Some(payload_digest) if !payload_digest.trim().is_empty() => {}
149            _ => {
150                return Err(DrivenError::Validation(
151                    "stored handoff payload digest is required".to_string(),
152                ));
153            }
154        }
155        if self.worktree_identity.is_none() {
156            return Err(DrivenError::Validation(
157                "stored handoff worktree identity is required".to_string(),
158            ));
159        }
160        self.validate()
161    }
162
163    pub(crate) fn redacted_for_persistence(&self) -> Self {
164        let mut handoff = self.clone();
165        handoff.payload_digest = None;
166        handoff.summary = redact_secrets(&handoff.summary);
167        handoff.next_action = redact_secrets(&handoff.next_action);
168        for blocker in &mut handoff.blockers {
169            *blocker = redact_secrets(blocker);
170        }
171        handoff
172    }
173
174    pub(crate) fn seal_payload_digest(&mut self) -> Result<()> {
175        self.payload_digest = Some(self.computed_payload_digest()?);
176        Ok(())
177    }
178
179    fn computed_payload_digest(&self) -> Result<String> {
180        let mut canonical = self.clone();
181        canonical.payload_digest = None;
182        let payload = serde_json::to_vec(&canonical)
183            .map_err(|e| DrivenError::Format(format!("failed to render handoff JSON: {}", e)))?;
184        Ok(blake3::hash(&payload).to_hex().to_string())
185    }
186
187    pub fn validate_against(&self, claim: &LaneClaim) -> Result<()> {
188        self.validate()?;
189        claim.validate()?;
190        if self.lane != claim.lane {
191            return Err(DrivenError::Validation(
192                "handoff lane does not match claim lane".to_string(),
193            ));
194        }
195        if self.completed_pass != claim.pass {
196            return Err(DrivenError::Validation(
197                "handoff completed pass does not match claim pass".to_string(),
198            ));
199        }
200        if self.next_pass != claim.pass.next()? {
201            return Err(DrivenError::Validation(
202                "handoff next pass must increment exactly once".to_string(),
203            ));
204        }
205        if self.worker_id != claim.worker_id {
206            return Err(DrivenError::Validation(
207                "handoff worker does not match claim worker".to_string(),
208            ));
209        }
210        if self.from_claim != claim.token {
211            return Err(DrivenError::Validation(
212                "handoff claim token does not match claim".to_string(),
213            ));
214        }
215        Ok(())
216    }
217}