decapod 0.38.12

Decapod is the daemonless, local-first control plane that agents call on demand to align intent, enforce boundaries, and produce proof-backed completion across concurrent multi-agent work. 🦀
Documentation
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};

use crate::core::error;

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum WorkUnitStatus {
    Draft,
    Executing,
    Claimed,
    Verified,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct WorkUnitProofResult {
    pub gate: String,
    pub status: String,
    pub artifact_ref: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct WorkUnitManifest {
    pub task_id: String,
    pub intent_ref: String,
    pub spec_refs: Vec<String>,
    pub state_refs: Vec<String>,
    pub proof_plan: Vec<String>,
    pub proof_results: Vec<WorkUnitProofResult>,
    pub status: WorkUnitStatus,
}

impl WorkUnitManifest {
    pub fn canonicalized(&self) -> Self {
        let mut out = self.clone();

        out.spec_refs.sort();
        out.spec_refs.dedup();

        out.state_refs.sort();
        out.state_refs.dedup();

        out.proof_plan.sort();
        out.proof_plan.dedup();

        out.proof_results.sort();

        out
    }

    pub fn canonical_json_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
        serde_json::to_vec(&self.canonicalized())
    }

    pub fn canonical_hash_hex(&self) -> Result<String, serde_json::Error> {
        let bytes = self.canonical_json_bytes()?;
        let mut hasher = Sha256::new();
        hasher.update(&bytes);
        Ok(format!("{:x}", hasher.finalize()))
    }
}

pub fn workunits_dir(project_root: &Path) -> PathBuf {
    project_root
        .join(".decapod")
        .join("governance")
        .join("workunits")
}

pub fn validate_task_id(task_id: &str) -> Result<(), error::DecapodError> {
    if task_id.is_empty() {
        return Err(error::DecapodError::ValidationError(
            "task_id cannot be empty".to_string(),
        ));
    }
    if task_id
        .chars()
        .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
    {
        Ok(())
    } else {
        Err(error::DecapodError::ValidationError(format!(
            "invalid task_id '{}': allowed characters are [A-Za-z0-9_-]",
            task_id
        )))
    }
}

pub fn workunit_path(project_root: &Path, task_id: &str) -> Result<PathBuf, error::DecapodError> {
    validate_task_id(task_id)?;
    Ok(workunits_dir(project_root).join(format!("{task_id}.json")))
}

pub fn init_workunit(
    project_root: &Path,
    task_id: &str,
    intent_ref: &str,
) -> Result<WorkUnitManifest, error::DecapodError> {
    let path = workunit_path(project_root, task_id)?;
    if path.exists() {
        return Err(error::DecapodError::ValidationError(format!(
            "workunit '{}' already exists",
            task_id
        )));
    }

    let manifest = WorkUnitManifest {
        task_id: task_id.to_string(),
        intent_ref: intent_ref.to_string(),
        spec_refs: Vec::new(),
        state_refs: Vec::new(),
        proof_plan: Vec::new(),
        proof_results: Vec::new(),
        status: WorkUnitStatus::Draft,
    };
    write_workunit(project_root, &manifest)?;
    Ok(manifest)
}

pub fn load_workunit(
    project_root: &Path,
    task_id: &str,
) -> Result<WorkUnitManifest, error::DecapodError> {
    let path = workunit_path(project_root, task_id)?;
    if !path.exists() {
        return Err(error::DecapodError::NotFound(format!(
            "workunit '{}' not found at {}",
            task_id,
            path.display()
        )));
    }
    let raw = fs::read_to_string(&path).map_err(error::DecapodError::IoError)?;
    serde_json::from_str(&raw).map_err(|e| {
        error::DecapodError::ValidationError(format!(
            "invalid workunit manifest {}: {}",
            path.display(),
            e
        ))
    })
}

pub fn write_workunit(
    project_root: &Path,
    manifest: &WorkUnitManifest,
) -> Result<PathBuf, error::DecapodError> {
    let path = workunit_path(project_root, &manifest.task_id)?;
    let parent = path.parent().ok_or_else(|| {
        error::DecapodError::ValidationError("invalid workunit parent path".to_string())
    })?;
    fs::create_dir_all(parent).map_err(error::DecapodError::IoError)?;

    let bytes = serde_json::to_vec_pretty(&manifest.canonicalized()).map_err(|e| {
        error::DecapodError::ValidationError(format!("failed to serialize workunit manifest: {e}"))
    })?;
    fs::write(&path, bytes).map_err(error::DecapodError::IoError)?;
    Ok(path)
}

pub fn add_spec_ref(
    project_root: &Path,
    task_id: &str,
    spec_ref: &str,
) -> Result<WorkUnitManifest, error::DecapodError> {
    let mut manifest = load_workunit(project_root, task_id)?;
    manifest.spec_refs.push(spec_ref.to_string());
    write_workunit(project_root, &manifest)?;
    load_workunit(project_root, task_id)
}

pub fn add_state_ref(
    project_root: &Path,
    task_id: &str,
    state_ref: &str,
) -> Result<WorkUnitManifest, error::DecapodError> {
    let mut manifest = load_workunit(project_root, task_id)?;
    manifest.state_refs.push(state_ref.to_string());
    write_workunit(project_root, &manifest)?;
    load_workunit(project_root, task_id)
}

pub fn set_proof_plan(
    project_root: &Path,
    task_id: &str,
    gates: &[String],
) -> Result<WorkUnitManifest, error::DecapodError> {
    let mut manifest = load_workunit(project_root, task_id)?;
    manifest.proof_plan = gates.to_vec();
    write_workunit(project_root, &manifest)?;
    load_workunit(project_root, task_id)
}

pub fn record_proof_result(
    project_root: &Path,
    task_id: &str,
    gate: &str,
    status: &str,
    artifact_ref: Option<String>,
) -> Result<WorkUnitManifest, error::DecapodError> {
    if !matches!(status, "pass" | "fail") {
        return Err(error::DecapodError::ValidationError(format!(
            "invalid proof status '{}': expected pass|fail",
            status
        )));
    }

    let mut manifest = load_workunit(project_root, task_id)?;
    manifest.proof_results.retain(|r| r.gate != gate);
    manifest.proof_results.push(WorkUnitProofResult {
        gate: gate.to_string(),
        status: status.to_string(),
        artifact_ref,
    });
    write_workunit(project_root, &manifest)?;
    load_workunit(project_root, task_id)
}

pub fn transition_status(
    project_root: &Path,
    task_id: &str,
    to: WorkUnitStatus,
) -> Result<WorkUnitManifest, error::DecapodError> {
    let mut manifest = load_workunit(project_root, task_id)?;
    let from = manifest.status.clone();
    if !can_transition(&from, &to) {
        return Err(error::DecapodError::ValidationError(format!(
            "invalid workunit transition: {:?} -> {:?}",
            from, to
        )));
    }

    if to == WorkUnitStatus::Verified {
        ensure_verified_ready(&manifest)?;
    }

    manifest.status = to;
    write_workunit(project_root, &manifest)?;
    load_workunit(project_root, task_id)
}

pub fn validate_verified_manifest(manifest: &WorkUnitManifest) -> Result<(), error::DecapodError> {
    ensure_verified_ready(manifest)
}

fn can_transition(from: &WorkUnitStatus, to: &WorkUnitStatus) -> bool {
    use WorkUnitStatus::*;
    matches!(
        (from, to),
        (Draft, Executing)
            | (Executing, Claimed)
            | (Claimed, Verified)
            | (Executing, Draft)
            | (Draft, Draft)
            | (Executing, Executing)
            | (Claimed, Claimed)
            | (Verified, Verified)
    )
}

fn ensure_verified_ready(manifest: &WorkUnitManifest) -> Result<(), error::DecapodError> {
    if manifest.proof_plan.is_empty() {
        return Err(error::DecapodError::ValidationError(
            "cannot transition to VERIFIED without proof_plan gates".to_string(),
        ));
    }

    for gate in &manifest.proof_plan {
        let hit = manifest
            .proof_results
            .iter()
            .any(|r| &r.gate == gate && r.status == "pass");
        if !hit {
            return Err(error::DecapodError::ValidationError(format!(
                "cannot transition to VERIFIED: missing passing proof result for gate '{}'",
                gate
            )));
        }
    }

    Ok(())
}