dx-driven 0.1.0

Professional AI-assisted development orchestrator with binary-first architecture
Documentation
use crate::{DrivenError, Result};
use serde::{Deserialize, Serialize};

use super::redaction::redact_secrets;
use super::{
    ClaimToken, LANE_HANDOFF_SCHEMA, LaneClaim, LaneId, OutcomeProof, PassNumber, ProofReceipt,
    WorkerId, WorktreeIdentity,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PassOutcome {
    Completed,
    Partial,
    Blocked,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NextPassHandoff {
    pub schema: String,
    pub lane: LaneId,
    pub completed_pass: PassNumber,
    pub next_pass: PassNumber,
    pub worker_id: WorkerId,
    pub from_claim: ClaimToken,
    pub receipt_id: String,
    pub outcome: PassOutcome,
    pub summary: String,
    pub next_action: String,
    pub blockers: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub payload_digest: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub worktree_identity: Option<WorktreeIdentity>,
}

impl LaneClaim {
    pub fn next_pass_handoff(
        &self,
        receipt: ProofReceipt,
        next_action: impl Into<String>,
    ) -> Result<NextPassHandoff> {
        self.validate()?;
        receipt.validate()?;
        if receipt.redacted {
            return Err(DrivenError::Validation(
                "next-pass handoff requires an unredacted proof receipt".to_string(),
            ));
        }
        if receipt.claim.lane != self.lane
            || receipt.claim.pass != self.pass
            || receipt.claim.worker_id != self.worker_id
            || receipt.claim.token != self.token
        {
            return Err(DrivenError::Validation(
                "receipt claim does not match lane claim".to_string(),
            ));
        }

        let next_action = next_action.into();
        if next_action.trim().is_empty() {
            return Err(DrivenError::Validation(
                "next-pass handoff requires a next action".to_string(),
            ));
        }

        let blockers = receipt.blockers();
        let outcome = if !blockers.is_empty() {
            PassOutcome::Blocked
        } else if receipt
            .outcomes
            .iter()
            .any(|outcome| matches!(outcome, OutcomeProof::Partial { .. }))
        {
            PassOutcome::Partial
        } else {
            PassOutcome::Completed
        };

        Ok(NextPassHandoff {
            schema: LANE_HANDOFF_SCHEMA.to_string(),
            lane: self.lane,
            completed_pass: self.pass,
            next_pass: self.pass.next()?,
            worker_id: self.worker_id.clone(),
            from_claim: self.token.clone(),
            receipt_id: receipt.receipt_id()?,
            outcome,
            summary: receipt.summary().to_string(),
            next_action,
            blockers,
            payload_digest: None,
            worktree_identity: None,
        })
    }
}

impl NextPassHandoff {
    pub fn validate(&self) -> Result<()> {
        if self.schema != LANE_HANDOFF_SCHEMA {
            return Err(DrivenError::Validation(format!(
                "handoff schema must be {}",
                LANE_HANDOFF_SCHEMA
            )));
        }
        if self.receipt_id.trim().is_empty() {
            return Err(DrivenError::Validation(
                "handoff receipt id cannot be empty".to_string(),
            ));
        }
        if self.summary.trim().is_empty() {
            return Err(DrivenError::Validation(
                "handoff summary cannot be empty".to_string(),
            ));
        }
        if self.next_action.trim().is_empty() {
            return Err(DrivenError::Validation(
                "handoff next action cannot be empty".to_string(),
            ));
        }
        if self.outcome == PassOutcome::Blocked && self.blockers.is_empty() {
            return Err(DrivenError::Validation(
                "blocked handoff requires at least one blocker".to_string(),
            ));
        }
        if self.outcome == PassOutcome::Completed && !self.blockers.is_empty() {
            return Err(DrivenError::Validation(
                "completed handoff cannot include blockers".to_string(),
            ));
        }
        if self.next_pass != self.completed_pass.next()? {
            return Err(DrivenError::Validation(
                "handoff next pass must increment exactly once".to_string(),
            ));
        }
        if let Some(payload_digest) = &self.payload_digest
            && payload_digest != &self.computed_payload_digest()?
        {
            return Err(DrivenError::Validation(
                "handoff payload digest does not match payload".to_string(),
            ));
        }
        Ok(())
    }

    pub(crate) fn validate_persisted(&self) -> Result<()> {
        match &self.payload_digest {
            Some(payload_digest) if !payload_digest.trim().is_empty() => {}
            _ => {
                return Err(DrivenError::Validation(
                    "stored handoff payload digest is required".to_string(),
                ));
            }
        }
        if self.worktree_identity.is_none() {
            return Err(DrivenError::Validation(
                "stored handoff worktree identity is required".to_string(),
            ));
        }
        self.validate()
    }

    pub(crate) fn redacted_for_persistence(&self) -> Self {
        let mut handoff = self.clone();
        handoff.payload_digest = None;
        handoff.summary = redact_secrets(&handoff.summary);
        handoff.next_action = redact_secrets(&handoff.next_action);
        for blocker in &mut handoff.blockers {
            *blocker = redact_secrets(blocker);
        }
        handoff
    }

    pub(crate) fn seal_payload_digest(&mut self) -> Result<()> {
        self.payload_digest = Some(self.computed_payload_digest()?);
        Ok(())
    }

    fn computed_payload_digest(&self) -> Result<String> {
        let mut canonical = self.clone();
        canonical.payload_digest = None;
        let payload = serde_json::to_vec(&canonical)
            .map_err(|e| DrivenError::Format(format!("failed to render handoff JSON: {}", e)))?;
        Ok(blake3::hash(&payload).to_hex().to_string())
    }

    pub fn validate_against(&self, claim: &LaneClaim) -> Result<()> {
        self.validate()?;
        claim.validate()?;
        if self.lane != claim.lane {
            return Err(DrivenError::Validation(
                "handoff lane does not match claim lane".to_string(),
            ));
        }
        if self.completed_pass != claim.pass {
            return Err(DrivenError::Validation(
                "handoff completed pass does not match claim pass".to_string(),
            ));
        }
        if self.next_pass != claim.pass.next()? {
            return Err(DrivenError::Validation(
                "handoff next pass must increment exactly once".to_string(),
            ));
        }
        if self.worker_id != claim.worker_id {
            return Err(DrivenError::Validation(
                "handoff worker does not match claim worker".to_string(),
            ));
        }
        if self.from_claim != claim.token {
            return Err(DrivenError::Validation(
                "handoff claim token does not match claim".to_string(),
            ));
        }
        Ok(())
    }
}