use std::path::Path;
use std::time::Duration;
use tokio::time::Instant;
use super::execution_manifest::{AcceptanceExecutionManifest, GateOutcome, GateResults};
use super::verification_evidence::{
evaluate_reuse, CaptureOutcome, Clock, CommandSupervisor, CurrentBindings, EvidenceStore,
RepositoryFacts, ReuseDecision, ReusePolicy, ReviewBinding, RuntimeVerificationExecutor,
VerificationRequest,
};
pub const TOOL_UNAVAILABLE: &str = "tool_unavailable";
#[derive(Debug, Clone, Copy)]
pub struct GateBudget {
pub remaining: Option<Duration>,
}
impl GateBudget {
pub fn unbounded() -> Self {
Self { remaining: None }
}
pub fn bounded(remaining: Duration) -> Self {
Self {
remaining: Some(remaining),
}
}
fn is_exhausted(&self) -> bool {
self.remaining.is_some_and(|remaining| remaining.is_zero())
}
fn consume(&mut self, elapsed: Duration) {
if let Some(remaining) = self.remaining.as_mut() {
*remaining = remaining.saturating_sub(elapsed);
}
}
fn exhaust(&mut self) {
if let Some(remaining) = self.remaining.as_mut() {
*remaining = Duration::ZERO;
}
}
}
pub async fn execute_declared_gates<F, S, C>(
executor: &RuntimeVerificationExecutor<F, S, C>,
manifest: &AcceptanceExecutionManifest,
reuse_decisions: &[ReuseDecision],
mut budget: GateBudget,
) -> GateResults
where
F: RepositoryFacts,
S: CommandSupervisor,
C: Clock,
{
let mut outcomes = Vec::new();
for gate in &manifest.gates {
let id = gate.verification_id.as_str();
if budget.is_exhausted() {
outcomes.push(GateOutcome::DeadlineExhausted {
verification_id: id.to_string(),
cleanup_confirmed: true,
cleanup_diagnostics: "the gate was never started".to_string(),
});
continue;
}
let reusable = reuse_decisions
.iter()
.find(|decision| decision.verification_id() == id)
.and_then(|decision| match decision {
ReuseDecision::Reuse { evidence, .. } => Some(evidence.as_ref()),
ReuseDecision::Rerun { .. } => None,
});
if let Some(evidence) = reusable {
outcomes.push(GateOutcome::Reused {
verification_id: id.to_string(),
artifact_path: evidence.artifact_path.clone(),
elapsed_seconds: evidence.elapsed_seconds().unwrap_or_default(),
});
continue;
}
if gate.tool.path.trim().is_empty() {
outcomes.push(GateOutcome::Refused {
verification_id: id.to_string(),
code: TOOL_UNAVAILABLE.to_string(),
detail: format!(
"declared command '{}' could not be resolved to an executable on PATH",
gate.argv.first().map_or("", String::as_str)
),
});
continue;
}
let request = gate.request();
let started = Instant::now();
let outcome = run_one_gate(executor, &request, budget.remaining).await;
if matches!(outcome, GateOutcome::DeadlineExhausted { .. }) {
budget.exhaust();
} else {
budget.consume(started.elapsed());
}
let blocking_failure = outcome.is_failure();
outcomes.push(outcome);
if blocking_failure {
for remaining in manifest
.gates
.iter()
.skip_while(|candidate| candidate.verification_id != gate.verification_id)
.skip(1)
{
outcomes.push(GateOutcome::Refused {
verification_id: remaining.verification_id.clone(),
code: "blocking_gate_already_failed".to_string(),
detail: format!(
"not executed: '{id}' already failed, so the Acceptance verdict is \
determined"
),
});
}
break;
}
}
GateResults { outcomes }
}
async fn run_one_gate<F, S, C>(
executor: &RuntimeVerificationExecutor<F, S, C>,
request: &VerificationRequest,
remaining: Option<Duration>,
) -> GateOutcome
where
F: RepositoryFacts,
S: CommandSupervisor,
C: Clock,
{
let id = request.verification_id.clone();
match executor.capture_bounded(request, remaining).await {
CaptureOutcome::Captured { evidence, .. } => GateOutcome::Executed {
verification_id: id,
artifact_path: evidence.artifact_path.clone(),
elapsed_seconds: evidence.elapsed_seconds().unwrap_or_default(),
},
CaptureOutcome::CommandFailed {
exit_code,
artifact_path,
..
} => GateOutcome::Failed {
verification_id: id,
exit_code,
artifact_path,
},
CaptureOutcome::Refused(refusal) => GateOutcome::Refused {
verification_id: id,
code: refusal.code.to_string(),
detail: refusal.detail,
},
CaptureOutcome::DeadlineExhausted { cleanup, .. } => GateOutcome::DeadlineExhausted {
verification_id: id,
cleanup_confirmed: cleanup.is_confirmed(),
cleanup_diagnostics: cleanup.diagnostics(),
},
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GateVerdict {
Proceed,
Failed { evidence: Vec<String> },
ExternalPrerequisite {
blocker: crate::acceptance::AcceptanceBlocker,
},
Hold {
category: super::execution_manifest::AcceptanceHoldCategory,
evidence: Vec<String>,
},
}
pub fn classify_gate_results(results: &GateResults) -> GateVerdict {
use super::execution_manifest::AcceptanceHoldCategory;
if let Some(GateOutcome::Refused {
verification_id,
detail,
..
}) = results.outcomes.iter().find(
|outcome| matches!(outcome, GateOutcome::Refused { code, .. } if code == TOOL_UNAVAILABLE),
) {
return GateVerdict::ExternalPrerequisite {
blocker: crate::acceptance::AcceptanceBlocker {
category: "infrastructure".to_string(),
evidence: vec![format!(
"declared change-blocking verification '{verification_id}' could not run: \
{detail}"
)],
unblock_condition: format!(
"the executable named by declared verification '{verification_id}' resolves on \
PATH in this workspace"
),
next_action: format!(
"Install or expose the tool the '{verification_id}' declaration names, then \
retry acceptance."
),
resumable: true,
prerequisite_owner: None,
evidence_ids: vec![verification_id.clone()],
},
};
}
if let Some(failure) = results.first_failure() {
return GateVerdict::Failed {
evidence: vec![failure.summary()],
};
}
if results.first_deadline_exhausted().is_some() {
return GateVerdict::Hold {
category: AcceptanceHoldCategory::DeclaredGateDeadlineExhausted,
evidence: results.summary_lines(),
};
}
if results.first_refusal().is_some() {
return GateVerdict::Hold {
category: AcceptanceHoldCategory::RuntimeDefect,
evidence: results.summary_lines(),
};
}
GateVerdict::Proceed
}
pub async fn plan_manifest_reuse<F: RepositoryFacts>(
facts: &F,
workspace: &Path,
store: &EvidenceStore,
manifest: &AcceptanceExecutionManifest,
policy: ReusePolicy,
) -> Vec<ReuseDecision> {
use super::verification_evidence::{dirty_entries, EvidenceDefect, RerunReason};
let mut decisions = Vec::new();
let dirty = match facts.porcelain_status(workspace).await {
Ok(status) => dirty_entries(&status),
Err(error) => {
for gate in &manifest.gates {
decisions.push(ReuseDecision::Rerun {
verification_id: gate.verification_id.clone(),
reason: RerunReason::Unobservable(error.clone()),
});
}
return decisions;
}
};
for gate in &manifest.gates {
let request = gate.request();
let record = match store.load(&request.verification_id) {
Ok(record) => record,
Err(defect) => {
decisions.push(ReuseDecision::Rerun {
verification_id: request.verification_id,
reason: RerunReason::Defect(defect),
});
continue;
}
};
if !dirty.is_empty() {
decisions.push(ReuseDecision::Rerun {
verification_id: request.verification_id,
reason: RerunReason::DirtyWorktree(dirty.clone()),
});
continue;
}
let artifact_digest = facts
.hash_file(workspace, &store.artifact_path(&request.verification_id))
.await
.ok();
if artifact_digest.is_none() {
decisions.push(ReuseDecision::Rerun {
verification_id: request.verification_id,
reason: RerunReason::Defect(EvidenceDefect::Unreadable(format!(
"artifact '{}' is absent or unreadable in the external store",
record.artifact_path
))),
});
continue;
}
let current = CurrentBindings {
commit_oid: manifest.candidate_commit_oid.clone(),
tree_oid: manifest.candidate_tree_oid.clone(),
review: Some(ReviewBinding {
base_commit: manifest.review_base_commit.clone(),
range: manifest.review_range.clone(),
}),
automation_blob_oid: gate.automation_blob_oid.clone(),
tool: gate.tool.clone(),
clean: true,
artifact_digest,
};
decisions.push(evaluate_reuse(&request, &record, ¤t, policy));
}
decisions
}
#[cfg(test)]
#[path = "gate_execution/tests.rs"]
mod tests;