1038 lines
35 KiB
Rust
1038 lines
35 KiB
Rust
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
use crate::{
|
|
CANCELLED_STATE_ID, CompiledFlowDefinition, CompiledTransition, StateId, TransitionId,
|
|
};
|
|
|
|
const MAX_REASON_BYTES: usize = 16 * 1024;
|
|
const MAX_RATIONALE_BYTES: usize = 32 * 1024;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum FlowInstanceStatus {
|
|
Active,
|
|
Completed,
|
|
Cancelled,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FlowInstance {
|
|
pub instance_id: String,
|
|
pub definition_id: String,
|
|
pub definition_revision: u64,
|
|
pub definition_digest: String,
|
|
pub current_state: StateId,
|
|
pub state_revision: u64,
|
|
pub status: FlowInstanceStatus,
|
|
pub active_attempt_id: Option<String>,
|
|
}
|
|
|
|
impl FlowInstance {
|
|
pub fn start(
|
|
instance_id: impl Into<String>,
|
|
definition_id: impl Into<String>,
|
|
definition_revision: u64,
|
|
definition: &CompiledFlowDefinition,
|
|
) -> Result<Self, FlowTransitionError> {
|
|
let instance_id = instance_id.into();
|
|
let definition_id = definition_id.into();
|
|
ensure_non_empty("instance_id", &instance_id)
|
|
.and_then(|_| ensure_non_empty("definition_id", &definition_id))
|
|
.map_err(FlowTransitionError::InvalidRequest)?;
|
|
if definition_revision == 0 {
|
|
return Err(FlowTransitionError::InvalidRequest(
|
|
"definition_revision must be positive".to_string(),
|
|
));
|
|
}
|
|
let initial_state = definition.state(&definition.initial).ok_or_else(|| {
|
|
FlowTransitionError::Invariant("compiled initial state is missing".to_string())
|
|
})?;
|
|
let status = if initial_state.terminal {
|
|
FlowInstanceStatus::Completed
|
|
} else {
|
|
FlowInstanceStatus::Active
|
|
};
|
|
Ok(Self {
|
|
instance_id,
|
|
definition_id,
|
|
definition_revision,
|
|
definition_digest: definition.content_digest.clone(),
|
|
current_state: definition.initial.clone(),
|
|
state_revision: 0,
|
|
status,
|
|
active_attempt_id: None,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FlowTransitionRequest {
|
|
pub attempt_id: String,
|
|
pub reason: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FlowTransitionAttempt {
|
|
pub attempt_id: String,
|
|
pub instance_id: String,
|
|
pub definition_revision: u64,
|
|
pub definition_digest: String,
|
|
pub checked_state_revision: u64,
|
|
pub from_state: StateId,
|
|
pub reason: String,
|
|
pub transitions: Vec<TransitionCheckSnapshot>,
|
|
pub status: FlowAttemptStatus,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct TransitionCheckSnapshot {
|
|
pub transition_id: TransitionId,
|
|
pub target: StateId,
|
|
pub condition: String,
|
|
pub synthetic: bool,
|
|
}
|
|
|
|
impl From<&CompiledTransition> for TransitionCheckSnapshot {
|
|
fn from(transition: &CompiledTransition) -> Self {
|
|
Self {
|
|
transition_id: transition.id.clone(),
|
|
target: transition.target.clone(),
|
|
condition: transition.condition.clone(),
|
|
synthetic: transition.synthetic,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum FlowAttemptStatus {
|
|
Verifying,
|
|
Entered,
|
|
Rejected,
|
|
Cancelled,
|
|
Failed,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum ConditionVerdict {
|
|
Met,
|
|
NotMet,
|
|
Indeterminate,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct TransitionConditionResult {
|
|
pub transition_id: TransitionId,
|
|
pub verdict: ConditionVerdict,
|
|
pub rationale: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum FlowVerifierOutcome {
|
|
Completed {
|
|
results: Vec<TransitionConditionResult>,
|
|
},
|
|
Cancelled,
|
|
Failed {
|
|
message: String,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FlowTransitionResolution {
|
|
pub attempt: FlowTransitionAttempt,
|
|
pub entered_state: Option<StateId>,
|
|
pub state_instructions: Option<String>,
|
|
pub rejection: Option<FlowTransitionRejection>,
|
|
pub events: Vec<FlowEventKind>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FlowTransitionRejection {
|
|
pub code: FlowRejectionCode,
|
|
pub message: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum FlowRejectionCode {
|
|
NoConditionMet,
|
|
MultipleConditionsMet,
|
|
Indeterminate,
|
|
InvalidVerifierOutput,
|
|
VerifierCancelled,
|
|
VerifierFailed,
|
|
StaleState,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(tag = "kind", rename_all = "snake_case")]
|
|
pub enum FlowEventKind {
|
|
TransitionRequested {
|
|
attempt_id: String,
|
|
state_id: StateId,
|
|
state_revision: u64,
|
|
reason: String,
|
|
transitions: Vec<TransitionCheckSnapshot>,
|
|
},
|
|
TransitionVerifying {
|
|
attempt_id: String,
|
|
},
|
|
TransitionCheckPassed {
|
|
attempt_id: String,
|
|
transition_id: TransitionId,
|
|
results: Vec<TransitionConditionResult>,
|
|
},
|
|
TransitionCheckRejected {
|
|
attempt_id: String,
|
|
rejection: FlowTransitionRejection,
|
|
results: Vec<TransitionConditionResult>,
|
|
},
|
|
TransitionVerificationCancelled {
|
|
attempt_id: String,
|
|
},
|
|
TransitionVerificationFailed {
|
|
attempt_id: String,
|
|
message: String,
|
|
},
|
|
StateEntered {
|
|
attempt_id: String,
|
|
state_id: StateId,
|
|
state_revision: u64,
|
|
status: FlowInstanceStatus,
|
|
},
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FlowRuntimeEvent {
|
|
pub sequence: u64,
|
|
pub event: FlowEventKind,
|
|
}
|
|
|
|
/// Durable Flow authority owned by one Runtime Worker.
|
|
///
|
|
/// Workspace authority resolves and revisions the source, but never mutates
|
|
/// this value. Runtime persists the complete snapshot with the Worker session
|
|
/// and replaces it only after the corresponding session-log write succeeds.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct FlowRuntimeState {
|
|
pub definition: CompiledFlowDefinition,
|
|
pub instance: FlowInstance,
|
|
pub active_attempt: Option<FlowTransitionAttempt>,
|
|
pub events: Vec<FlowRuntimeEvent>,
|
|
}
|
|
|
|
impl FlowRuntimeState {
|
|
pub fn start(
|
|
source: &crate::ResolvedFlowSource,
|
|
instance_id: impl Into<String>,
|
|
) -> Result<(Self, String), FlowTransitionError> {
|
|
if source.content_digest != source.definition.content_digest {
|
|
return Err(FlowTransitionError::InvalidRequest(
|
|
"resolved Flow source digest does not match compiled definition".to_string(),
|
|
));
|
|
}
|
|
if source.selector.slug() != source.definition.name {
|
|
return Err(FlowTransitionError::InvalidRequest(
|
|
"resolved Flow selector does not match compiled definition name".to_string(),
|
|
));
|
|
}
|
|
let instance = FlowInstance::start(
|
|
instance_id,
|
|
source.flow_id.clone(),
|
|
source.revision,
|
|
&source.definition,
|
|
)?;
|
|
let initial = source
|
|
.definition
|
|
.state(&source.definition.initial)
|
|
.ok_or_else(|| {
|
|
FlowTransitionError::Invariant("compiled initial state is missing".to_string())
|
|
})?;
|
|
let event = FlowRuntimeEvent {
|
|
sequence: 0,
|
|
event: FlowEventKind::StateEntered {
|
|
attempt_id: String::new(),
|
|
state_id: instance.current_state.clone(),
|
|
state_revision: instance.state_revision,
|
|
status: instance.status,
|
|
},
|
|
};
|
|
Ok((
|
|
Self {
|
|
definition: source.definition.clone(),
|
|
instance,
|
|
active_attempt: None,
|
|
events: vec![event],
|
|
},
|
|
initial.instructions.clone(),
|
|
))
|
|
}
|
|
|
|
/// Begin a new attempt, or return the persisted active attempt after a
|
|
/// Runtime/Worker restore. A recovered attempt is never rewritten.
|
|
pub fn begin_or_recover_transition(
|
|
&mut self,
|
|
attempt_id: impl Into<String>,
|
|
reason: impl Into<String>,
|
|
) -> Result<FlowTransitionAttempt, FlowTransitionError> {
|
|
if let Some(attempt) = &self.active_attempt {
|
|
return Ok(attempt.clone());
|
|
}
|
|
let request = FlowTransitionRequest {
|
|
attempt_id: attempt_id.into(),
|
|
reason: reason.into(),
|
|
};
|
|
let (attempt, events) = begin_transition(&mut self.instance, &self.definition, request)?;
|
|
self.append_events(events)?;
|
|
self.active_attempt = Some(attempt.clone());
|
|
Ok(attempt)
|
|
}
|
|
|
|
pub fn resolve_active_transition(
|
|
&mut self,
|
|
attempt_id: &str,
|
|
outcome: FlowVerifierOutcome,
|
|
) -> Result<FlowTransitionResolution, FlowTransitionError> {
|
|
let attempt = self.active_attempt.clone().ok_or_else(|| {
|
|
FlowTransitionError::InvalidRequest("Flow has no active transition attempt".to_string())
|
|
})?;
|
|
if attempt.attempt_id != attempt_id {
|
|
return Err(FlowTransitionError::InvalidRequest(
|
|
"attempt is not the active/latest attempt for this Flow instance".to_string(),
|
|
));
|
|
}
|
|
let resolution =
|
|
resolve_transition(&mut self.instance, &self.definition, attempt, outcome)?;
|
|
self.append_events(resolution.events.clone())?;
|
|
self.active_attempt = None;
|
|
Ok(resolution)
|
|
}
|
|
|
|
fn append_events(&mut self, events: Vec<FlowEventKind>) -> Result<(), FlowTransitionError> {
|
|
for event in events {
|
|
let sequence = u64::try_from(self.events.len()).map_err(|_| {
|
|
FlowTransitionError::Invariant("Flow event sequence overflowed".to_string())
|
|
})?;
|
|
self.events.push(FlowRuntimeEvent { sequence, event });
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
|
|
pub enum FlowTransitionError {
|
|
#[error("Flow instance is not active")]
|
|
NotActive,
|
|
#[error("another transition attempt is already active")]
|
|
AttemptInProgress,
|
|
#[error("Flow definition does not match the instance's pinned revision")]
|
|
DefinitionMismatch,
|
|
#[error("invalid transition request: {0}")]
|
|
InvalidRequest(String),
|
|
#[error("Flow invariant failed: {0}")]
|
|
Invariant(String),
|
|
}
|
|
|
|
pub fn begin_transition(
|
|
instance: &mut FlowInstance,
|
|
definition: &CompiledFlowDefinition,
|
|
request: FlowTransitionRequest,
|
|
) -> Result<(FlowTransitionAttempt, Vec<FlowEventKind>), FlowTransitionError> {
|
|
if instance.status != FlowInstanceStatus::Active {
|
|
return Err(FlowTransitionError::NotActive);
|
|
}
|
|
if instance.active_attempt_id.is_some() {
|
|
return Err(FlowTransitionError::AttemptInProgress);
|
|
}
|
|
if definition.content_digest != instance.definition_digest {
|
|
return Err(FlowTransitionError::DefinitionMismatch);
|
|
}
|
|
ensure_non_empty("attempt_id", &request.attempt_id)
|
|
.map_err(FlowTransitionError::InvalidRequest)?;
|
|
validate_reason(&request.reason)?;
|
|
let state = definition.state(&instance.current_state).ok_or_else(|| {
|
|
FlowTransitionError::Invariant(format!(
|
|
"current state {:?} is missing from the pinned definition",
|
|
instance.current_state
|
|
))
|
|
})?;
|
|
if state.terminal || state.transitions.is_empty() {
|
|
return Err(FlowTransitionError::Invariant(
|
|
"active instance points at a state without transitions".to_string(),
|
|
));
|
|
}
|
|
|
|
let transitions = state
|
|
.transitions
|
|
.iter()
|
|
.map(TransitionCheckSnapshot::from)
|
|
.collect::<Vec<_>>();
|
|
let attempt = FlowTransitionAttempt {
|
|
attempt_id: request.attempt_id,
|
|
instance_id: instance.instance_id.clone(),
|
|
definition_revision: instance.definition_revision,
|
|
definition_digest: instance.definition_digest.clone(),
|
|
checked_state_revision: instance.state_revision,
|
|
from_state: instance.current_state.clone(),
|
|
reason: request.reason,
|
|
transitions,
|
|
status: FlowAttemptStatus::Verifying,
|
|
};
|
|
instance.active_attempt_id = Some(attempt.attempt_id.clone());
|
|
let events = vec![
|
|
FlowEventKind::TransitionRequested {
|
|
attempt_id: attempt.attempt_id.clone(),
|
|
state_id: attempt.from_state.clone(),
|
|
state_revision: attempt.checked_state_revision,
|
|
reason: attempt.reason.clone(),
|
|
transitions: attempt.transitions.clone(),
|
|
},
|
|
FlowEventKind::TransitionVerifying {
|
|
attempt_id: attempt.attempt_id.clone(),
|
|
},
|
|
];
|
|
Ok((attempt, events))
|
|
}
|
|
|
|
pub fn resolve_transition(
|
|
instance: &mut FlowInstance,
|
|
definition: &CompiledFlowDefinition,
|
|
mut attempt: FlowTransitionAttempt,
|
|
outcome: FlowVerifierOutcome,
|
|
) -> Result<FlowTransitionResolution, FlowTransitionError> {
|
|
if instance.active_attempt_id.as_deref() != Some(attempt.attempt_id.as_str()) {
|
|
return Err(FlowTransitionError::InvalidRequest(
|
|
"attempt is not the active/latest attempt for this Flow instance".to_string(),
|
|
));
|
|
}
|
|
if definition.content_digest != instance.definition_digest
|
|
|| attempt.definition_digest != instance.definition_digest
|
|
|| attempt.definition_revision != instance.definition_revision
|
|
{
|
|
return Err(FlowTransitionError::DefinitionMismatch);
|
|
}
|
|
|
|
let mut events = Vec::new();
|
|
let resolution = match outcome {
|
|
FlowVerifierOutcome::Cancelled => {
|
|
attempt.status = FlowAttemptStatus::Cancelled;
|
|
events.push(FlowEventKind::TransitionVerificationCancelled {
|
|
attempt_id: attempt.attempt_id.clone(),
|
|
});
|
|
rejected_resolution(
|
|
attempt,
|
|
FlowRejectionCode::VerifierCancelled,
|
|
"Flow verifier was cancelled before producing a complete verdict",
|
|
Vec::new(),
|
|
events,
|
|
)
|
|
}
|
|
FlowVerifierOutcome::Failed { message } => {
|
|
let message = bounded_message(message);
|
|
attempt.status = FlowAttemptStatus::Failed;
|
|
events.push(FlowEventKind::TransitionVerificationFailed {
|
|
attempt_id: attempt.attempt_id.clone(),
|
|
message: message.clone(),
|
|
});
|
|
rejected_resolution(
|
|
attempt,
|
|
FlowRejectionCode::VerifierFailed,
|
|
format!("Flow verifier failed: {message}"),
|
|
Vec::new(),
|
|
events,
|
|
)
|
|
}
|
|
FlowVerifierOutcome::Completed { results } => {
|
|
if instance.state_revision != attempt.checked_state_revision
|
|
|| instance.current_state != attempt.from_state
|
|
{
|
|
attempt.status = FlowAttemptStatus::Rejected;
|
|
let rejection = FlowTransitionRejection {
|
|
code: FlowRejectionCode::StaleState,
|
|
message: "Flow state changed after verification began".to_string(),
|
|
};
|
|
events.push(FlowEventKind::TransitionCheckRejected {
|
|
attempt_id: attempt.attempt_id.clone(),
|
|
rejection: rejection.clone(),
|
|
results,
|
|
});
|
|
FlowTransitionResolution {
|
|
attempt,
|
|
entered_state: None,
|
|
state_instructions: None,
|
|
rejection: Some(rejection),
|
|
events,
|
|
}
|
|
} else {
|
|
resolve_completed_results(instance, definition, attempt, results, events)?
|
|
}
|
|
}
|
|
};
|
|
instance.active_attempt_id = None;
|
|
Ok(resolution)
|
|
}
|
|
|
|
fn resolve_completed_results(
|
|
instance: &mut FlowInstance,
|
|
definition: &CompiledFlowDefinition,
|
|
mut attempt: FlowTransitionAttempt,
|
|
results: Vec<TransitionConditionResult>,
|
|
mut events: Vec<FlowEventKind>,
|
|
) -> Result<FlowTransitionResolution, FlowTransitionError> {
|
|
let expected = attempt
|
|
.transitions
|
|
.iter()
|
|
.map(|transition| transition.transition_id.clone())
|
|
.collect::<BTreeSet<_>>();
|
|
let mut by_id = BTreeMap::new();
|
|
let mut invalid_reason = None;
|
|
for result in &results {
|
|
if result.rationale.len() > MAX_RATIONALE_BYTES {
|
|
invalid_reason = Some(format!(
|
|
"rationale for transition {:?} exceeds {MAX_RATIONALE_BYTES} bytes",
|
|
result.transition_id
|
|
));
|
|
break;
|
|
}
|
|
if by_id
|
|
.insert(result.transition_id.clone(), result.verdict)
|
|
.is_some()
|
|
{
|
|
invalid_reason = Some(format!(
|
|
"verifier returned transition {:?} more than once",
|
|
result.transition_id
|
|
));
|
|
break;
|
|
}
|
|
}
|
|
let actual = by_id.keys().cloned().collect::<BTreeSet<_>>();
|
|
if invalid_reason.is_none() && actual != expected {
|
|
invalid_reason = Some(
|
|
"verifier must return exactly one result for every captured outgoing transition"
|
|
.to_string(),
|
|
);
|
|
}
|
|
if let Some(message) = invalid_reason {
|
|
attempt.status = FlowAttemptStatus::Rejected;
|
|
let rejection = FlowTransitionRejection {
|
|
code: FlowRejectionCode::InvalidVerifierOutput,
|
|
message,
|
|
};
|
|
events.push(FlowEventKind::TransitionCheckRejected {
|
|
attempt_id: attempt.attempt_id.clone(),
|
|
rejection: rejection.clone(),
|
|
results,
|
|
});
|
|
return Ok(FlowTransitionResolution {
|
|
attempt,
|
|
entered_state: None,
|
|
state_instructions: None,
|
|
rejection: Some(rejection),
|
|
events,
|
|
});
|
|
}
|
|
|
|
let met = results
|
|
.iter()
|
|
.filter(|result| result.verdict == ConditionVerdict::Met)
|
|
.collect::<Vec<_>>();
|
|
let rejection = if met.is_empty() {
|
|
let indeterminate = results
|
|
.iter()
|
|
.any(|result| result.verdict == ConditionVerdict::Indeterminate);
|
|
Some(FlowTransitionRejection {
|
|
code: if indeterminate {
|
|
FlowRejectionCode::Indeterminate
|
|
} else {
|
|
FlowRejectionCode::NoConditionMet
|
|
},
|
|
message: if indeterminate {
|
|
"No transition condition was met and at least one condition could not be determined"
|
|
.to_string()
|
|
} else {
|
|
"No outgoing transition condition was met".to_string()
|
|
},
|
|
})
|
|
} else if met.len() > 1 {
|
|
Some(FlowTransitionRejection {
|
|
code: FlowRejectionCode::MultipleConditionsMet,
|
|
message: "More than one outgoing transition condition was met".to_string(),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(rejection) = rejection {
|
|
attempt.status = FlowAttemptStatus::Rejected;
|
|
events.push(FlowEventKind::TransitionCheckRejected {
|
|
attempt_id: attempt.attempt_id.clone(),
|
|
rejection: rejection.clone(),
|
|
results,
|
|
});
|
|
return Ok(FlowTransitionResolution {
|
|
attempt,
|
|
entered_state: None,
|
|
state_instructions: None,
|
|
rejection: Some(rejection),
|
|
events,
|
|
});
|
|
}
|
|
|
|
let selected = met[0];
|
|
let transition = attempt
|
|
.transitions
|
|
.iter()
|
|
.find(|transition| transition.transition_id == selected.transition_id)
|
|
.ok_or_else(|| {
|
|
FlowTransitionError::Invariant(
|
|
"selected transition is missing from the captured attempt".to_string(),
|
|
)
|
|
})?;
|
|
let target_state = definition.state(&transition.target).ok_or_else(|| {
|
|
FlowTransitionError::Invariant(format!(
|
|
"target state {:?} is missing from the pinned definition",
|
|
transition.target
|
|
))
|
|
})?;
|
|
attempt.status = FlowAttemptStatus::Entered;
|
|
events.push(FlowEventKind::TransitionCheckPassed {
|
|
attempt_id: attempt.attempt_id.clone(),
|
|
transition_id: transition.transition_id.clone(),
|
|
results,
|
|
});
|
|
instance.current_state = transition.target.clone();
|
|
instance.state_revision = instance.state_revision.checked_add(1).ok_or_else(|| {
|
|
FlowTransitionError::Invariant("Flow state revision overflowed".to_string())
|
|
})?;
|
|
instance.status = if transition.target.as_str() == CANCELLED_STATE_ID {
|
|
FlowInstanceStatus::Cancelled
|
|
} else if target_state.terminal {
|
|
FlowInstanceStatus::Completed
|
|
} else {
|
|
FlowInstanceStatus::Active
|
|
};
|
|
events.push(FlowEventKind::StateEntered {
|
|
attempt_id: attempt.attempt_id.clone(),
|
|
state_id: instance.current_state.clone(),
|
|
state_revision: instance.state_revision,
|
|
status: instance.status,
|
|
});
|
|
Ok(FlowTransitionResolution {
|
|
attempt,
|
|
entered_state: Some(instance.current_state.clone()),
|
|
state_instructions: Some(target_state.instructions.clone()),
|
|
rejection: None,
|
|
events,
|
|
})
|
|
}
|
|
|
|
fn rejected_resolution(
|
|
attempt: FlowTransitionAttempt,
|
|
code: FlowRejectionCode,
|
|
message: impl Into<String>,
|
|
results: Vec<TransitionConditionResult>,
|
|
mut events: Vec<FlowEventKind>,
|
|
) -> FlowTransitionResolution {
|
|
let rejection = FlowTransitionRejection {
|
|
code,
|
|
message: message.into(),
|
|
};
|
|
events.push(FlowEventKind::TransitionCheckRejected {
|
|
attempt_id: attempt.attempt_id.clone(),
|
|
rejection: rejection.clone(),
|
|
results,
|
|
});
|
|
FlowTransitionResolution {
|
|
attempt,
|
|
entered_state: None,
|
|
state_instructions: None,
|
|
rejection: Some(rejection),
|
|
events,
|
|
}
|
|
}
|
|
|
|
fn validate_reason(reason: &str) -> Result<(), FlowTransitionError> {
|
|
if reason.trim().is_empty() {
|
|
return Err(FlowTransitionError::InvalidRequest(
|
|
"reason must not be empty".to_string(),
|
|
));
|
|
}
|
|
if reason.len() > MAX_REASON_BYTES {
|
|
return Err(FlowTransitionError::InvalidRequest(format!(
|
|
"reason exceeds {MAX_REASON_BYTES} bytes"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ensure_non_empty(field: &str, value: &str) -> Result<(), String> {
|
|
if value.trim().is_empty() {
|
|
Err(format!("{field} must not be empty"))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn bounded_message(message: String) -> String {
|
|
if message.len() <= MAX_RATIONALE_BYTES {
|
|
return message;
|
|
}
|
|
let mut end = MAX_RATIONALE_BYTES;
|
|
while !message.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
format!("{}…", &message[..end])
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::compile_flow_source;
|
|
|
|
fn definition() -> CompiledFlowDefinition {
|
|
compile_flow_source(
|
|
r#"{
|
|
schema_version = 1;
|
|
name = "simple";
|
|
initial = "work";
|
|
states = {
|
|
work = {
|
|
instructions = "Do the work.";
|
|
transitions = {
|
|
done = {
|
|
target = "done";
|
|
condition = "The requested work and validation are complete.";
|
|
};
|
|
};
|
|
};
|
|
done = { instructions = ""; terminal = true; };
|
|
};
|
|
}"#,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn begin(instance: &mut FlowInstance) -> FlowTransitionAttempt {
|
|
let (attempt, events) = begin_transition(
|
|
instance,
|
|
&definition(),
|
|
FlowTransitionRequest {
|
|
attempt_id: "attempt-1".to_string(),
|
|
reason: "Implementation and tests are complete.".to_string(),
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert!(matches!(
|
|
events.as_slice(),
|
|
[
|
|
FlowEventKind::TransitionRequested { .. },
|
|
FlowEventKind::TransitionVerifying { .. }
|
|
]
|
|
));
|
|
attempt
|
|
}
|
|
|
|
#[test]
|
|
fn exactly_one_met_transition_enters_terminal_state() {
|
|
let definition = definition();
|
|
let mut instance = FlowInstance {
|
|
instance_id: "instance-1".to_string(),
|
|
definition_id: "definition-1".to_string(),
|
|
definition_revision: 1,
|
|
definition_digest: definition.content_digest.clone(),
|
|
current_state: definition.initial.clone(),
|
|
state_revision: 0,
|
|
status: FlowInstanceStatus::Active,
|
|
active_attempt_id: None,
|
|
};
|
|
let attempt = begin(&mut instance);
|
|
let results = attempt
|
|
.transitions
|
|
.iter()
|
|
.map(|transition| TransitionConditionResult {
|
|
transition_id: transition.transition_id.clone(),
|
|
verdict: if transition.transition_id.as_str() == "done" {
|
|
ConditionVerdict::Met
|
|
} else {
|
|
ConditionVerdict::NotMet
|
|
},
|
|
rationale: "bounded rationale".to_string(),
|
|
})
|
|
.collect();
|
|
let resolution = resolve_transition(
|
|
&mut instance,
|
|
&definition,
|
|
attempt,
|
|
FlowVerifierOutcome::Completed { results },
|
|
)
|
|
.unwrap();
|
|
assert_eq!(instance.current_state.as_str(), "done");
|
|
assert_eq!(instance.state_revision, 1);
|
|
assert_eq!(instance.status, FlowInstanceStatus::Completed);
|
|
assert!(resolution.rejection.is_none());
|
|
assert!(matches!(
|
|
resolution.events.as_slice(),
|
|
[
|
|
FlowEventKind::TransitionCheckPassed { .. },
|
|
FlowEventKind::StateEntered { .. }
|
|
]
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn cancellation_requires_synthetic_condition_to_be_met() {
|
|
let definition = definition();
|
|
let mut instance = FlowInstance {
|
|
instance_id: "instance-1".to_string(),
|
|
definition_id: "definition-1".to_string(),
|
|
definition_revision: 1,
|
|
definition_digest: definition.content_digest.clone(),
|
|
current_state: definition.initial.clone(),
|
|
state_revision: 0,
|
|
status: FlowInstanceStatus::Active,
|
|
active_attempt_id: None,
|
|
};
|
|
let attempt = begin(&mut instance);
|
|
let results = attempt
|
|
.transitions
|
|
.iter()
|
|
.map(|transition| TransitionConditionResult {
|
|
transition_id: transition.transition_id.clone(),
|
|
verdict: if transition.synthetic {
|
|
ConditionVerdict::Met
|
|
} else {
|
|
ConditionVerdict::NotMet
|
|
},
|
|
rationale: "the required authority is unavailable".to_string(),
|
|
})
|
|
.collect();
|
|
let resolution = resolve_transition(
|
|
&mut instance,
|
|
&definition,
|
|
attempt,
|
|
FlowVerifierOutcome::Completed { results },
|
|
)
|
|
.unwrap();
|
|
assert_eq!(instance.current_state.as_str(), CANCELLED_STATE_ID);
|
|
assert_eq!(instance.status, FlowInstanceStatus::Cancelled);
|
|
assert!(resolution.rejection.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn no_met_condition_keeps_state_unchanged() {
|
|
let definition = definition();
|
|
let mut instance = FlowInstance {
|
|
instance_id: "instance-1".to_string(),
|
|
definition_id: "definition-1".to_string(),
|
|
definition_revision: 1,
|
|
definition_digest: definition.content_digest.clone(),
|
|
current_state: definition.initial.clone(),
|
|
state_revision: 0,
|
|
status: FlowInstanceStatus::Active,
|
|
active_attempt_id: None,
|
|
};
|
|
let attempt = begin(&mut instance);
|
|
let results = attempt
|
|
.transitions
|
|
.iter()
|
|
.map(|transition| TransitionConditionResult {
|
|
transition_id: transition.transition_id.clone(),
|
|
verdict: ConditionVerdict::NotMet,
|
|
rationale: "not enough evidence".to_string(),
|
|
})
|
|
.collect();
|
|
let resolution = resolve_transition(
|
|
&mut instance,
|
|
&definition,
|
|
attempt,
|
|
FlowVerifierOutcome::Completed { results },
|
|
)
|
|
.unwrap();
|
|
assert_eq!(instance.current_state.as_str(), "work");
|
|
assert_eq!(instance.state_revision, 0);
|
|
assert_eq!(instance.active_attempt_id, None);
|
|
assert_eq!(
|
|
resolution.rejection.unwrap().code,
|
|
FlowRejectionCode::NoConditionMet
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_or_missing_verdict_is_invalid_output() {
|
|
let definition = definition();
|
|
let mut instance = FlowInstance {
|
|
instance_id: "instance-1".to_string(),
|
|
definition_id: "definition-1".to_string(),
|
|
definition_revision: 1,
|
|
definition_digest: definition.content_digest.clone(),
|
|
current_state: definition.initial.clone(),
|
|
state_revision: 0,
|
|
status: FlowInstanceStatus::Active,
|
|
active_attempt_id: None,
|
|
};
|
|
let attempt = begin(&mut instance);
|
|
let one_result = vec![TransitionConditionResult {
|
|
transition_id: attempt.transitions[0].transition_id.clone(),
|
|
verdict: ConditionVerdict::Met,
|
|
rationale: "done".to_string(),
|
|
}];
|
|
let resolution = resolve_transition(
|
|
&mut instance,
|
|
&definition,
|
|
attempt,
|
|
FlowVerifierOutcome::Completed {
|
|
results: one_result,
|
|
},
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
resolution.rejection.unwrap().code,
|
|
FlowRejectionCode::InvalidVerifierOutput
|
|
);
|
|
assert_eq!(instance.current_state.as_str(), "work");
|
|
}
|
|
|
|
#[test]
|
|
fn stale_result_cannot_enter_state() {
|
|
let definition = definition();
|
|
let mut instance = FlowInstance {
|
|
instance_id: "instance-1".to_string(),
|
|
definition_id: "definition-1".to_string(),
|
|
definition_revision: 1,
|
|
definition_digest: definition.content_digest.clone(),
|
|
current_state: definition.initial.clone(),
|
|
state_revision: 0,
|
|
status: FlowInstanceStatus::Active,
|
|
active_attempt_id: None,
|
|
};
|
|
let attempt = begin(&mut instance);
|
|
instance.state_revision = 1;
|
|
let results = attempt
|
|
.transitions
|
|
.iter()
|
|
.map(|transition| TransitionConditionResult {
|
|
transition_id: transition.transition_id.clone(),
|
|
verdict: ConditionVerdict::NotMet,
|
|
rationale: "not met".to_string(),
|
|
})
|
|
.collect();
|
|
let resolution = resolve_transition(
|
|
&mut instance,
|
|
&definition,
|
|
attempt,
|
|
FlowVerifierOutcome::Completed { results },
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
resolution.rejection.unwrap().code,
|
|
FlowRejectionCode::StaleState
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn multiple_met_conditions_reject_without_state_entry() {
|
|
let definition = definition();
|
|
let mut instance = FlowInstance {
|
|
instance_id: "instance-1".to_string(),
|
|
definition_id: "definition-1".to_string(),
|
|
definition_revision: 1,
|
|
definition_digest: definition.content_digest.clone(),
|
|
current_state: definition.initial.clone(),
|
|
state_revision: 0,
|
|
status: FlowInstanceStatus::Active,
|
|
active_attempt_id: None,
|
|
};
|
|
let attempt = begin(&mut instance);
|
|
let results = attempt
|
|
.transitions
|
|
.iter()
|
|
.map(|transition| TransitionConditionResult {
|
|
transition_id: transition.transition_id.clone(),
|
|
verdict: ConditionVerdict::Met,
|
|
rationale: "claimed met".to_string(),
|
|
})
|
|
.collect();
|
|
let resolution = resolve_transition(
|
|
&mut instance,
|
|
&definition,
|
|
attempt,
|
|
FlowVerifierOutcome::Completed { results },
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
resolution.rejection.unwrap().code,
|
|
FlowRejectionCode::MultipleConditionsMet
|
|
);
|
|
assert_eq!(instance.current_state.as_str(), "work");
|
|
assert_eq!(instance.state_revision, 0);
|
|
assert!(
|
|
resolution
|
|
.events
|
|
.iter()
|
|
.all(|event| !matches!(event, FlowEventKind::StateEntered { .. }))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn verifier_cancellation_is_terminal_for_attempt_but_not_flow() {
|
|
let definition = definition();
|
|
let mut instance = FlowInstance {
|
|
instance_id: "instance-1".to_string(),
|
|
definition_id: "definition-1".to_string(),
|
|
definition_revision: 1,
|
|
definition_digest: definition.content_digest.clone(),
|
|
current_state: definition.initial.clone(),
|
|
state_revision: 0,
|
|
status: FlowInstanceStatus::Active,
|
|
active_attempt_id: None,
|
|
};
|
|
let attempt = begin(&mut instance);
|
|
let resolution = resolve_transition(
|
|
&mut instance,
|
|
&definition,
|
|
attempt,
|
|
FlowVerifierOutcome::Cancelled,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(resolution.attempt.status, FlowAttemptStatus::Cancelled);
|
|
assert_eq!(instance.status, FlowInstanceStatus::Active);
|
|
assert_eq!(instance.current_state.as_str(), "work");
|
|
assert_eq!(instance.active_attempt_id, None);
|
|
assert!(
|
|
resolution
|
|
.events
|
|
.iter()
|
|
.all(|event| !matches!(event, FlowEventKind::StateEntered { .. }))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn overlapping_attempt_is_rejected_before_verifier() {
|
|
let definition = definition();
|
|
let mut instance = FlowInstance {
|
|
instance_id: "instance-1".to_string(),
|
|
definition_id: "definition-1".to_string(),
|
|
definition_revision: 1,
|
|
definition_digest: definition.content_digest.clone(),
|
|
current_state: definition.initial.clone(),
|
|
state_revision: 0,
|
|
status: FlowInstanceStatus::Active,
|
|
active_attempt_id: None,
|
|
};
|
|
let _ = begin(&mut instance);
|
|
let overlap = begin_transition(
|
|
&mut instance,
|
|
&definition,
|
|
FlowTransitionRequest {
|
|
attempt_id: "attempt-2".to_string(),
|
|
reason: "retry".to_string(),
|
|
},
|
|
)
|
|
.unwrap_err();
|
|
assert_eq!(overlap, FlowTransitionError::AttemptInProgress);
|
|
}
|
|
}
|