use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::verification_evidence::{
eligible_request, is_storable_verification_id, EvidenceStore, ToolIdentity, VerificationRequest,
};
use crate::openspec::VerificationDeclaration;
pub const PRE_INTEGRATION_PHASE: &str = "pre-integration";
pub const MANIFEST_SCHEMA: &str = "conflux-acceptance-execution-manifest-v2";
pub const MANIFEST_FILE_NAME: &str = "manifest.json";
pub const FINALIZATION_RESERVE_SECS: u64 = 30;
pub const UNCHANGED_ACCEPTANCE_INPUT: &str = "unchanged_acceptance_input";
pub const FINGERPRINT_COMPONENTS: &[&str] = &[
"candidate_commit",
"candidate_tree",
"review_base",
"review_range",
"change_inputs",
"skill_identity",
"executable_identity",
"declaration",
"automation_blob",
"command_identity",
"tool_identity",
"artifact_digest",
];
pub fn is_storable_change_id(change_id: &str) -> bool {
is_storable_verification_id(change_id)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AcceptanceHoldCategory {
DeclaredGateDeadlineExhausted,
ReviewDeadlineExhausted,
ReviewCleanupUnproven,
ManifestMalformed,
RuntimeDefect,
StatePathUnavailable,
LegacyTargetEvidence,
}
impl AcceptanceHoldCategory {
pub fn as_str(self) -> &'static str {
match self {
Self::DeclaredGateDeadlineExhausted => "acceptance_declared_gate_deadline_exhausted",
Self::ReviewDeadlineExhausted => "acceptance_review_deadline_exhausted",
Self::ReviewCleanupUnproven => "acceptance_review_cleanup_unproven",
Self::ManifestMalformed => "acceptance_manifest_malformed",
Self::RuntimeDefect => "acceptance_runtime_defect",
Self::StatePathUnavailable => "acceptance_state_path_unavailable",
Self::LegacyTargetEvidence => "acceptance_legacy_target_evidence",
}
}
pub fn parse(raw: &str) -> Option<Self> {
match raw {
"acceptance_declared_gate_deadline_exhausted" => {
Some(Self::DeclaredGateDeadlineExhausted)
}
"acceptance_review_deadline_exhausted" => Some(Self::ReviewDeadlineExhausted),
"acceptance_review_cleanup_unproven" => Some(Self::ReviewCleanupUnproven),
"acceptance_manifest_malformed" => Some(Self::ManifestMalformed),
"acceptance_runtime_defect" => Some(Self::RuntimeDefect),
"acceptance_state_path_unavailable" => Some(Self::StatePathUnavailable),
"acceptance_legacy_target_evidence" => Some(Self::LegacyTargetEvidence),
_ => None,
}
}
pub fn refuses_unchanged_retry(self) -> bool {
match self {
Self::DeclaredGateDeadlineExhausted
| Self::ReviewDeadlineExhausted
| Self::ReviewCleanupUnproven => true,
Self::ManifestMalformed
| Self::RuntimeDefect
| Self::StatePathUnavailable
| Self::LegacyTargetEvidence => false,
}
}
pub fn detail(self) -> &'static str {
match self {
Self::DeclaredGateDeadlineExhausted => {
"declared change-blocking verification did not finish inside the shared \
Acceptance budget"
}
Self::ReviewDeadlineExhausted => {
"semantic review produced no canonical verdict before the reserved finalization \
window began"
}
Self::ReviewCleanupUnproven => {
"the reviewer was terminated but its owned process group was not proven quiescent"
}
Self::ManifestMalformed => {
"the stored Acceptance execution manifest could not be parsed or validated"
}
Self::RuntimeDefect => {
"the Conflux runtime could not execute or observe the declared Acceptance boundary"
}
Self::StatePathUnavailable => {
"the external Conflux-owned Acceptance state path could not be resolved, created, \
or written, and Acceptance may not fall back to the target worktree"
}
Self::LegacyTargetEvidence => {
"the target still carries legacy Conflux-owned '.cflx/verification-evidence' \
content, which this runtime neither reads nor removes"
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AcceptanceExecutionHold {
pub category: AcceptanceHoldCategory,
pub budget_secs: u64,
pub cleanup_confirmed: bool,
pub cleanup_diagnostics: String,
pub fingerprint: String,
pub evidence: Vec<String>,
}
impl AcceptanceExecutionHold {
pub fn permits_retry(&self) -> bool {
false
}
pub fn summary(&self, change_id: &str) -> String {
let head = format!(
"Acceptance for '{}' settled as a non-resumable execution hold ({}): {}. \
Input fingerprint {}. This is not a PASS, not a FAIL, and not an external block.",
change_id,
self.category.as_str(),
self.category.detail(),
&self.fingerprint[..16.min(self.fingerprint.len())],
);
if self.category.refuses_unchanged_retry() {
format!(
"{head} While this owner process is alive, a retry with unchanged repository \
input is refused as `{}`; change one of {} to permit a new bounded attempt. A \
restarted owner admits one fresh attempt without a repository change.",
UNCHANGED_ACCEPTANCE_INPUT,
FINGERPRINT_COMPONENTS.join(", "),
)
} else {
format!("{head} {}", self.next_action())
}
}
pub fn next_action(&self) -> String {
match self.category {
AcceptanceHoldCategory::StatePathUnavailable => {
"Make the Conflux-owned Acceptance state root resolvable and writable outside the \
target repository — set `state_base_dir` to an absolute path outside the project, \
or grant write permission on the resolved directory — then retry acceptance."
.to_string()
}
AcceptanceHoldCategory::LegacyTargetEvidence => {
"Remove the legacy '.cflx/verification-evidence' directory from the target \
yourself; Conflux will not read, migrate, or delete it. Then retry acceptance."
.to_string()
}
_ if self.category.refuses_unchanged_retry() => format!(
"Change one of {} in the repository, then retry explicitly. While this owner \
process is alive an unchanged retry is refused as `{}`.",
FINGERPRINT_COMPONENTS.join(", "),
UNCHANGED_ACCEPTANCE_INPUT
),
_ => "Resolve the reported runtime condition, then retry acceptance.".to_string(),
}
}
pub fn to_stalled_blocker(&self, change_id: &str) -> crate::events::StalledBlocker {
let mut evidence = vec![format!("input_fingerprint={}", self.fingerprint)];
evidence.extend(self.evidence.iter().cloned());
evidence.push(format!(
"process_group_cleanup={}",
if self.cleanup_confirmed {
"confirmed".to_string()
} else {
format!("unproven: {}", self.cleanup_diagnostics)
}
));
crate::events::StalledBlocker {
category: self.category.as_str().to_string(),
phase: "acceptance".to_string(),
gate: "acceptance_execution_boundary".to_string(),
error_summary: self.summary(change_id),
evidence,
unblock_condition: None,
prerequisite_owner: None,
next_action: self.next_action(),
resumable: false,
worktree_preserved: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeadlinePartition {
pub absolute_secs: u64,
pub work_secs: u64,
pub reserve_secs: u64,
}
impl DeadlinePartition {
pub fn is_bounded(&self) -> bool {
self.absolute_secs > 0
}
pub fn work_duration(&self) -> Option<std::time::Duration> {
self.is_bounded()
.then(|| std::time::Duration::from_secs(self.work_secs))
}
}
pub fn partition_deadline(absolute_secs: u64) -> DeadlinePartition {
if absolute_secs == 0 {
return DeadlinePartition {
absolute_secs: 0,
work_secs: 0,
reserve_secs: 0,
};
}
let reserve = FINALIZATION_RESERVE_SECS.min(absolute_secs.saturating_sub(1));
DeadlinePartition {
absolute_secs,
work_secs: absolute_secs - reserve,
reserve_secs: reserve,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ManifestGate {
pub verification_id: String,
pub argv: Vec<String>,
pub cwd: String,
pub automation_path: String,
#[serde(default)]
pub automation_blob_oid: String,
#[serde(default)]
pub tool: ToolIdentity,
pub artifact_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub artifact_digest: Option<String>,
}
impl ManifestGate {
fn fingerprint_value(&self) -> serde_json::Value {
serde_json::json!({
"verification_id": self.verification_id,
"argv": self.argv,
"cwd": self.cwd,
"automation_path": self.automation_path,
"automation_blob_oid": self.automation_blob_oid,
"tool_path": self.tool.path,
"tool_digest": self.tool.executable_digest,
"tool_version": self.tool.version,
"artifact_path": self.artifact_path,
"artifact_digest": self.artifact_digest,
})
}
pub fn request(&self) -> VerificationRequest {
VerificationRequest {
verification_id: self.verification_id.clone(),
argv: self.argv.clone(),
cwd: self.cwd.clone(),
automation_path: self.automation_path.clone(),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SkillIdentity {
pub name: String,
pub digest: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum GateOutcome {
Executed {
verification_id: String,
artifact_path: String,
elapsed_seconds: i64,
},
Reused {
verification_id: String,
artifact_path: String,
elapsed_seconds: i64,
},
Failed {
verification_id: String,
exit_code: i32,
artifact_path: String,
},
Refused {
verification_id: String,
code: String,
detail: String,
},
DeadlineExhausted {
verification_id: String,
#[serde(default)]
cleanup_confirmed: bool,
#[serde(default)]
cleanup_diagnostics: String,
},
}
impl GateOutcome {
pub fn verification_id(&self) -> &str {
match self {
Self::Executed {
verification_id, ..
}
| Self::Reused {
verification_id, ..
}
| Self::Failed {
verification_id, ..
}
| Self::Refused {
verification_id, ..
}
| Self::DeadlineExhausted {
verification_id, ..
} => verification_id,
}
}
pub fn is_successful(&self) -> bool {
matches!(self, Self::Executed { .. } | Self::Reused { .. })
}
pub fn is_failure(&self) -> bool {
matches!(self, Self::Failed { .. })
}
pub fn status(&self) -> &'static str {
match self {
Self::Executed { .. } => "executed",
Self::Reused { .. } => "reused",
Self::Failed { .. } => "failed",
Self::Refused { .. } => "refused",
Self::DeadlineExhausted { .. } => "deadline_exhausted",
}
}
pub fn summary(&self) -> String {
match self {
Self::Executed {
verification_id,
artifact_path,
elapsed_seconds,
} => format!(
"{verification_id}: executed and passed ({elapsed_seconds}s, artifact {artifact_path})"
),
Self::Reused {
verification_id,
artifact_path,
elapsed_seconds,
} => format!(
"{verification_id}: reused bound evidence ({elapsed_seconds}s, artifact {artifact_path})"
),
Self::Failed {
verification_id,
exit_code,
artifact_path,
} => format!(
"{verification_id}: FAILED with exit code {exit_code} (artifact {artifact_path})"
),
Self::Refused {
verification_id,
code,
detail,
} => format!("{verification_id}: not executed ({code}) {detail}"),
Self::DeadlineExhausted {
verification_id,
cleanup_confirmed,
cleanup_diagnostics,
} => format!(
"{verification_id}: did not finish inside the shared Acceptance budget; \
terminated, process-group cleanup {}",
if *cleanup_confirmed {
"confirmed".to_string()
} else {
format!("unproven: {cleanup_diagnostics}")
}
),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct GateResults {
pub outcomes: Vec<GateOutcome>,
}
impl GateResults {
pub fn is_empty(&self) -> bool {
self.outcomes.is_empty()
}
pub fn first_failure(&self) -> Option<&GateOutcome> {
self.outcomes.iter().find(|outcome| outcome.is_failure())
}
pub fn first_deadline_exhausted(&self) -> Option<&GateOutcome> {
self.outcomes
.iter()
.find(|outcome| matches!(outcome, GateOutcome::DeadlineExhausted { .. }))
}
pub fn cleanup_evidence(&self) -> (bool, String) {
match self.first_deadline_exhausted() {
Some(GateOutcome::DeadlineExhausted {
cleanup_confirmed,
cleanup_diagnostics,
..
}) => (*cleanup_confirmed, cleanup_diagnostics.clone()),
_ => (
true,
"no reviewer was started and no gate was left running".to_string(),
),
}
}
pub fn first_refusal(&self) -> Option<&GateOutcome> {
self.outcomes
.iter()
.find(|outcome| matches!(outcome, GateOutcome::Refused { .. }))
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"gates": self.outcomes,
"all_successful": self.outcomes.iter().all(GateOutcome::is_successful),
})
}
pub fn summary_lines(&self) -> Vec<String> {
self.outcomes.iter().map(GateOutcome::summary).collect()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcceptanceDiagnostics {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hold_category: Option<AcceptanceHoldCategory>,
pub fingerprint: String,
pub cleanup_confirmed: bool,
#[serde(default)]
pub cleanup_diagnostics: String,
#[serde(default)]
pub evidence: Vec<String>,
pub recorded_at: DateTime<Utc>,
#[serde(default)]
pub recorded_by_pid: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AcceptanceExecutionManifest {
pub schema: String,
pub change_id: String,
pub candidate_commit_oid: String,
pub candidate_tree_oid: String,
#[serde(default)]
pub review_base_commit: String,
#[serde(default)]
pub review_range: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review_base_ref: Option<String>,
pub change_input_digest: String,
#[serde(default)]
pub skill: SkillIdentity,
#[serde(default)]
pub executable_identity: String,
pub gates: Vec<ManifestGate>,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub absolute_deadline_secs: u64,
#[serde(default)]
pub work_budget_secs: u64,
}
impl AcceptanceExecutionManifest {
pub fn fingerprint(&self) -> String {
let canonical = serde_json::json!({
"schema": self.schema,
"change_id": self.change_id,
"candidate_commit_oid": self.candidate_commit_oid,
"candidate_tree_oid": self.candidate_tree_oid,
"review_base_commit": self.review_base_commit,
"review_range": self.review_range,
"change_input_digest": self.change_input_digest,
"skill_name": self.skill.name,
"skill_digest": self.skill.digest,
"executable_identity": self.executable_identity,
"gates": self
.gates
.iter()
.map(ManifestGate::fingerprint_value)
.collect::<Vec<_>>(),
});
let mut hasher = Sha256::new();
hasher.update(canonical.to_string().as_bytes());
format!("{:x}", hasher.finalize())
}
pub fn requests(&self) -> Vec<VerificationRequest> {
self.gates.iter().map(ManifestGate::request).collect()
}
pub fn has_gates(&self) -> bool {
!self.gates.is_empty()
}
pub fn to_review_json(&self) -> serde_json::Value {
serde_json::json!({
"change_id": self.change_id,
"candidate_commit": self.candidate_commit_oid,
"candidate_tree": self.candidate_tree_oid,
"review_base": self.review_base_commit,
"review_range": self.review_range,
"input_fingerprint": self.fingerprint(),
"declared_blocking_verifications": self
.gates
.iter()
.map(|gate| serde_json::json!({
"verification_id": gate.verification_id,
"command": gate.argv.join(" "),
"automation": gate.automation_path,
}))
.collect::<Vec<_>>(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestDefect {
Missing,
Unreadable(String),
Malformed(String),
UnknownSchema(String),
}
impl ManifestDefect {
pub fn code(&self) -> &'static str {
match self {
Self::Missing => "manifest_missing",
Self::Unreadable(_) => "manifest_unreadable",
Self::Malformed(_) => "manifest_malformed",
Self::UnknownSchema(_) => "manifest_unknown_schema",
}
}
pub fn detail(&self) -> String {
match self {
Self::Missing => "no runtime-authored Acceptance manifest exists".to_string(),
Self::Unreadable(detail) => format!("manifest could not be read: {detail}"),
Self::Malformed(detail) => format!("manifest is malformed: {detail}"),
Self::UnknownSchema(found) => {
format!("manifest schema '{found}' is not '{MANIFEST_SCHEMA}'")
}
}
}
}
fn is_full_length_object_id(value: &str) -> bool {
matches!(value.len(), 40 | 64) && value.chars().all(|c| c.is_ascii_hexdigit())
}
pub fn parse_manifest(bytes: &[u8]) -> Result<AcceptanceExecutionManifest, ManifestDefect> {
let value: serde_json::Value = serde_json::from_slice(bytes)
.map_err(|error| ManifestDefect::Malformed(error.to_string()))?;
let schema = value
.get("schema")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string();
if schema != MANIFEST_SCHEMA {
return Err(ManifestDefect::UnknownSchema(schema));
}
let manifest: AcceptanceExecutionManifest = serde_json::from_value(value)
.map_err(|error| ManifestDefect::Malformed(error.to_string()))?;
if !is_storable_change_id(&manifest.change_id) {
return Err(ManifestDefect::Malformed(format!(
"change id '{}' is not storable",
manifest.change_id
)));
}
for (field, value) in [
("candidate_commit_oid", &manifest.candidate_commit_oid),
("candidate_tree_oid", &manifest.candidate_tree_oid),
("change_input_digest", &manifest.change_input_digest),
] {
if !is_full_length_object_id(value) {
return Err(ManifestDefect::Malformed(format!(
"{field} '{value}' is not a full-length object id"
)));
}
}
if !manifest.review_base_commit.is_empty()
&& !is_full_length_object_id(&manifest.review_base_commit)
{
return Err(ManifestDefect::Malformed(format!(
"review_base_commit '{}' is not a full-length object id",
manifest.review_base_commit
)));
}
let mut previous: Option<&str> = None;
for gate in &manifest.gates {
if !is_storable_verification_id(&gate.verification_id) {
return Err(ManifestDefect::Malformed(format!(
"verification id '{}' is not storable",
gate.verification_id
)));
}
if gate.argv.is_empty() || gate.argv.iter().any(|arg| arg.trim().is_empty()) {
return Err(ManifestDefect::Malformed(format!(
"gate '{}' has no supervisable argv",
gate.verification_id
)));
}
if let Some(previous) = previous {
if previous >= gate.verification_id.as_str() {
return Err(ManifestDefect::Malformed(format!(
"gates are not in canonical verification-id order at '{}'",
gate.verification_id
)));
}
}
previous = Some(gate.verification_id.as_str());
}
Ok(manifest)
}
#[derive(Debug, Clone)]
pub struct ManifestStore {
root: PathBuf,
}
impl ManifestStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn path(&self) -> PathBuf {
self.root.join(MANIFEST_FILE_NAME)
}
pub fn load(&self) -> Result<AcceptanceExecutionManifest, ManifestDefect> {
let path = self.path();
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(ManifestDefect::Missing)
}
Err(error) => return Err(ManifestDefect::Unreadable(error.to_string())),
};
parse_manifest(&bytes)
}
pub fn store(&self, manifest: &AcceptanceExecutionManifest) -> std::io::Result<()> {
if !is_storable_change_id(&manifest.change_id) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("change id '{}' is not storable", manifest.change_id),
));
}
self.write_atomically(MANIFEST_FILE_NAME, &serde_json::to_vec_pretty(manifest)?)
}
pub const DIAGNOSTICS_FILE_NAME: &'static str = "diagnostics.json";
pub fn diagnostics_path(&self) -> PathBuf {
self.root.join(Self::DIAGNOSTICS_FILE_NAME)
}
pub fn record_diagnostics(&self, diagnostics: &AcceptanceDiagnostics) -> std::io::Result<()> {
self.write_atomically(
Self::DIAGNOSTICS_FILE_NAME,
&serde_json::to_vec_pretty(diagnostics)?,
)
}
fn write_atomically(&self, name: &str, serialized: &[u8]) -> std::io::Result<()> {
std::fs::create_dir_all(&self.root)?;
let temporary = self
.root
.join(format!(".{name}.tmp-{}", std::process::id()));
std::fs::write(&temporary, serialized)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o600))?;
}
match std::fs::rename(&temporary, self.root.join(name)) {
Ok(()) => Ok(()),
Err(error) => {
let _ = std::fs::remove_file(&temporary);
Err(error)
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LiveAcceptanceHold {
pub category: AcceptanceHoldCategory,
pub fingerprint: String,
pub review_base_ref: Option<String>,
}
#[derive(Debug, Default)]
pub struct LiveAcceptanceHolds {
holds: Mutex<HashMap<String, LiveAcceptanceHold>>,
}
impl LiveAcceptanceHolds {
pub fn new() -> Self {
Self::default()
}
pub fn record(&self, change_id: &str, hold: LiveAcceptanceHold) {
if !hold.category.refuses_unchanged_retry() {
self.clear(change_id);
return;
}
self.lock().insert(change_id.to_string(), hold);
}
pub fn clear(&self, change_id: &str) {
self.lock().remove(change_id);
}
pub fn get(&self, change_id: &str) -> Option<LiveAcceptanceHold> {
self.lock().get(change_id).cloned()
}
pub fn classify(&self, change_id: &str, current_fingerprint: &str) -> AcceptanceAdmission {
classify_admission(current_fingerprint, self.get(change_id).as_ref())
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, LiveAcceptanceHold>> {
self.holds
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
pub fn live_acceptance_holds() -> &'static Arc<LiveAcceptanceHolds> {
static HOLDS: OnceLock<Arc<LiveAcceptanceHolds>> = OnceLock::new();
HOLDS.get_or_init(|| Arc::new(LiveAcceptanceHolds::new()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AcceptanceAdmission {
Admit,
Refuse {
outcome: &'static str,
category: AcceptanceHoldCategory,
fingerprint: String,
components: Vec<&'static str>,
},
}
impl AcceptanceAdmission {
pub fn is_refused(&self) -> bool {
matches!(self, Self::Refuse { .. })
}
pub fn detail(&self) -> Option<String> {
match self {
Self::Admit => None,
Self::Refuse {
category,
fingerprint,
components,
..
} => Some(format!(
"Acceptance input is unchanged since the {} hold (fingerprint {}). \
A repository-visible change to one of {} restores retry eligibility.",
category.as_str(),
&fingerprint[..16.min(fingerprint.len())],
components.join(", ")
)),
}
}
pub fn to_json(&self) -> serde_json::Value {
match self {
Self::Admit => serde_json::json!({ "admitted": true }),
Self::Refuse {
outcome,
category,
fingerprint,
components,
} => serde_json::json!({
"admitted": false,
"outcome": outcome,
"category": category.as_str(),
"fingerprint": fingerprint,
"restore_eligibility_by_changing": components,
}),
}
}
}
pub fn classify_admission(
current_fingerprint: &str,
live: Option<&LiveAcceptanceHold>,
) -> AcceptanceAdmission {
let Some(live) = live else {
return AcceptanceAdmission::Admit;
};
if !live.category.refuses_unchanged_retry() {
return AcceptanceAdmission::Admit;
}
if live.fingerprint != current_fingerprint {
return AcceptanceAdmission::Admit;
}
AcceptanceAdmission::Refuse {
outcome: UNCHANGED_ACCEPTANCE_INPUT,
category: live.category,
fingerprint: current_fingerprint.to_string(),
components: FINGERPRINT_COMPONENTS.to_vec(),
}
}
pub struct WorkspaceAcceptanceAdmission<R> {
resolve_workspace: R,
skill_name: String,
state_base_dir: Option<String>,
holds: Arc<LiveAcceptanceHolds>,
}
impl<R> WorkspaceAcceptanceAdmission<R>
where
R: Fn(&str) -> Option<PathBuf> + Send + Sync,
{
pub fn new(
resolve_workspace: R,
skill_name: impl Into<String>,
state_base_dir: Option<String>,
) -> Self {
Self::with_holds(
resolve_workspace,
skill_name,
state_base_dir,
live_acceptance_holds().clone(),
)
}
pub fn with_holds(
resolve_workspace: R,
skill_name: impl Into<String>,
state_base_dir: Option<String>,
holds: Arc<LiveAcceptanceHolds>,
) -> Self {
Self {
resolve_workspace,
skill_name: skill_name.into(),
state_base_dir,
holds,
}
}
}
#[async_trait::async_trait]
impl<R> crate::orchestration::operator_command::AcceptanceAdmissionPort
for WorkspaceAcceptanceAdmission<R>
where
R: Fn(&str) -> Option<PathBuf> + Send + Sync,
{
async fn classify(&self, change_id: &str) -> AcceptanceAdmission {
let Some(live) = self.holds.get(change_id) else {
return AcceptanceAdmission::Admit;
};
let Some(workspace) = (self.resolve_workspace)(change_id) else {
return AcceptanceAdmission::Admit;
};
let project_root = super::manifest_builder::repository_project_root(&workspace)
.await
.unwrap_or_else(|| workspace.clone());
let Ok(store_root) = super::evidence_location::store_path(
self.state_base_dir.as_deref(),
&project_root,
&workspace,
change_id,
) else {
return AcceptanceAdmission::Admit;
};
let manifest = match super::manifest_builder::build_manifest_for_workspace(
&super::verification_evidence::GitRepositoryFacts,
&workspace,
&EvidenceStore::new(store_root),
change_id,
&self.skill_name,
live.review_base_ref.as_deref(),
0,
)
.await
{
Ok(manifest) => manifest,
Err(_) => return AcceptanceAdmission::Admit,
};
classify_admission(&manifest.fingerprint(), Some(&live))
}
}
pub fn is_eligible_declaration(declaration: &VerificationDeclaration) -> bool {
let phase_ok = declaration
.phase
.as_deref()
.map(str::trim)
.is_some_and(|phase| phase == PRE_INTEGRATION_PHASE);
phase_ok && eligible_request(declaration).is_ok()
}
pub fn eligible_gates(declarations: &[VerificationDeclaration]) -> Vec<VerificationRequest> {
let mut requests: Vec<VerificationRequest> = declarations
.iter()
.filter(|declaration| is_eligible_declaration(declaration))
.filter_map(|declaration| eligible_request(declaration).ok())
.collect();
requests.sort_by(|left, right| left.verification_id.cmp(&right.verification_id));
requests.dedup_by(|left, right| left.verification_id == right.verification_id);
requests
}
pub fn digest_change_inputs(inputs: &[(String, Vec<u8>)]) -> String {
let mut sorted: Vec<&(String, Vec<u8>)> = inputs.iter().collect();
sorted.sort_by(|left, right| left.0.cmp(&right.0));
let mut hasher = Sha256::new();
for (path, bytes) in sorted {
hasher.update(path.as_bytes());
hasher.update([0u8]);
hasher.update(bytes.len().to_le_bytes());
hasher.update(bytes);
}
format!("{:x}", hasher.finalize())
}
pub fn digest_text(text: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
format!("{:x}", hasher.finalize())
}
#[cfg(test)]
#[path = "execution_manifest/tests.rs"]
mod tests;