Skip to main content

driven/cli/
strategy_receipt.rs

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}