driven/strategy/
handoff.rs1use 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}