use std::path::Path;
use chrono::Utc;
use super::execution_manifest::{
digest_change_inputs, digest_text, eligible_gates, partition_deadline,
AcceptanceExecutionManifest, ManifestGate, SkillIdentity, MANIFEST_SCHEMA,
};
use super::verification_evidence::{EvidenceStore, RepositoryFacts, ToolIdentity};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ManifestBuildError {
UnstorableChangeId(String),
Unobservable(String),
}
impl ManifestBuildError {
pub fn detail(&self) -> String {
match self {
Self::UnstorableChangeId(id) => {
format!("change id '{id}' cannot name an Acceptance store component")
}
Self::Unobservable(error) => {
format!("current repository state could not be proven: {error}")
}
}
}
}
fn collect_change_inputs(workspace: &Path, change_id: &str) -> Vec<(String, Vec<u8>)> {
let mut inputs = Vec::new();
let change_dir = workspace.join("openspec/changes").join(change_id);
fn push(inputs: &mut Vec<(String, Vec<u8>)>, relative: String, absolute: &Path) {
if let Ok(bytes) = std::fs::read(absolute) {
inputs.push((relative, bytes));
}
}
for name in ["proposal.md", "design.md", "tasks.md", "tasks.json"] {
push(
&mut inputs,
format!("openspec/changes/{change_id}/{name}"),
&change_dir.join(name),
);
}
let specs_root = change_dir.join("specs");
let mut stack = vec![specs_root.clone()];
while let Some(directory) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&directory) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(relative) = path.strip_prefix(workspace) {
let relative = relative.to_string_lossy().to_string();
if let Ok(bytes) = std::fs::read(&path) {
inputs.push((relative, bytes));
}
}
}
}
push(
&mut inputs,
"openspec/CONSTITUTION.md".to_string(),
&workspace.join("openspec/CONSTITUTION.md"),
);
inputs
}
fn executable_identity() -> String {
let version = env!("CARGO_PKG_VERSION");
match std::env::current_exe() {
Ok(path) => digest_text(&format!("{version}:{}", path.to_string_lossy())),
Err(_) => digest_text(version),
}
}
pub async fn workspace_is_repository(workspace: &Path) -> bool {
tokio::process::Command::new("git")
.args(["rev-parse", "--git-dir"])
.current_dir(workspace)
.stdin(std::process::Stdio::null())
.output()
.await
.is_ok_and(|output| output.status.success())
}
#[allow(clippy::too_many_arguments)]
pub async fn build_manifest<F: RepositoryFacts>(
facts: &F,
workspace: &Path,
store: &EvidenceStore,
change_id: &str,
declarations: &[crate::openspec::VerificationDeclaration],
skill_name: &str,
review_base: Option<&str>,
absolute_deadline_secs: u64,
) -> Result<AcceptanceExecutionManifest, ManifestBuildError> {
use super::execution_manifest::is_storable_change_id;
if !is_storable_change_id(change_id) {
return Err(ManifestBuildError::UnstorableChangeId(
change_id.to_string(),
));
}
let candidate_commit_oid = facts
.head_commit(workspace)
.await
.map_err(ManifestBuildError::Unobservable)?;
let candidate_tree_oid = facts
.head_tree(workspace)
.await
.map_err(ManifestBuildError::Unobservable)?;
let review_base_commit = match review_base {
Some(base) => facts
.resolve_revision(workspace, base)
.await
.unwrap_or_default(),
None => String::new(),
};
let review_range = if review_base_commit.is_empty() {
String::new()
} else {
format!("{review_base_commit}..{candidate_commit_oid}")
};
let mut gates = Vec::new();
for request in eligible_gates(declarations) {
let automation_blob_oid = facts
.tracked_blob_oid(workspace, &request.automation_path)
.await
.unwrap_or_default();
let tool = facts
.resolve_tool(workspace, request.argv.first().map_or("", String::as_str))
.await
.unwrap_or_else(|_| ToolIdentity::default());
let artifact_path = EvidenceStore::artifact_relative_path(&request.verification_id);
let artifact_digest = facts
.hash_file(workspace, &store.artifact_path(&request.verification_id))
.await
.ok();
gates.push(ManifestGate {
verification_id: request.verification_id,
argv: request.argv,
cwd: request.cwd,
automation_path: request.automation_path,
automation_blob_oid,
tool,
artifact_path,
artifact_digest,
});
}
let skill = SkillIdentity {
name: skill_name.to_string(),
digest: crate::embedded_skills::embedded_acceptance_skill_text(skill_name)
.map(digest_text)
.unwrap_or_default(),
};
let partition = partition_deadline(absolute_deadline_secs);
Ok(AcceptanceExecutionManifest {
schema: MANIFEST_SCHEMA.to_string(),
change_id: change_id.to_string(),
candidate_commit_oid,
candidate_tree_oid,
review_base_commit,
review_range,
review_base_ref: review_base.map(str::to_string),
change_input_digest: digest_change_inputs(&collect_change_inputs(workspace, change_id)),
skill,
executable_identity: executable_identity(),
gates,
created_at: Utc::now(),
absolute_deadline_secs: partition.absolute_secs,
work_budget_secs: partition.work_secs,
})
}
pub async fn build_manifest_for_workspace<F: RepositoryFacts>(
facts: &F,
workspace: &Path,
store: &EvidenceStore,
change_id: &str,
skill_name: &str,
review_base: Option<&str>,
absolute_deadline_secs: u64,
) -> Result<AcceptanceExecutionManifest, ManifestBuildError> {
let proposal_path = workspace
.join("openspec/changes")
.join(change_id)
.join("proposal.md");
let declarations = if proposal_path.is_file() {
crate::openspec::parse_proposal_metadata_from_file(&proposal_path).verifications
} else {
Vec::new()
};
build_manifest(
facts,
workspace,
store,
change_id,
&declarations,
skill_name,
review_base,
absolute_deadline_secs,
)
.await
}
pub async fn repository_project_root(workspace: &Path) -> Option<std::path::PathBuf> {
let output = tokio::process::Command::new("git")
.args(["rev-parse", "--git-common-dir"])
.current_dir(workspace)
.stdin(std::process::Stdio::null())
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
if raw.is_empty() {
return None;
}
let common = Path::new(&raw);
let common = if common.is_absolute() {
common.to_path_buf()
} else {
workspace.join(common)
};
common.parent().map(|parent| {
parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf())
})
}
#[cfg(test)]
#[path = "manifest_builder/tests.rs"]
mod tests;