use anyhow::{Result, ensure};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{Display, Formatter};
use crate::{
GraphState, GuardExpression, Transition, TransitionEvent, WorkflowDefinition,
analysis::AnalyzedWorkflow,
};
#[derive(Clone, Debug)]
pub struct CompiledWorkflow {
pub(crate) definition: WorkflowDefinition,
state_indexes: BTreeMap<String, usize>,
transition_indexes: BTreeMap<(String, TransitionEvent), Vec<usize>>,
outgoing_indexes: BTreeMap<String, Vec<usize>>,
predecessors: BTreeMap<String, BTreeSet<String>>,
reachable: BTreeSet<String>,
}
impl CompiledWorkflow {
pub fn definition(&self) -> &WorkflowDefinition {
&self.definition
}
pub fn into_definition(self) -> WorkflowDefinition {
self.definition
}
pub fn state(&self, id: &str) -> Option<&GraphState> {
self.state_indexes
.get(id)
.map(|index| &self.definition.states[*index])
}
pub fn transitions(
&self,
from: &str,
event: TransitionEvent,
) -> impl Iterator<Item = &Transition> {
self.transition_indexes
.get(&(from.to_owned(), event))
.into_iter()
.flatten()
.map(|index| &self.definition.transitions[*index])
}
pub fn outgoing(&self, from: &str) -> impl Iterator<Item = &Transition> {
self.outgoing_indexes
.get(from)
.into_iter()
.flatten()
.map(|index| &self.definition.transitions[*index])
}
pub fn predecessors(&self, state: &str) -> &BTreeSet<String> {
static EMPTY: std::sync::LazyLock<BTreeSet<String>> =
std::sync::LazyLock::new(BTreeSet::new);
self.predecessors.get(state).unwrap_or(&EMPTY)
}
pub fn reachable(&self) -> &BTreeSet<String> {
&self.reachable
}
pub fn select_transition<'a>(
&'a self,
from: &str,
event: TransitionEvent,
payload: &serde_json::Value,
) -> Result<Option<&'a Transition>> {
let candidates = self.transitions(from, event).collect::<Vec<_>>();
let mut selected = None;
let mut fallback = None;
for transition in candidates {
match &transition.guard {
Some(guard) if guard_matches(guard, payload) => {
ensure!(selected.is_none(), "graph_guard_ambiguous_at_runtime");
selected = Some(transition);
}
Some(_) => {}
None => fallback = Some(transition),
}
}
Ok(selected.or(fallback))
}
}
pub(crate) fn compile_validated_workflow(analyzed: AnalyzedWorkflow) -> CompiledWorkflow {
let definition = analyzed.into_definition();
let state_indexes = definition
.states
.iter()
.enumerate()
.map(|(index, state)| (state.id.clone(), index))
.collect::<BTreeMap<_, _>>();
let mut transition_indexes = BTreeMap::<(String, TransitionEvent), Vec<usize>>::new();
let mut outgoing_indexes = BTreeMap::<String, Vec<usize>>::new();
let mut predecessors = BTreeMap::<String, BTreeSet<String>>::new();
for (index, transition) in definition.transitions.iter().enumerate() {
transition_indexes
.entry((transition.from.clone(), transition.event))
.or_default()
.push(index);
outgoing_indexes
.entry(transition.from.clone())
.or_default()
.push(index);
predecessors
.entry(transition.to.clone())
.or_default()
.insert(transition.from.clone());
}
let reachable = definition
.states
.iter()
.map(|state| state.id.clone())
.collect();
CompiledWorkflow {
definition,
state_indexes,
transition_indexes,
outgoing_indexes,
predecessors,
reachable,
}
}
#[cfg(test)]
fn compile_workflow(
definition: WorkflowDefinition,
) -> std::result::Result<CompiledWorkflow, crate::WorkflowValidationFailure> {
crate::compile_workflow(
definition,
&crate::SchemaId::new(crate::GENERIC_WORKFLOW_SCHEMA),
)
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct PlanIdentity {
pub compiler_semantics: &'static str,
pub engine_semantics: &'static str,
pub plan_key_schema: &'static str,
}
impl PlanIdentity {
pub const fn new(
compiler_semantics: &'static str,
engine_semantics: &'static str,
plan_key_schema: &'static str,
) -> Self {
Self {
compiler_semantics,
engine_semantics,
plan_key_schema,
}
}
}
const COMPILER_SEMANTICS_LINE: &str = "acativa.workflow.compiler";
const COMPILER_SEMANTICS_VERSION: u16 = 1;
const ENGINE_SEMANTICS_LINE: &str = "acativa.workflow.engine";
const ENGINE_SEMANTICS_VERSION: u16 = 1;
const BINDING_IDENTITY_SCHEMA: &str = "acativa.workflow.plan-key.v1";
pub const ACATIVA_PLAN_IDENTITY: PlanIdentity = PlanIdentity::new(
COMPILER_SEMANTICS_LINE,
ENGINE_SEMANTICS_LINE,
BINDING_IDENTITY_SCHEMA,
);
const MAX_ECHOED_VALUE: usize = 64;
const MAX_CAPABILITY_LENGTH: usize = 128;
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(try_from = "u16", into = "u16")]
pub struct CompilerSemantics(u16);
impl CompilerSemantics {
pub const CURRENT: Self = Self(COMPILER_SEMANTICS_VERSION);
pub fn version(
identity: &PlanIdentity,
version: u16,
) -> std::result::Result<Self, PlanInputError> {
semantics_version(
identity.compiler_semantics,
COMPILER_SEMANTICS_VERSION,
version,
)
.map(Self)
}
pub fn generic_version(version: u16) -> std::result::Result<Self, PlanInputError> {
Self::version(&ACATIVA_PLAN_IDENTITY, version)
}
pub const fn version_of(self) -> u16 {
self.0
}
pub fn wire(self, identity: &PlanIdentity) -> String {
semantics_wire(identity.compiler_semantics, self.0)
}
pub fn generic_wire(self) -> String {
self.wire(&ACATIVA_PLAN_IDENTITY)
}
pub fn from_wire(
identity: &PlanIdentity,
value: &str,
) -> std::result::Result<Self, PlanInputError> {
parse_semantics_wire(
identity.compiler_semantics,
COMPILER_SEMANTICS_VERSION,
value,
)
.map(Self)
}
pub fn generic_from_wire(value: &str) -> std::result::Result<Self, PlanInputError> {
Self::from_wire(&ACATIVA_PLAN_IDENTITY, value)
}
}
impl From<CompilerSemantics> for u16 {
fn from(semantics: CompilerSemantics) -> Self {
semantics.0
}
}
impl TryFrom<u16> for CompilerSemantics {
type Error = PlanInputError;
fn try_from(version: u16) -> std::result::Result<Self, Self::Error> {
Self::generic_version(version)
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(try_from = "u16", into = "u16")]
pub struct EngineSemantics(u16);
impl EngineSemantics {
pub const CURRENT: Self = Self(ENGINE_SEMANTICS_VERSION);
pub fn version(
identity: &PlanIdentity,
version: u16,
) -> std::result::Result<Self, PlanInputError> {
semantics_version(identity.engine_semantics, ENGINE_SEMANTICS_VERSION, version).map(Self)
}
pub fn generic_version(version: u16) -> std::result::Result<Self, PlanInputError> {
Self::version(&ACATIVA_PLAN_IDENTITY, version)
}
pub const fn version_of(self) -> u16 {
self.0
}
pub fn wire(self, identity: &PlanIdentity) -> String {
semantics_wire(identity.engine_semantics, self.0)
}
pub fn generic_wire(self) -> String {
self.wire(&ACATIVA_PLAN_IDENTITY)
}
pub fn from_wire(
identity: &PlanIdentity,
value: &str,
) -> std::result::Result<Self, PlanInputError> {
parse_semantics_wire(identity.engine_semantics, ENGINE_SEMANTICS_VERSION, value).map(Self)
}
pub fn generic_from_wire(value: &str) -> std::result::Result<Self, PlanInputError> {
Self::from_wire(&ACATIVA_PLAN_IDENTITY, value)
}
}
impl From<EngineSemantics> for u16 {
fn from(semantics: EngineSemantics) -> Self {
semantics.0
}
}
impl TryFrom<u16> for EngineSemantics {
type Error = PlanInputError;
fn try_from(version: u16) -> std::result::Result<Self, Self::Error> {
Self::generic_version(version)
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(try_from = "String", into = "String")]
pub struct DefinitionRevision(String);
impl DefinitionRevision {
pub fn of(definition: &WorkflowDefinition) -> std::result::Result<Self, PlanInputError> {
let bytes = serde_json::to_vec(definition).map_err(PlanInputError::PlanEncoding)?;
Ok(Self(sha256_hex(&bytes)))
}
pub fn from_wire(value: &str) -> std::result::Result<Self, PlanInputError> {
let hex = value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
if !hex {
return Err(PlanInputError::DefinitionRevision {
value: clipped(value).to_owned(),
});
}
Ok(Self(value.to_owned()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for DefinitionRevision {
type Error = PlanInputError;
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
Self::from_wire(&value)
}
}
impl From<DefinitionRevision> for String {
fn from(revision: DefinitionRevision) -> Self {
revision.0
}
}
#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(try_from = "Vec<String>", into = "Vec<String>")]
pub struct LoweringCapabilities(BTreeSet<String>);
impl LoweringCapabilities {
pub fn none() -> Self {
Self(BTreeSet::new())
}
pub fn declare<I, S>(tags: I) -> std::result::Result<Self, PlanInputError>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut declared = BTreeSet::new();
for tag in tags {
declared.insert(validate_capability(tag.as_ref())?.to_owned());
}
Ok(Self(declared))
}
pub fn contains(&self, tag: &str) -> bool {
self.0.contains(tag)
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = &str> {
self.0.iter().map(String::as_str)
}
pub fn missing_from<'a>(&'a self, other: &Self) -> Option<&'a str> {
self.0
.iter()
.find(|tag| !other.0.contains(*tag))
.map(String::as_str)
}
}
impl TryFrom<Vec<String>> for LoweringCapabilities {
type Error = PlanInputError;
fn try_from(values: Vec<String>) -> std::result::Result<Self, Self::Error> {
Self::declare(values)
}
}
impl From<LoweringCapabilities> for Vec<String> {
fn from(capabilities: LoweringCapabilities) -> Self {
capabilities.0.into_iter().collect()
}
}
#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PlanKey {
definition_revision: DefinitionRevision,
compiler_semantics: CompilerSemantics,
engine_semantics: EngineSemantics,
lowering_capabilities: LoweringCapabilities,
}
impl PlanKey {
pub fn new(
definition_revision: DefinitionRevision,
compiler_semantics: CompilerSemantics,
engine_semantics: EngineSemantics,
lowering_capabilities: LoweringCapabilities,
) -> Self {
Self {
definition_revision,
compiler_semantics,
engine_semantics,
lowering_capabilities,
}
}
pub fn for_definition(
definition: &WorkflowDefinition,
compiler_semantics: CompilerSemantics,
engine_semantics: EngineSemantics,
lowering_capabilities: LoweringCapabilities,
) -> std::result::Result<Self, PlanInputError> {
Ok(Self::new(
DefinitionRevision::of(definition)?,
compiler_semantics,
engine_semantics,
lowering_capabilities,
))
}
pub fn definition_revision(&self) -> &DefinitionRevision {
&self.definition_revision
}
pub fn compiler_semantics(&self) -> CompilerSemantics {
self.compiler_semantics
}
pub fn engine_semantics(&self) -> EngineSemantics {
self.engine_semantics
}
pub fn lowering_capabilities(&self) -> &LoweringCapabilities {
&self.lowering_capabilities
}
pub fn canonical_bytes(&self, identity: &PlanIdentity) -> Vec<u8> {
let capabilities = self
.lowering_capabilities
.iter()
.collect::<Vec<_>>()
.join(",");
format!(
"{}\0revision\0{}\0compiler\0{}\0engine\0{}\0capabilities\0{}\0",
identity.plan_key_schema,
self.definition_revision.as_str(),
self.compiler_semantics.version_of(),
self.engine_semantics.version_of(),
capabilities
)
.into_bytes()
}
pub fn binding_digest(&self, identity: &PlanIdentity) -> String {
sha256_hex(&self.canonical_bytes(identity))
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct RecordedPlanKey {
pub definition_revision: String,
pub compiler_semantics: String,
pub engine_semantics: String,
pub lowering_capabilities: Vec<String>,
}
impl RecordedPlanKey {
pub fn record(key: &PlanKey, identity: &PlanIdentity) -> Self {
Self {
definition_revision: key.definition_revision().as_str().to_owned(),
compiler_semantics: key.compiler_semantics().wire(identity),
engine_semantics: key.engine_semantics().wire(identity),
lowering_capabilities: key
.lowering_capabilities()
.iter()
.map(str::to_owned)
.collect(),
}
}
pub fn typed(&self, identity: &PlanIdentity) -> std::result::Result<PlanKey, PlanInputError> {
Ok(PlanKey::new(
DefinitionRevision::from_wire(&self.definition_revision)?,
CompilerSemantics::from_wire(identity, &self.compiler_semantics)?,
EngineSemantics::from_wire(identity, &self.engine_semantics)?,
LoweringCapabilities::declare(self.lowering_capabilities.iter().map(String::as_str))?,
))
}
}
impl From<&PlanKey> for RecordedPlanKey {
fn from(key: &PlanKey) -> Self {
Self::record(key, &ACATIVA_PLAN_IDENTITY)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InterpreterProfile {
compiler_semantics: CompilerSemantics,
engine_semantics: EngineSemantics,
lowering_capabilities: LoweringCapabilities,
}
impl InterpreterProfile {
pub fn new(
compiler_semantics: CompilerSemantics,
engine_semantics: EngineSemantics,
lowering_capabilities: LoweringCapabilities,
) -> Self {
Self {
compiler_semantics,
engine_semantics,
lowering_capabilities,
}
}
pub fn current(lowering_capabilities: LoweringCapabilities) -> Self {
Self::new(
CompilerSemantics::CURRENT,
EngineSemantics::CURRENT,
lowering_capabilities,
)
}
pub fn compiler_semantics(&self) -> CompilerSemantics {
self.compiler_semantics
}
pub fn engine_semantics(&self) -> EngineSemantics {
self.engine_semantics
}
pub fn lowering_capabilities(&self) -> &LoweringCapabilities {
&self.lowering_capabilities
}
pub fn key_for(&self, definition_revision: DefinitionRevision) -> PlanKey {
PlanKey::new(
definition_revision,
self.compiler_semantics,
self.engine_semantics,
self.lowering_capabilities.clone(),
)
}
pub fn lowerable(&self, key: &PlanKey) -> std::result::Result<(), LoweringRefusal> {
if key.compiler_semantics() != self.compiler_semantics {
return Err(LoweringRefusal::CompilerSemantics {
build: self.compiler_semantics,
bound: key.compiler_semantics(),
});
}
match key
.lowering_capabilities()
.missing_from(&self.lowering_capabilities)
{
Some(capability) => Err(LoweringRefusal::MissingCapability {
capability: capability.to_owned(),
}),
None => Ok(()),
}
}
pub fn admit_run(&self, bound: &PlanKey) -> RunAdmission {
if let Some(capability) = bound
.lowering_capabilities()
.missing_from(&self.lowering_capabilities)
{
return RunAdmission::MissingCapability {
capability: capability.to_owned(),
};
}
if bound.compiler_semantics() != self.compiler_semantics {
return RunAdmission::Handoff {
reason: HandoffReason::CompilerSemantics,
};
}
if bound.engine_semantics() != self.engine_semantics {
return RunAdmission::Handoff {
reason: HandoffReason::EngineSemantics,
};
}
RunAdmission::Compatible
}
pub fn admit_recorded(
&self,
recorded: &RecordedPlanKey,
identity: &PlanIdentity,
) -> RunAdmission {
match recorded.typed(identity) {
Ok(key) => self.admit_run(&key),
Err(_) => RunAdmission::Handoff {
reason: HandoffReason::UnprovenSemantics,
},
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LoweringRefusal {
CompilerSemantics {
build: CompilerSemantics,
bound: CompilerSemantics,
},
MissingCapability { capability: String },
}
impl Display for LoweringRefusal {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::CompilerSemantics { build, bound } => write!(
formatter,
"plan_compiler_semantics_mismatch: build {} bound {}",
build.version_of(),
bound.version_of()
),
Self::MissingCapability { capability } => {
write!(formatter, "plan_capability_missing: {capability}")
}
}
}
}
impl std::error::Error for LoweringRefusal {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum RunAdmission {
Compatible,
MissingCapability {
capability: String,
},
Handoff {
reason: HandoffReason,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HandoffReason {
CompilerSemantics,
EngineSemantics,
UnprovenSemantics,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RetainedPlan {
definition: WorkflowDefinition,
key: PlanKey,
}
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct RetainedPlanWire {
definition: WorkflowDefinition,
key: PlanKey,
}
impl RetainedPlan {
pub fn of(
compiled: &CompiledWorkflow,
key: PlanKey,
) -> std::result::Result<Self, PlanMismatch> {
Self::pair(compiled.definition().clone(), key)
}
pub fn definition(&self) -> &WorkflowDefinition {
&self.definition
}
pub fn key(&self) -> &PlanKey {
&self.key
}
pub fn binding_digest(&self, identity: &PlanIdentity) -> String {
self.key.binding_digest(identity)
}
pub fn to_wire_bytes(&self) -> std::result::Result<Vec<u8>, PlanMismatch> {
serde_json::to_vec(&RetainedPlanWire {
definition: self.definition.clone(),
key: self.key.clone(),
})
.map_err(|error| PlanMismatch::from(PlanInputError::PlanEncoding(error)))
}
pub fn retained_bytes(&self) -> std::result::Result<usize, PlanMismatch> {
self.to_wire_bytes().map(|bytes| bytes.len())
}
pub fn from_wire_bytes(bytes: &[u8]) -> std::result::Result<Self, PlanMismatch> {
let wire: RetainedPlanWire = serde_json::from_slice(bytes)
.map_err(|error| PlanMismatch::from(PlanInputError::PlanEncoding(error)))?;
Self::pair(wire.definition, wire.key)
}
pub fn rebuild(
&self,
supported_schema: &crate::SchemaId,
) -> std::result::Result<CompiledWorkflow, crate::WorkflowValidationFailure> {
crate::compile_workflow(self.definition.clone(), supported_schema)
}
fn pair(
definition: WorkflowDefinition,
key: PlanKey,
) -> std::result::Result<Self, PlanMismatch> {
let actual = DefinitionRevision::of(&definition)?;
if actual != *key.definition_revision() {
return Err(PlanMismatch::RevisionDrift {
recorded: key.definition_revision().as_str().to_owned(),
actual: actual.as_str().to_owned(),
});
}
Ok(Self { definition, key })
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PlanMismatch {
RevisionDrift { recorded: String, actual: String },
PlanEncoding { message: String },
}
#[derive(Debug)]
pub enum PlanInputError {
SemanticsVersion { line: &'static str, value: String },
DefinitionRevision { value: String },
LoweringCapability { value: String },
PlanEncoding(serde_json::Error),
}
impl Display for PlanInputError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::SemanticsVersion { line, value } => {
write!(
formatter,
"plan_semantics_version_invalid: {line} does not own {value}"
)
}
Self::DefinitionRevision { value } => {
write!(formatter, "plan_definition_revision_invalid: {value}")
}
Self::LoweringCapability { value } => {
write!(formatter, "plan_lowering_capability_invalid: {value}")
}
Self::PlanEncoding(error) => write!(formatter, "plan_encoding_invalid: {error}"),
}
}
}
impl std::error::Error for PlanInputError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::PlanEncoding(error) => Some(error),
_ => None,
}
}
}
impl Display for PlanMismatch {
fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::RevisionDrift { recorded, actual } => {
write!(
formatter,
"plan_revision_drift: recorded {recorded} actual {actual}"
)
}
Self::PlanEncoding { message } => write!(formatter, "plan_encoding_invalid: {message}"),
}
}
}
impl std::error::Error for PlanMismatch {}
impl From<PlanInputError> for PlanMismatch {
fn from(error: PlanInputError) -> Self {
Self::PlanEncoding {
message: error.to_string(),
}
}
}
fn semantics_version(
line: &'static str,
current: u16,
version: u16,
) -> std::result::Result<u16, PlanInputError> {
if version > current {
return Err(PlanInputError::SemanticsVersion {
line,
value: version.to_string(),
});
}
Ok(version)
}
fn semantics_wire(line: &str, version: u16) -> String {
format!("{line}.v{version}")
}
fn parse_semantics_wire(
line: &'static str,
current: u16,
value: &str,
) -> std::result::Result<u16, PlanInputError> {
let invalid = || PlanInputError::SemanticsVersion {
line,
value: clipped(value).to_owned(),
};
let version = value
.strip_prefix(line)
.and_then(|rest| rest.strip_prefix(".v"))
.and_then(|version| version.parse::<u16>().ok())
.ok_or_else(invalid)?;
semantics_version(line, current, version).map_err(|_| invalid())
}
fn validate_capability(tag: &str) -> std::result::Result<&str, PlanInputError> {
let invalid = || PlanInputError::LoweringCapability {
value: clipped(tag).to_owned(),
};
if tag.is_empty() || tag.len() > MAX_CAPABILITY_LENGTH {
return Err(invalid());
}
let Some((namespace, name)) = tag.split_once('/') else {
return Err(invalid());
};
if !valid_namespace(namespace) || !valid_capability_name(name) {
return Err(invalid());
}
Ok(tag)
}
fn valid_namespace(namespace: &str) -> bool {
namespace.contains('.')
&& namespace.split('.').all(|part| {
!part.is_empty()
&& part
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
})
}
fn valid_capability_name(name: &str) -> bool {
!name.is_empty()
&& name.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
})
}
fn clipped(value: &str) -> &str {
match value.char_indices().nth(MAX_ECHOED_VALUE) {
Some((index, _)) => &value[..index],
None => value,
}
}
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn guard_matches(guard: &GuardExpression, payload: &serde_json::Value) -> bool {
let value = guard
.path
.split('.')
.filter(|part| !part.is_empty())
.try_fold(payload, |value, part| value.get(part));
if guard.exists && value.is_none() {
return false;
}
guard
.equals
.as_ref()
.is_none_or(|expected| value == Some(expected))
}
pub(super) fn valid_instruction(value: &str) -> bool {
value == value.trim()
&& !value.is_empty()
&& value.len() <= 16 * 1024
&& !value
.chars()
.any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
ActorSlot, GraphStateKind, RetryPolicy, TransitionMode, WorkflowLimits, WorkflowMetadata,
};
use serde_json::Value;
fn state(id: &str, kind: GraphStateKind) -> GraphState {
GraphState {
id: id.into(),
kind,
label: id.into(),
instruction: String::new(),
binding: None,
runtime: None,
entry: None,
workset: None,
retry: RetryPolicy::default(),
}
}
fn workflow(states: Vec<GraphState>, transitions: Vec<Transition>) -> WorkflowDefinition {
WorkflowDefinition {
schema: crate::GENERIC_WORKFLOW_SCHEMA.into(),
metadata: WorkflowMetadata {
id: "test.workflow".into(),
name: "Test".into(),
version: "1".into(),
description: String::new(),
},
limits: WorkflowLimits::default(),
actor_slots: vec![],
runtimes: vec![],
worksets: vec![],
initial: states[0].id.clone(),
states,
transitions,
}
}
#[test]
fn compiles_pipeline_in_linear_time_indexes() {
let compiled = compile_workflow(workflow(
vec![
state("start", GraphStateKind::Pass),
state("done", GraphStateKind::Succeed),
],
vec![Transition {
id: "finish".into(),
from: "start".into(),
to: "done".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
}],
))
.unwrap();
assert_eq!(compiled.reachable().len(), 2);
assert_eq!(
compiled
.transitions("start", TransitionEvent::Complete)
.count(),
1
);
}
#[test]
fn transition_mode_defaults_to_flow_and_rejects_unknown_values() {
let transition = |mode: Option<&str>| {
let mut value = serde_json::json!({
"id": "next",
"from": "start",
"to": "done",
"event": "complete"
});
if let Some(mode) = mode {
value["mode"] = mode.into();
}
serde_json::from_value::<Transition>(value)
};
assert_eq!(transition(None).unwrap().mode, TransitionMode::Flow);
assert_eq!(transition(Some("flow")).unwrap().mode, TransitionMode::Flow);
assert_eq!(
transition(Some("callback")).unwrap().mode,
TransitionMode::Callback
);
assert!(transition(Some("warp")).is_err(), "unknown mode decodes");
let serialized = serde_json::to_value(transition(None).unwrap()).unwrap();
assert!(serialized.get("mode").is_none());
let serialized = serde_json::to_value(transition(Some("callback")).unwrap()).unwrap();
assert_eq!(serialized["mode"], "callback");
}
#[test]
fn flow_mode_targets_may_not_leave_actor_binding_empty() {
let build = |entry_mode: TransitionMode| {
let mut definition = workflow(
vec![
state("start", GraphStateKind::Pass),
state("review", GraphStateKind::Actor),
state("done", GraphStateKind::Succeed),
],
vec![
Transition {
id: "begin".into(),
from: "start".into(),
to: "review".into(),
event: TransitionEvent::Complete,
mode: entry_mode,
guard: None,
},
Transition {
id: "reviewed".into(),
from: "review".into(),
to: "done".into(),
event: TransitionEvent::Success,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "review-failed".into(),
from: "review".into(),
to: "done".into(),
event: TransitionEvent::Failure,
mode: TransitionMode::Flow,
guard: None,
},
],
);
definition.actor_slots = vec![ActorSlot::required_actor("worker", "Worker")];
definition
};
assert!(compile_workflow(build(TransitionMode::Callback)).is_ok());
let error = compile_workflow(build(TransitionMode::Flow)).unwrap_err();
assert!(
error.diagnostics.iter().any(|diagnostic| {
diagnostic.code == crate::WorkflowDiagnosticCode::WorkflowFlowTargetIncomplete
}),
"flow target with empty binding rejected with the rule: {error}"
);
let mut initial_actor = build(TransitionMode::Callback);
initial_actor.initial = "review".into();
let error = compile_workflow(initial_actor).unwrap_err();
assert!(
error.diagnostics.iter().any(|diagnostic| {
diagnostic.code == crate::WorkflowDiagnosticCode::WorkflowFlowTargetIncomplete
}),
"the initial state is flow-entered: {error}"
);
}
#[test]
fn fork_branch_edges_must_stay_flow_mode() {
let mut definition = workflow(
vec![
state("fork", GraphStateKind::Fork),
state("branch-a", GraphStateKind::Pass),
state("branch-b", GraphStateKind::Pass),
state("join", GraphStateKind::Join),
state("done", GraphStateKind::Succeed),
],
vec![
Transition {
id: "fa".into(),
from: "fork".into(),
to: "branch-a".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Callback,
guard: None,
},
Transition {
id: "fb".into(),
from: "fork".into(),
to: "branch-b".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "aj".into(),
from: "branch-a".into(),
to: "join".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "bj".into(),
from: "branch-b".into(),
to: "join".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "jd".into(),
from: "join".into(),
to: "done".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
],
);
let error = compile_workflow(definition.clone()).unwrap_err();
assert!(
error
.to_string()
.contains("workflow_transition_mode_invalid"),
"callback fan-out rejected: {error}"
);
definition.transitions[0].mode = TransitionMode::Flow;
assert!(compile_workflow(definition).is_ok());
}
#[test]
fn actor_graphs_require_exactly_one_declared_entry() {
let mut definition = workflow(
vec![
state("start", GraphStateKind::Pass),
state("done", GraphStateKind::Succeed),
],
vec![Transition {
id: "finish".into(),
from: "start".into(),
to: "done".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
}],
);
definition.actor_slots = vec![ActorSlot::required_actor("entry", "Entry"), {
let mut slot = ActorSlot::required_actor("worker-a", "Worker");
slot.entry = false;
slot
}];
definition.actor_slots[0].entry = false;
assert!(compile_workflow(definition.clone()).is_err());
definition.actor_slots[1].entry = true;
definition.actor_slots[0].entry = true;
assert!(compile_workflow(definition).is_err());
}
#[test]
fn rejects_effect_free_cycle() {
let result = compile_workflow(workflow(
vec![
state("first", GraphStateKind::Choice),
state("second", GraphStateKind::Choice),
state("done", GraphStateKind::Succeed),
],
vec![
Transition {
id: "a".into(),
from: "first".into(),
to: "second".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "b".into(),
from: "second".into(),
to: "first".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: Some(GuardExpression {
path: "loop".into(),
equals: Some(true.into()),
exists: false,
}),
},
Transition {
id: "c".into(),
from: "second".into(),
to: "done".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
],
));
assert!(
result
.unwrap_err()
.to_string()
.contains("workflow_effect_cycle")
);
}
#[test]
fn rejects_unknown_transition_events() {
let json = serde_json::json!({
"schema": crate::GENERIC_WORKFLOW_SCHEMA,
"metadata": {
"id": "test.workflow",
"name": "Test",
"version": "1",
"description": ""
},
"limits": {},
"actorSlots": [],
"runtimes": [],
"worksets": [],
"initial": "start",
"states": [
{"id": "start", "kind": "pass", "label": "start", "retry": {}},
{"id": "done", "kind": "succeed", "label": "done", "retry": {}}
],
"transitions": [
{"id": "next", "from": "start", "to": "done", "event": "jump"}
]
});
let decoded = serde_json::from_value::<WorkflowDefinition>(json);
assert!(decoded.is_err(), "unknown event decoded: {decoded:?}");
let definition = serde_json::from_value::<WorkflowDefinition>(serde_json::json!({
"schema": crate::GENERIC_WORKFLOW_SCHEMA,
"metadata": {
"id": "test.workflow",
"name": "Test",
"version": "1",
"description": ""
},
"limits": {},
"actorSlots": [],
"runtimes": [],
"worksets": [],
"initial": "start",
"states": [
{"id": "start", "kind": "pass", "label": "start", "retry": {}},
{"id": "done", "kind": "succeed", "label": "done", "retry": {}}
],
"transitions": [
{"id": "next", "from": "start", "to": "done", "event": "complete"}
]
}))
.unwrap();
assert_eq!(definition.transitions[0].event, TransitionEvent::Complete);
}
#[test]
fn guard_partitions_require_fallback_and_same_path_equality() {
let choice = |guards: Vec<GuardExpression>| {
let mut transitions = guards
.into_iter()
.enumerate()
.map(|(index, guard)| Transition {
id: format!("guard-{index}"),
from: "pick".into(),
to: "done".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: Some(guard),
})
.collect::<Vec<_>>();
transitions.push(Transition {
id: "fallback".into(),
from: "pick".into(),
to: "done".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
});
workflow(
vec![
state("pick", GraphStateKind::Choice),
state("done", GraphStateKind::Succeed),
],
transitions,
)
};
let guard = |path: &str, value: Option<Value>, exists: bool| GuardExpression {
path: path.into(),
equals: value,
exists,
};
assert!(compile_workflow(choice(vec![guard("mode", Some("fast".into()), false)])).is_ok());
assert!(
compile_workflow(choice(vec![
guard("mode", Some("fast".into()), false),
guard("other", Some("fast".into()), false),
]))
.unwrap_err()
.to_string()
.contains("workflow_guard_ambiguous")
);
assert!(
compile_workflow(choice(vec![
guard("mode", Some("fast".into()), false),
guard("mode", Some("fast".into()), false),
]))
.unwrap_err()
.to_string()
.contains("workflow_guard_ambiguous")
);
assert!(
compile_workflow(choice(vec![
guard("mode", Some("fast".into()), false),
guard("mode", None, true),
]))
.unwrap_err()
.to_string()
.contains("workflow_guard_ambiguous")
);
let missing_fallback = workflow(
vec![
state("pick", GraphStateKind::Choice),
state("done", GraphStateKind::Succeed),
],
vec![Transition {
id: "only-guard".into(),
from: "pick".into(),
to: "done".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: Some(guard("mode", Some("fast".into()), false)),
}],
);
assert!(
compile_workflow(missing_fallback)
.unwrap_err()
.to_string()
.contains("workflow_guard_ambiguous")
);
}
#[test]
fn effect_states_require_total_success_and_failure_routing() {
let mut definition = workflow(
vec![
state("plan", GraphStateKind::Actor),
state("done", GraphStateKind::Succeed),
],
vec![Transition {
id: "plan-ready".into(),
from: "plan".into(),
to: "done".into(),
event: TransitionEvent::Success,
mode: TransitionMode::Flow,
guard: None,
}],
);
definition.actor_slots = vec![ActorSlot::required_actor("entry", "Entry")];
definition.states[0].binding = Some("entry".into());
assert!(
compile_workflow(definition.clone())
.unwrap_err()
.to_string()
.contains("workflow_routing_invalid")
);
definition.transitions.push(Transition {
id: "plan-failed".into(),
from: "plan".into(),
to: "done".into(),
event: TransitionEvent::Failure,
mode: TransitionMode::Flow,
guard: None,
});
assert!(compile_workflow(definition).is_ok());
}
#[test]
fn structured_fork_join_regions_compile() {
let result = compile_workflow(workflow(
vec![
state("fork", GraphStateKind::Fork),
state("branch-a", GraphStateKind::Pass),
state("branch-b", GraphStateKind::Pass),
state("join", GraphStateKind::Join),
state("done", GraphStateKind::Succeed),
],
vec![
Transition {
id: "fa".into(),
from: "fork".into(),
to: "branch-a".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "fb".into(),
from: "fork".into(),
to: "branch-b".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "aj".into(),
from: "branch-a".into(),
to: "join".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "bj".into(),
from: "branch-b".into(),
to: "join".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "jd".into(),
from: "join".into(),
to: "done".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
],
));
assert!(result.is_ok(), "structured fork/join rejected: {result:?}");
}
#[test]
fn malformed_parallel_regions_are_rejected() {
let base_states = || {
vec![
state("fork", GraphStateKind::Fork),
state("branch-a", GraphStateKind::Pass),
state("branch-b", GraphStateKind::Pass),
state("join", GraphStateKind::Join),
state("done", GraphStateKind::Succeed),
]
};
let base_edges = || {
vec![
Transition {
id: "fa".into(),
from: "fork".into(),
to: "branch-a".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "fb".into(),
from: "fork".into(),
to: "branch-b".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "aj".into(),
from: "branch-a".into(),
to: "join".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "bj".into(),
from: "branch-b".into(),
to: "join".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "jd".into(),
from: "join".into(),
to: "done".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
]
};
let assert_rejected = |definition: WorkflowDefinition| {
let states = definition
.states
.iter()
.map(|state| state.id.clone())
.collect::<Vec<_>>();
let error = compile_workflow(definition).unwrap_err().to_string();
assert!(
error.contains("workflow_topology_invalid"),
"malformed region accepted with error: {error}; states: {states:?}"
);
};
let mut missing_join = base_states();
missing_join[4] = state("done", GraphStateKind::Succeed);
let mut edges = base_edges();
edges[2] = Transition {
id: "ad".into(),
from: "branch-a".into(),
to: "done".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
};
assert_rejected(workflow(missing_join, edges));
let shared = base_states();
let mut edges = base_edges();
edges[3] = Transition {
id: "ba".into(),
from: "branch-b".into(),
to: "branch-a".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
};
assert_rejected(workflow(shared, edges));
let mut nested = base_states();
nested.insert(3, state("nested-fork", GraphStateKind::Fork));
let mut edges = base_edges();
edges[1] = Transition {
id: "fn".into(),
from: "fork".into(),
to: "nested-fork".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
};
edges.push(Transition {
id: "nj".into(),
from: "nested-fork".into(),
to: "join".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
});
edges.push(Transition {
id: "nb".into(),
from: "nested-fork".into(),
to: "branch-b".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
});
assert_rejected(workflow(nested, edges));
let mut terminal_branch = base_states();
terminal_branch[2] = state("branch-terminal", GraphStateKind::Succeed);
let mut edges = base_edges();
edges[1] = Transition {
id: "ft".into(),
from: "fork".into(),
to: "branch-terminal".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
};
edges.remove(3);
assert_rejected(workflow(terminal_branch, edges));
let cyclic = base_states();
let mut edges = base_edges();
edges[2] = Transition {
id: "ab".into(),
from: "branch-a".into(),
to: "branch-b".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
};
edges[3] = Transition {
id: "ba".into(),
from: "branch-b".into(),
to: "branch-a".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
};
assert_rejected(workflow(cyclic, edges));
let extra_predecessor = vec![
state("choice", GraphStateKind::Choice),
state("fork", GraphStateKind::Fork),
state("branch-a", GraphStateKind::Pass),
state("branch-b", GraphStateKind::Pass),
state("join", GraphStateKind::Join),
state("extra", GraphStateKind::Pass),
state("done", GraphStateKind::Succeed),
];
let edges = vec![
Transition {
id: "cf".into(),
from: "choice".into(),
to: "fork".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: Some(GuardExpression {
path: "mode".into(),
equals: Some("parallel".into()),
exists: false,
}),
},
Transition {
id: "ce".into(),
from: "choice".into(),
to: "extra".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "fa".into(),
from: "fork".into(),
to: "branch-a".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "fb".into(),
from: "fork".into(),
to: "branch-b".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "aj".into(),
from: "branch-a".into(),
to: "join".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "bj".into(),
from: "branch-b".into(),
to: "join".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "ej".into(),
from: "extra".into(),
to: "join".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "jd".into(),
from: "join".into(),
to: "done".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
];
assert_rejected(workflow(extra_predecessor, edges));
let fork_only = workflow(
vec![
state("fork", GraphStateKind::Fork),
state("branch-a", GraphStateKind::Pass),
state("done", GraphStateKind::Succeed),
],
vec![
Transition {
id: "fa".into(),
from: "fork".into(),
to: "branch-a".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
Transition {
id: "ad".into(),
from: "branch-a".into(),
to: "done".into(),
event: TransitionEvent::Complete,
mode: TransitionMode::Flow,
guard: None,
},
],
);
assert!(
compile_workflow(fork_only)
.unwrap_err()
.to_string()
.contains("workflow_routing_invalid")
);
}
}