1use crate::Result;
2use crate::cli::command_capture::{
3 CapturedCommandStatus, CommandExecutionPolicy, run_command_with_policy,
4};
5use crate::cli::strategy::{
6 StrategyCommand, StrategyStateOptions, command_display, enforce_strict_isolation,
7 render_receipt, state_config, unix_seconds_now,
8};
9use crate::strategy::artifacts::write_receipt_artifact;
10use crate::strategy::{
11 ClaimStatus, CommandProof, CommandStatus, LanePassStore, OutcomeProof, VerificationClass,
12 WorkerId,
13};
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct StrategyReceiptExecutionOptions {
18 pub timeout_ms: u64,
19 pub max_output_bytes: u64,
20}
21
22impl Default for StrategyReceiptExecutionOptions {
23 fn default() -> Self {
24 Self {
25 timeout_ms: 300_000,
26 max_output_bytes: 1_048_576,
27 }
28 }
29}
30
31impl StrategyReceiptExecutionOptions {
32 pub fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
33 self.timeout_ms = timeout_ms;
34 self
35 }
36
37 pub fn with_max_output_bytes(mut self, max_output_bytes: u64) -> Self {
38 self.max_output_bytes = max_output_bytes;
39 self
40 }
41
42 fn validate(&self) -> Result<()> {
43 if self.timeout_ms == 0 {
44 return Err(crate::DrivenError::Validation(
45 "receipt command timeout must be at least 1 ms".to_string(),
46 ));
47 }
48 if self.max_output_bytes == 0 {
49 return Err(crate::DrivenError::Validation(
50 "receipt command output limit must be at least 1 byte".to_string(),
51 ));
52 }
53 Ok(())
54 }
55}
56
57impl StrategyCommand {
58 pub fn receipt_state_with_options(
59 state_dir: PathBuf,
60 scope: &str,
61 max_lanes: u8,
62 max_passes: u32,
63 worker_id: &str,
64 summary: &str,
65 class: VerificationClass,
66 program: &str,
67 args: &[String],
68 receipt_path: &Path,
69 json: bool,
70 options: StrategyStateOptions,
71 ) -> Result<String> {
72 Self::receipt_state_with_execution_options(
73 state_dir,
74 scope,
75 max_lanes,
76 max_passes,
77 worker_id,
78 summary,
79 class,
80 program,
81 args,
82 receipt_path,
83 json,
84 StrategyReceiptExecutionOptions::default(),
85 options,
86 )
87 }
88
89 #[allow(clippy::too_many_arguments)]
90 pub fn receipt_state_with_execution_options(
91 state_dir: PathBuf,
92 scope: &str,
93 max_lanes: u8,
94 max_passes: u32,
95 worker_id: &str,
96 summary: &str,
97 class: VerificationClass,
98 program: &str,
99 args: &[String],
100 receipt_path: &Path,
101 json: bool,
102 execution: StrategyReceiptExecutionOptions,
103 options: StrategyStateOptions,
104 ) -> Result<String> {
105 execution.validate()?;
106 let config = state_config(state_dir, scope, &options)
107 .with_max_lanes(max_lanes)
108 .with_max_passes(max_passes);
109 enforce_strict_isolation(&config, &options)?;
110 let cwd = config
111 .project_root
112 .clone()
113 .unwrap_or(std::env::current_dir().map_err(crate::DrivenError::Io)?);
114 let store = LanePassStore::new(config)?;
115 let worker = WorkerId::new(worker_id)?;
116 let assignment = store.worker_assignment(&worker)?.ok_or_else(|| {
117 crate::DrivenError::Validation(format!("worker {} has no lane claim", worker))
118 })?;
119 if assignment.claim.status != ClaimStatus::Claimed {
120 return Err(crate::DrivenError::Validation(format!(
121 "worker {} lane claim is not active",
122 worker
123 )));
124 }
125 store.validate_current_worktree_identity(&assignment)?;
126
127 let command_text = command_display(program, args);
128 let started_unix_seconds = unix_seconds_now()?;
129 let captured = run_command_with_policy(
130 program,
131 args,
132 &cwd,
133 &CommandExecutionPolicy {
134 timeout_ms: execution.timeout_ms,
135 max_output_bytes: execution.max_output_bytes,
136 },
137 started_unix_seconds,
138 )?;
139
140 let (command, mut outcome) = match captured.status {
141 CapturedCommandStatus::Exited { exit_code } => {
142 let command = CommandProof::observed_captured(
143 command_text.clone(),
144 class,
145 exit_code,
146 &cwd,
147 captured.stdout.digest,
148 captured.stderr.digest,
149 captured.stdout.bytes,
150 captured.stderr.bytes,
151 captured.stdout.truncated,
152 captured.stderr.truncated,
153 execution.max_output_bytes,
154 captured.duration_ms,
155 captured.started_unix_seconds,
156 captured.finished_unix_seconds,
157 )?;
158 let outcome = if exit_code == 0 {
159 OutcomeProof::verified(format!(
160 "{} passed with exit code {}",
161 command_text, exit_code
162 ))
163 } else {
164 OutcomeProof::partial(format!(
165 "{} failed with exit code {}",
166 command_text, exit_code
167 ))
168 };
169 (command, outcome)
170 }
171 CapturedCommandStatus::TimedOut => {
172 let reason = format!(
173 "{} timed out after {} ms",
174 command_text, execution.timeout_ms
175 );
176 (
177 CommandProof::new(
178 command_text,
179 class,
180 CommandStatus::Blocked {
181 reason: reason.clone(),
182 },
183 ),
184 OutcomeProof::blocked(reason),
185 )
186 }
187 };
188 if let Some(reason) = stale_receipt_reason(&store, &worker, &assignment)? {
189 outcome = OutcomeProof::blocked(reason);
190 }
191
192 let mut receipt = crate::strategy::ProofReceipt::new(assignment.claim, summary)
193 .with_worktree_identity(assignment.worktree.identity())
194 .with_command(command)
195 .with_outcome(outcome);
196 receipt.receipt_id = receipt.receipt_id()?;
197 write_receipt_artifact(receipt_path, &receipt)?;
198 render_receipt(&receipt, json)
199 }
200}
201
202fn stale_receipt_reason(
203 store: &LanePassStore,
204 worker: &WorkerId,
205 assignment: &crate::strategy::LanePassAssignment,
206) -> Result<Option<String>> {
207 if store
208 .validate_current_worktree_identity(assignment)
209 .is_err()
210 {
211 return Ok(Some(format!(
212 "worker {} worktree identity changed before receipt write",
213 worker
214 )));
215 }
216 let Some(current) = store.worker_assignment(worker)? else {
217 return Ok(Some(format!(
218 "worker {} lane claim changed before receipt write",
219 worker
220 )));
221 };
222 if current.claim.status != ClaimStatus::Claimed {
223 return Ok(Some(format!(
224 "worker {} lane claim changed before receipt write",
225 worker
226 )));
227 }
228 if current.claim.token != assignment.claim.token
229 || current.lane != assignment.lane
230 || current.pass != assignment.pass
231 {
232 return Ok(Some(format!(
233 "worker {} lane claim changed before receipt write",
234 worker
235 )));
236 }
237 if !current.worktree.has_same_identity(&assignment.worktree) {
238 return Ok(Some(format!(
239 "worker {} worktree identity changed before receipt write",
240 worker
241 )));
242 }
243 Ok(None)
244}