use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, LazyLock};
use anyhow::Result;
use serde::Serialize;
use sha2::{Digest, Sha256};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::{Mutex, MutexGuard};
use crate::evolution::ast_tools::{cleanup_worktree, create_shadow_worktree_named};
pub static APPLY_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ApplyStatus {
Running,
Staged,
Rejected(String),
Succeeded,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct StagedDiff {
pub digest: String,
pub files_changed: usize,
pub insertions: usize,
pub deletions: usize,
pub preview: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ApplyRun {
pub id: String,
pub prompt: String,
pub status: ApplyStatus,
pub output: String,
pub exit_code: Option<i32>,
pub shadow_path: Option<PathBuf>,
pub base_revision: Option<String>,
pub diff: Option<StagedDiff>,
}
pub type ApplyRegistry = Arc<Mutex<HashMap<String, ApplyRun>>>;
pub fn new_registry() -> ApplyRegistry {
Arc::new(Mutex::new(HashMap::new()))
}
const MAX_OUTPUT: usize = 200_000;
fn cap_run_output(output: &mut String) {
if output.len() > MAX_OUTPUT {
let cut = output.floor_char_boundary(output.len() - MAX_OUTPUT);
output.drain(..cut);
}
}
const MAX_PREVIEW: usize = 8 * 1024;
const MAX_KEPT_SHADOWS: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RejectReason {
OutOfScope(String),
Empty,
CompileFailed(String),
}
impl std::fmt::Display for RejectReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RejectReason::OutOfScope(path) => write!(f, "diff_out_of_scope: {path}"),
RejectReason::Empty => write!(f, "empty_diff"),
RejectReason::CompileFailed(stderr) => write!(f, "compile_failed: {stderr}"),
}
}
}
pub fn verify_staged_diff(
shadow_path: &Path,
base_revision: &str,
) -> std::result::Result<std::result::Result<StagedDiff, RejectReason>, git2::Error> {
let repo = git2::Repository::open(shadow_path)?;
let base = repo
.find_commit(git2::Oid::from_str(base_revision)?)?
.tree()?;
let mut index = repo.index()?;
index.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)?;
index.write()?;
let diff = repo.diff_tree_to_index(Some(&base), None, None)?;
if diff.deltas().len() == 0 {
return Ok(Err(RejectReason::Empty));
}
for delta in diff.deltas() {
let path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
if !(path.starts_with("src/") || path.starts_with("docs/")) {
return Ok(Err(RejectReason::OutOfScope(path)));
}
if delta.new_file().mode() == git2::FileMode::Link {
return Ok(Err(RejectReason::OutOfScope(format!("{path} (symlink)"))));
}
if crate::evolution::PROTECTED_PATHS
.iter()
.any(|protected| path.starts_with(protected))
{
return Ok(Err(RejectReason::OutOfScope(format!(
"{path} (protected path)"
))));
}
}
let stats = diff.stats()?;
let mut patch = Vec::new();
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
patch.push(line.origin() as u8);
patch.extend_from_slice(line.content());
true
})?;
let digest = format!("{:x}", Sha256::digest(&patch));
let preview = String::from_utf8_lossy(&patch)
.chars()
.take(MAX_PREVIEW)
.collect();
Ok(Ok(StagedDiff {
digest,
files_changed: stats.files_changed(),
insertions: stats.insertions(),
deletions: stats.deletions(),
preview,
}))
}
const CARGO_CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
const MAX_COMPILE_STDERR: usize = 2 * 1024;
enum CompileGate {
Passed,
Failed(RejectReason),
Unavailable(String),
}
async fn cargo_check_shadow(shadow_path: &Path, project_root: &Path) -> CompileGate {
let mut cmd = Command::new(crate::tools::cargo::cargo_program());
crate::safety::process_env::sanitize_command_env(&mut cmd);
cmd.arg("check")
.current_dir(shadow_path)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.kill_on_drop(true)
.env("CARGO_TARGET_DIR", project_root.join("target"));
let child = match cmd.spawn() {
Ok(child) => child,
Err(e) => {
return CompileGate::Unavailable(format!("compile gate failed to spawn cargo: {e}"));
}
};
match tokio::time::timeout(CARGO_CHECK_TIMEOUT, child.wait_with_output()).await {
Ok(Ok(output)) if output.status.success() => CompileGate::Passed,
Ok(Ok(output)) => {
let stderr: String = String::from_utf8_lossy(&output.stderr)
.chars()
.take(MAX_COMPILE_STDERR)
.collect();
CompileGate::Failed(RejectReason::CompileFailed(stderr))
}
Ok(Err(e)) => CompileGate::Unavailable(format!("compile gate cargo wait failed: {e}")),
Err(_) => CompileGate::Unavailable(format!(
"compile gate timed out after {}s (cargo killed)",
CARGO_CHECK_TIMEOUT.as_secs()
)),
}
}
#[derive(Debug)]
pub enum RunVerification {
Staged(StagedDiff),
Rejected(String),
Failed(String),
}
pub async fn finalize_run(
shadow_path: &Path,
base_revision: &str,
project_root: &Path,
) -> RunVerification {
match verify_staged_diff(shadow_path, base_revision) {
Ok(Ok(diff)) => match cargo_check_shadow(shadow_path, project_root).await {
CompileGate::Passed => RunVerification::Staged(diff),
CompileGate::Failed(reason) => RunVerification::Rejected(reason.to_string()),
CompileGate::Unavailable(note) => RunVerification::Failed(note),
},
Ok(Err(reason)) => RunVerification::Rejected(reason.to_string()),
Err(e) => RunVerification::Failed(format!("diff verification failed: {e}")),
}
}
fn prune_old_shadows(project_root: &Path, protected: &[PathBuf]) {
let worktrees_dir = project_root.join(".worktrees");
let Ok(entries) = std::fs::read_dir(&worktrees_dir) else {
return;
};
let mut shadows: Vec<(std::time::SystemTime, PathBuf)> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.is_dir()
&& !protected.contains(p)
&& p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("evolve-apply-"))
})
.filter_map(|p| {
p.metadata()
.and_then(|m| m.modified())
.ok()
.map(|mtime| (mtime, p))
})
.collect();
if shadows.len() <= MAX_KEPT_SHADOWS {
return;
}
shadows.sort_by_key(|(mtime, _)| *mtime);
let excess = shadows.len() - MAX_KEPT_SHADOWS;
for (_, path) in shadows.into_iter().take(excess) {
let _ = cleanup_worktree(project_root, &path);
}
}
#[derive(Debug, thiserror::Error)]
pub enum ApplyError {
#[error("failed to create shadow worktree: {0}")]
ShadowWorktree(#[from] crate::evolution::ast_tools::WorktreeError),
#[error("failed to read live checkout HEAD: {0}")]
BaseRevision(String),
#[error("failed to spawn agent process: {0}")]
Spawn(std::io::Error),
}
#[derive(Debug)]
pub struct StagedRun {
pub id: String,
pub shadow_path: PathBuf,
pub base_revision: String,
pub guard: MutexGuard<'static, ()>,
}
pub async fn stage_run(
prompt: String,
project_root: PathBuf,
registry: ApplyRegistry,
) -> std::result::Result<StagedRun, ApplyError> {
let guard = APPLY_LOCK.lock().await;
let id = format!("apply-{}", uuid::Uuid::new_v4().simple());
{
let registry_guard = registry.lock().await;
let protected: Vec<PathBuf> = registry_guard
.values()
.filter_map(|run| run.shadow_path.clone())
.collect();
drop(registry_guard);
prune_old_shadows(&project_root, &protected);
}
let base_revision = match read_head_oid(&project_root) {
Ok(oid) => oid,
Err(e) => {
register_failed(®istry, &id, &prompt).await;
return Err(ApplyError::BaseRevision(e.to_string()));
}
};
let shadow_path = match create_shadow_worktree_named(
&project_root,
&format!("evolve-apply-{}", id.trim_start_matches("apply-")),
) {
Ok(path) => path,
Err(e) => {
register_failed(®istry, &id, &prompt).await;
return Err(e.into());
}
};
registry.lock().await.insert(
id.clone(),
ApplyRun {
id: id.clone(),
prompt,
status: ApplyStatus::Running,
output: String::new(),
exit_code: None,
shadow_path: Some(shadow_path.clone()),
base_revision: Some(base_revision.clone()),
diff: None,
},
);
Ok(StagedRun {
id,
shadow_path,
base_revision,
guard,
})
}
fn read_head_oid(project_root: &std::path::Path) -> std::result::Result<String, git2::Error> {
let repo = git2::Repository::open(project_root)?;
let oid = repo.head()?.peel_to_commit()?.id().to_string();
Ok(oid)
}
fn shadow_config_env(project_root: &Path) -> (Option<PathBuf>, Option<String>) {
let project_config = project_root.join("selfware.toml");
if !project_config.is_file() {
return (None, None);
}
if crate::config::trust::is_config_trusted(&project_config) {
(Some(project_config), None)
} else {
(
None,
Some(format!(
"untrusted project config: child runs without it ({})",
project_config.display()
)),
)
}
}
async fn register_failed(registry: &ApplyRegistry, id: &str, prompt: &str) {
registry.lock().await.insert(
id.to_string(),
ApplyRun {
id: id.to_string(),
prompt: prompt.to_string(),
status: ApplyStatus::Failed,
output: String::new(),
exit_code: None,
shadow_path: None,
base_revision: None,
diff: None,
},
);
}
pub async fn spawn(
prompt: String,
project_root: PathBuf,
registry: ApplyRegistry,
) -> Result<String> {
let exe = std::env::current_exe()?;
let staged = stage_run(prompt.clone(), project_root.clone(), registry.clone()).await?;
let id = staged.id;
let mut command = Command::new(exe);
command
.arg("run")
.arg(&prompt)
.arg("--yolo")
.current_dir(&staged.shadow_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let (config_env, config_withheld) = shadow_config_env(&project_root);
match &config_env {
Some(path) => {
command.env("SELFWARE_CONFIG", path);
}
None => {
if config_withheld.is_some() {
command.env_remove("SELFWARE_CONFIG");
}
}
}
if let Some(line) = config_withheld {
tracing::warn!("{line}");
if let Some(run) = registry.lock().await.get_mut(&id) {
run.output.push_str(&line);
run.output.push('\n');
}
}
let mut child = match command.spawn() {
Ok(child) => child,
Err(e) => {
let _ = cleanup_worktree(&project_root, &staged.shadow_path);
if let Some(run) = registry.lock().await.get_mut(&id) {
run.status = ApplyStatus::Failed;
}
return Err(ApplyError::Spawn(e).into());
}
};
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let reg = registry.clone();
let rid = id.clone();
let shadow_for_verify = staged.shadow_path.clone();
let base_for_verify = staged.base_revision.clone();
let root_for_check = project_root.clone();
tokio::spawn(async move {
let _guard = staged.guard;
tokio::join!(
pump_pipe(stdout, reg.clone(), rid.clone()),
pump_pipe(stderr, reg.clone(), rid.clone()),
);
let code = child.wait().await.ok().and_then(|s| s.code());
let verification = if code == Some(0) {
Some(finalize_run(&shadow_for_verify, &base_for_verify, &root_for_check).await)
} else {
None
};
if let Some(run) = reg.lock().await.get_mut(&rid) {
run.exit_code = code;
match verification {
Some(RunVerification::Staged(diff)) => {
run.status = ApplyStatus::Staged;
run.diff = Some(diff);
}
Some(RunVerification::Rejected(reason)) => {
run.status = ApplyStatus::Rejected(reason);
}
Some(RunVerification::Failed(note)) => {
run.status = ApplyStatus::Failed;
run.output.push_str(&format!("\n{note}\n"));
cap_run_output(&mut run.output);
}
None => {
run.status = ApplyStatus::Failed;
}
}
}
});
Ok(id)
}
async fn pump_pipe<R>(pipe: Option<R>, reg: ApplyRegistry, rid: String)
where
R: tokio::io::AsyncRead + Unpin,
{
let Some(pipe) = pipe else { return };
let mut lines = BufReader::new(pipe).lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(run) = reg.lock().await.get_mut(&rid) {
run.output.push_str(&line);
run.output.push('\n');
cap_run_output(&mut run.output);
}
}
}
pub async fn get(registry: &ApplyRegistry, id: &str) -> Option<ApplyRun> {
registry.lock().await.get(id).cloned()
}
pub async fn remove(registry: &ApplyRegistry, id: &str, project_root: &Path) -> Option<ApplyRun> {
let run = registry.lock().await.remove(id);
if let Some(shadow) = run.as_ref().and_then(|r| r.shadow_path.as_ref()) {
let _ = cleanup_worktree(project_root, shadow);
}
run
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MergeOutcome {
pub new_head: String,
pub files_changed: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum CommitError {
#[error("unknown_run: {0}")]
UnknownRun(String),
#[error("not_staged: run is {0:?}; only Staged runs can be committed")]
NotStaged(ApplyStatus),
#[error("base_moved: staged at {base} but live HEAD is {head}; rebase required")]
BaseMoved { base: String, head: String },
#[error("merge failed: {0}")]
Git(String),
}
pub async fn commit_staged(
registry: &ApplyRegistry,
run_id: &str,
diff_digest: &str,
project_root: &Path,
) -> std::result::Result<MergeOutcome, CommitError> {
let _guard = APPLY_LOCK.lock().await;
let run = get(registry, run_id)
.await
.ok_or_else(|| CommitError::UnknownRun(run_id.to_string()))?;
if run.status != ApplyStatus::Staged {
return Err(CommitError::NotStaged(run.status));
}
let (diff, base, shadow) = match (
run.diff.as_ref(),
run.base_revision.as_ref(),
run.shadow_path.as_ref(),
) {
(Some(diff), Some(base), Some(shadow)) => (diff, base, shadow),
_ => {
return Err(CommitError::Git(format!(
"staged run {run_id} is missing its diff, base revision, or shadow path"
)))
}
};
if diff.digest != diff_digest {
return Err(CommitError::UnknownRun(run_id.to_string()));
}
let head = read_head_oid(project_root).map_err(|e| CommitError::Git(e.to_string()))?;
if head != *base {
return Err(CommitError::BaseMoved {
base: base.clone(),
head,
});
}
let recomputed = verify_staged_diff(shadow, base)
.map_err(|e| CommitError::Git(format!("failed to re-verify staged diff: {e}")))?;
let recomputed_digest = match &recomputed {
Ok(staged) => staged.digest.clone(),
Err(rejection) => {
return Err(CommitError::Git(format!(
"staged diff no longer verifies: {rejection}"
)))
}
};
if recomputed_digest != diff.digest {
return Err(CommitError::BaseMoved {
base: format!("staged diff {}", diff.digest),
head: format!("shadow now {recomputed_digest}"),
});
}
let files_changed = diff.files_changed;
let new_head = merge_shadow(shadow, project_root, base, run_id, &run.prompt)?;
remove(registry, run_id, project_root).await;
Ok(MergeOutcome {
new_head,
files_changed,
})
}
fn merge_shadow(
shadow_path: &Path,
project_root: &Path,
base_revision: &str,
run_id: &str,
prompt: &str,
) -> std::result::Result<String, CommitError> {
fn git_err(e: git2::Error) -> CommitError {
CommitError::Git(e.to_string())
}
let shadow_repo = git2::Repository::open(shadow_path).map_err(git_err)?;
{
let shadow_head = shadow_repo
.head()
.and_then(|h| h.peel_to_commit())
.map_err(git_err)?;
if shadow_head.id().to_string() != base_revision {
return Err(CommitError::BaseMoved {
base: base_revision.to_string(),
head: format!("shadow at {}", shadow_head.id()),
});
}
}
let mut index = shadow_repo.index().map_err(git_err)?;
index
.add_all(["*"].iter(), git2::IndexAddOption::DEFAULT, None)
.map_err(git_err)?;
index.write().map_err(git_err)?;
let tree_id = index.write_tree().map_err(git_err)?;
let tree = shadow_repo.find_tree(tree_id).map_err(git_err)?;
let base_commit = shadow_repo
.head()
.and_then(|head| head.peel_to_commit())
.map_err(git_err)?;
let signature = shadow_repo
.signature()
.or_else(|_| git2::Signature::now("selfware-evolve", "evolve@selfware.local"))
.map_err(git_err)?;
let summary: String = prompt
.lines()
.next()
.unwrap_or("")
.chars()
.take(72)
.collect();
let message = format!("evolve apply {run_id}: {summary}");
let new_oid = shadow_repo
.commit(
Some("HEAD"),
&signature,
&signature,
&message,
&tree,
&[&base_commit],
)
.map_err(git_err)?;
let live = git2::Repository::open(project_root).map_err(git_err)?;
let new_commit = live.find_commit(new_oid).map_err(git_err)?;
live.checkout_tree(
new_commit.as_object(),
Some(git2::build::CheckoutBuilder::new().safe()),
)
.map_err(git_err)?;
live.head()
.map_err(git_err)?
.set_target(new_oid, "evolve apply: fast-forward staged run")
.map_err(git_err)?;
Ok(new_oid.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
struct HomeGuard(Option<std::ffi::OsString>);
impl HomeGuard {
fn set(dir: &Path) -> Self {
let prev = std::env::var_os("HOME");
std::env::set_var("HOME", dir);
Self(prev)
}
}
impl Drop for HomeGuard {
fn drop(&mut self) {
match self.0.take() {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
#[test]
fn untrusted_project_config_is_not_handed_to_the_child() {
let _env = crate::test_support::EnvGuard::clear_selfware_env();
let home = tempfile::tempdir().unwrap();
let _home = HomeGuard::set(home.path());
let project = tempfile::tempdir().unwrap();
std::fs::write(
project.path().join("selfware.toml"),
"endpoint = \"https://attacker.example.com/v1\"\n",
)
.unwrap();
let (env, withheld) = shadow_config_env(project.path());
assert!(
env.is_none(),
"untrusted project config must not set SELFWARE_CONFIG"
);
let line = withheld.expect("a warning line must be produced");
assert!(
line.contains("untrusted project config"),
"warning names the cause: {line}"
);
}
#[test]
fn trusted_project_config_is_handed_to_the_child() {
let _env = crate::test_support::EnvGuard::clear_selfware_env();
let home = tempfile::tempdir().unwrap();
let _home = HomeGuard::set(home.path());
let project = tempfile::tempdir().unwrap();
let config_path = project.path().join("selfware.toml");
std::fs::write(&config_path, "endpoint = \"http://localhost:1234/v1\"\n").unwrap();
crate::config::trust::add_trusted_config(&config_path).unwrap();
let (env, withheld) = shadow_config_env(project.path());
assert_eq!(env.as_deref(), Some(config_path.as_path()));
assert!(withheld.is_none(), "trusted config needs no warning");
}
#[test]
fn missing_project_config_is_a_noop() {
let project = tempfile::tempdir().unwrap();
let (env, withheld) = shadow_config_env(project.path());
assert!(env.is_none());
assert!(withheld.is_none());
}
}