af-workflow 0.7.1

Spec-driven workflow chassis: typed node expressions composed into a branched DAG. Port of agent_core/workflow.
Documentation
//! Versioned metadata commands; published execution contracts remain immutable.
use af_context::{InstanceId, WorkflowDefinitionId};
use serde::{Deserialize, Serialize};

/// Resource whose mutable display metadata is being changed.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "resource", rename_all = "snake_case", deny_unknown_fields)]
pub enum WorkflowTarget {
    /// A definition owned by its author or managed by an administrator.
    Definition {
        /// Definition identity.
        definition_id: WorkflowDefinitionId,
    },
    /// An instance owned by the caller.
    Instance {
        /// Instance identity.
        instance_id: InstanceId,
    },
}
/// Compare-and-set metadata command with a durable idempotency receipt.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkflowMetadataChange {
    /// Target resource.
    pub target: WorkflowTarget,
    /// Version displayed to the caller.
    pub expected_version: u64,
    /// New display name; omitted to preserve it.
    pub name: Option<String>,
    /// Archive/unarchive; omitted to preserve it. Active instances cannot be archived.
    pub archived: Option<bool>,
    /// Stable command identity, scoped to the authenticated tenant and subject.
    pub idempotency_key: String,
}
impl WorkflowMetadataChange {
    /// Reject empty patches, oversized names/keys and unrepresentable versions.
    pub fn validate(&self) -> Result<(), String> {
        if self.expected_version == 0 || self.expected_version >= i64::MAX as u64 {
            return Err("workflow metadata version must be within 1..i64::MAX".into());
        }
        if self.idempotency_key.trim().is_empty() || self.idempotency_key.len() > 256 {
            return Err("workflow metadata idempotency key requires 1..256 bytes".into());
        }
        if self.name.is_none() && self.archived.is_none() {
            return Err("workflow metadata patch is empty".into());
        }
        if self
            .name
            .as_ref()
            .is_some_and(|name| name.trim().is_empty() || name.chars().count() > 256)
        {
            return Err("workflow display name requires 1..256 characters".into());
        }
        Ok(())
    }
}
/// Receipt of one metadata command, retained unchanged for retries.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowMetadata {
    /// Resource identity.
    pub target: WorkflowTarget,
    /// Committed metadata version.
    pub version: u64,
    /// Current display name at this version.
    pub name: String,
    /// Archive state at this version.
    pub archived: bool,
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn metadata_input_is_bounded_and_has_no_identity_override() {
        let valid = WorkflowMetadataChange {
            target: WorkflowTarget::Definition {
                definition_id: "definition".parse().unwrap(),
            },
            expected_version: 1,
            name: Some("名称".into()),
            archived: None,
            idempotency_key: "key".into(),
        };
        assert!(valid.validate().is_ok());
        let mut changed = valid.clone();
        changed.name = None;
        assert!(changed.validate().is_err());
        changed.archived = Some(false);
        assert!(changed.validate().is_ok());
        for name in [" ".into(), "".repeat(257)] {
            changed.name = Some(name);
            assert!(changed.validate().is_err());
        }
        for version in [0, i64::MAX as u64, u64::MAX] {
            let mut changed = valid.clone();
            changed.expected_version = version;
            assert!(changed.validate().is_err());
        }
        for key in [" ".into(), "k".repeat(257)] {
            let mut changed = valid.clone();
            changed.idempotency_key = key;
            assert!(changed.validate().is_err());
        }
        let mut value = serde_json::to_value(&valid).unwrap();
        value["subject_id"] = "other".into();
        assert!(serde_json::from_value::<WorkflowMetadataChange>(value).is_err());
        let instance = WorkflowTarget::Instance {
            instance_id: "instance".parse().unwrap(),
        };
        assert_eq!(
            serde_json::from_value::<WorkflowTarget>(serde_json::to_value(&instance).unwrap())
                .unwrap(),
            instance
        );
    }
}