use std::{num::NonZeroU64, time::Duration};
use runifold_core::CheckpointId;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
const MAX_SIGNAL_PAYLOAD_BYTES: usize = 1_048_576;
const MAX_INTERRUPT_PROMPT_BYTES: usize = 16_384;
const MAX_INTERRUPT_REJECTION_BYTES: usize = 4_096;
const INTERRUPT_SIGNAL_PREFIX: &str = "__runifold.interrupt.";
#[derive(Clone, Debug, Error, Eq, PartialEq)]
#[non_exhaustive]
pub enum WorkflowWaitError {
#[error("signal name must contain 1..=128 portable ASCII characters")]
InvalidSignalName,
#[error("durable timer must fit in a positive whole-millisecond duration")]
InvalidTimerDuration,
#[error("signal retention must fit in a positive whole-millisecond duration")]
InvalidRetention,
#[error("signal payload exceeds the 1 MiB durable limit")]
SignalPayloadTooLarge,
#[error("interrupt prompt must contain 1..=16384 bytes")]
InvalidInterruptPrompt,
#[error("interrupt decision payload exceeds the 1 MiB durable limit")]
InterruptPayloadTooLarge,
#[error("interrupt rejection reason must contain 1..=4096 bytes")]
InvalidInterruptRejection,
}
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct WorkflowSignalName(String);
impl WorkflowSignalName {
pub fn parse(value: impl Into<String>) -> Result<Self, WorkflowWaitError> {
let value = value.into();
let valid = !value.is_empty()
&& value.len() <= 128
&& !value.starts_with(INTERRUPT_SIGNAL_PREFIX)
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'));
valid
.then_some(Self(value))
.ok_or(WorkflowWaitError::InvalidSignalName)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct WorkflowSignalId(CheckpointId);
impl WorkflowSignalId {
pub fn new() -> Self {
Self(CheckpointId::new())
}
pub const fn from_checkpoint_id(id: CheckpointId) -> Self {
Self(id)
}
pub const fn as_checkpoint_id(self) -> CheckpointId {
self.0
}
}
impl Default for WorkflowSignalId {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(transparent)]
pub struct WorkflowInterruptId(CheckpointId);
impl WorkflowInterruptId {
pub fn new() -> Self {
Self(CheckpointId::new())
}
pub const fn from_checkpoint_id(id: CheckpointId) -> Self {
Self(id)
}
pub const fn as_checkpoint_id(self) -> CheckpointId {
self.0
}
pub(crate) fn signal_name(self) -> WorkflowSignalName {
WorkflowSignalName(format!("{INTERRUPT_SIGNAL_PREFIX}{}", self.0))
}
}
impl Default for WorkflowInterruptId {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkflowInterruptRequest {
pub interrupt_id: WorkflowInterruptId,
pub prompt: String,
pub proposal: Value,
}
impl WorkflowInterruptRequest {
pub fn new(prompt: impl Into<String>, proposal: Value) -> Result<Self, WorkflowWaitError> {
Self::with_id(WorkflowInterruptId::new(), prompt, proposal)
}
pub fn with_id(
interrupt_id: WorkflowInterruptId,
prompt: impl Into<String>,
proposal: Value,
) -> Result<Self, WorkflowWaitError> {
let prompt = prompt.into();
Self::validate_prompt(&prompt)?;
validate_interrupt_payload(&proposal)?;
Ok(Self {
interrupt_id,
prompt,
proposal,
})
}
pub(crate) fn validate_prompt(prompt: &str) -> Result<(), WorkflowWaitError> {
if prompt.trim().is_empty() || prompt.len() > MAX_INTERRUPT_PROMPT_BYTES {
return Err(WorkflowWaitError::InvalidInterruptPrompt);
}
Ok(())
}
#[doc(hidden)]
pub fn signal_name(&self) -> WorkflowSignalName {
self.interrupt_id.signal_name()
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum WorkflowInterruptDecision {
Approve,
Edit {
value: Value,
},
Reject {
reason: String,
},
}
impl WorkflowInterruptDecision {
pub const fn approve() -> Self {
Self::Approve
}
pub fn edit(value: Value) -> Result<Self, WorkflowWaitError> {
validate_interrupt_payload(&value)?;
Ok(Self::Edit { value })
}
pub fn reject(reason: impl Into<String>) -> Result<Self, WorkflowWaitError> {
let reason = reason.into();
if reason.trim().is_empty() || reason.len() > MAX_INTERRUPT_REJECTION_BYTES {
return Err(WorkflowWaitError::InvalidInterruptRejection);
}
Ok(Self::Reject { reason })
}
pub(crate) fn validate(&self) -> Result<(), WorkflowWaitError> {
match self {
Self::Approve => Ok(()),
Self::Edit { value } => validate_interrupt_payload(value),
Self::Reject { reason } => {
if reason.trim().is_empty() || reason.len() > MAX_INTERRUPT_REJECTION_BYTES {
Err(WorkflowWaitError::InvalidInterruptRejection)
} else {
Ok(())
}
}
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct WorkflowInterruptCommand {
pub decision_id: WorkflowSignalId,
pub checkpoint_id: CheckpointId,
pub interrupt_id: WorkflowInterruptId,
pub decision: WorkflowInterruptDecision,
}
impl WorkflowInterruptCommand {
pub fn new(
checkpoint_id: CheckpointId,
interrupt_id: WorkflowInterruptId,
decision: WorkflowInterruptDecision,
) -> Result<Self, WorkflowWaitError> {
Self::with_id(
WorkflowSignalId::new(),
checkpoint_id,
interrupt_id,
decision,
)
}
pub fn with_id(
decision_id: WorkflowSignalId,
checkpoint_id: CheckpointId,
interrupt_id: WorkflowInterruptId,
decision: WorkflowInterruptDecision,
) -> Result<Self, WorkflowWaitError> {
decision.validate()?;
Ok(Self {
decision_id,
checkpoint_id,
interrupt_id,
decision,
})
}
pub(crate) fn into_signal(self) -> Result<WorkflowSignal, WorkflowWaitError> {
let payload = serde_json::to_value(self.decision)
.map_err(|_| WorkflowWaitError::InterruptPayloadTooLarge)?;
WorkflowSignal::with_id(
self.decision_id,
self.checkpoint_id,
self.interrupt_id.signal_name(),
payload,
)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum WorkflowInterruptDecisionOutcome {
Buffered,
WokeWorkflow,
Duplicate,
DeadLettered,
}
impl From<WorkflowSignalOutcome> for WorkflowInterruptDecisionOutcome {
fn from(value: WorkflowSignalOutcome) -> Self {
match value {
WorkflowSignalOutcome::Buffered => Self::Buffered,
WorkflowSignalOutcome::WokeWorkflow => Self::WokeWorkflow,
WorkflowSignalOutcome::Duplicate => Self::Duplicate,
WorkflowSignalOutcome::DeadLettered => Self::DeadLettered,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum WorkflowInterruptOutcome {
Approved {
value: Value,
},
Edited {
value: Value,
},
Rejected {
reason: String,
},
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum WorkflowWait {
Timer {
delay_ms: u64,
},
Signal {
name: WorkflowSignalName,
},
SignalOrTimeout {
name: WorkflowSignalName,
timeout_ms: u64,
},
Interrupt {
request: WorkflowInterruptRequest,
},
}
impl WorkflowWait {
pub fn timer(delay: Duration) -> Result<Self, WorkflowWaitError> {
let delay_ms = u64::try_from(delay.as_millis())
.ok()
.filter(|value| *value > 0)
.ok_or(WorkflowWaitError::InvalidTimerDuration)?;
Ok(Self::Timer { delay_ms })
}
pub const fn signal(name: WorkflowSignalName) -> Self {
Self::Signal { name }
}
pub fn signal_or_timeout(
name: WorkflowSignalName,
timeout: Duration,
) -> Result<Self, WorkflowWaitError> {
let timeout_ms = u64::try_from(timeout.as_millis())
.ok()
.filter(|value| *value > 0)
.ok_or(WorkflowWaitError::InvalidTimerDuration)?;
Ok(Self::SignalOrTimeout { name, timeout_ms })
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum WorkflowWake {
Timer,
Timeout,
Signal {
signal_id: WorkflowSignalId,
name: WorkflowSignalName,
payload: Value,
},
}
impl WorkflowWake {
pub(crate) fn matches(&self, wait: &WorkflowWait) -> bool {
match (self, wait) {
(Self::Timer, WorkflowWait::Timer { .. })
| (Self::Timeout, WorkflowWait::SignalOrTimeout { .. }) => true,
(Self::Signal { name: actual, .. }, WorkflowWait::Signal { name: expected }) => {
actual == expected
}
(
Self::Signal { name: actual, .. },
WorkflowWait::SignalOrTimeout { name: expected, .. },
) => actual == expected,
(Self::Signal { name: actual, .. }, WorkflowWait::Interrupt { request }) => {
*actual == request.signal_name()
}
_ => false,
}
}
}
fn validate_interrupt_payload(value: &Value) -> Result<(), WorkflowWaitError> {
if serde_json::to_vec(value).is_ok_and(|encoded| encoded.len() > MAX_SIGNAL_PAYLOAD_BYTES) {
Err(WorkflowWaitError::InterruptPayloadTooLarge)
} else {
Ok(())
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
#[non_exhaustive]
pub enum WorkflowWaitOutcome {
Signal {
signal_id: WorkflowSignalId,
name: WorkflowSignalName,
payload: Value,
},
TimedOut,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct WorkflowSignal {
pub signal_id: WorkflowSignalId,
pub checkpoint_id: CheckpointId,
pub name: WorkflowSignalName,
pub payload: Value,
}
impl WorkflowSignal {
pub fn new(
checkpoint_id: CheckpointId,
name: WorkflowSignalName,
payload: Value,
) -> Result<Self, WorkflowWaitError> {
Self::with_id(WorkflowSignalId::new(), checkpoint_id, name, payload)
}
pub fn with_id(
signal_id: WorkflowSignalId,
checkpoint_id: CheckpointId,
name: WorkflowSignalName,
payload: Value,
) -> Result<Self, WorkflowWaitError> {
if serde_json::to_vec(&payload)
.is_ok_and(|encoded| encoded.len() > MAX_SIGNAL_PAYLOAD_BYTES)
{
return Err(WorkflowWaitError::SignalPayloadTooLarge);
}
Ok(Self {
signal_id,
checkpoint_id,
name,
payload,
})
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum WorkflowSignalOutcome {
Buffered,
WokeWorkflow,
Duplicate,
DeadLettered,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum WorkflowSignalState {
Pending,
Consumed,
DeadLettered,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct WorkflowSignalSnapshot {
pub signal_id: WorkflowSignalId,
pub tenant_id: crate::WorkflowTenantId,
pub checkpoint_id: CheckpointId,
pub name: WorkflowSignalName,
pub state: WorkflowSignalState,
pub accepted_at_ms: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WorkflowSignalRetention(NonZeroU64);
impl WorkflowSignalRetention {
pub fn new(duration: Duration) -> Result<Self, WorkflowWaitError> {
let millis = u64::try_from(duration.as_millis())
.ok()
.and_then(NonZeroU64::new)
.ok_or(WorkflowWaitError::InvalidRetention)?;
Ok(Self(millis))
}
pub const fn as_millis(self) -> u64 {
self.0.get()
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn wait_inputs_enforce_portable_bounded_values() {
assert!(WorkflowSignalName::parse("approval.received").is_ok());
assert!(WorkflowSignalName::parse("approval received").is_err());
assert!(WorkflowWait::timer(Duration::ZERO).is_err());
assert!(WorkflowWait::timer(Duration::from_nanos(1)).is_err());
assert!(WorkflowWait::timer(Duration::from_millis(1)).is_ok());
assert!(
WorkflowWait::signal_or_timeout(
WorkflowSignalName::parse("approval").unwrap(),
Duration::ZERO,
)
.is_err()
);
}
#[test]
fn signal_payload_is_bounded_before_persistence() {
let oversized = Value::String("x".repeat(MAX_SIGNAL_PAYLOAD_BYTES));
let error = WorkflowSignal::new(
CheckpointId::new(),
WorkflowSignalName::parse("payload").unwrap(),
oversized,
)
.unwrap_err();
assert_eq!(error, WorkflowWaitError::SignalPayloadTooLarge);
assert!(
WorkflowSignal::new(
CheckpointId::new(),
WorkflowSignalName::parse("payload").unwrap(),
json!({"small": true}),
)
.is_ok()
);
}
#[test]
fn interrupt_inputs_are_bounded_and_reserved_from_external_signals() {
assert!(WorkflowSignalName::parse(format!("{INTERRUPT_SIGNAL_PREFIX}forged")).is_err());
assert!(WorkflowInterruptRequest::new(" ", json!({"amount": 42})).is_err());
assert!(
WorkflowInterruptRequest::new(
"x".repeat(MAX_INTERRUPT_PROMPT_BYTES + 1),
json!({"amount": 42}),
)
.is_err()
);
assert!(
WorkflowInterruptDecision::edit(Value::String("x".repeat(MAX_SIGNAL_PAYLOAD_BYTES)))
.is_err()
);
assert!(WorkflowInterruptDecision::reject(" ").is_err());
assert!(
WorkflowInterruptDecision::reject("x".repeat(MAX_INTERRUPT_REJECTION_BYTES + 1))
.is_err()
);
}
#[test]
fn interrupt_command_round_trips_for_remote_control_planes() {
let command = WorkflowInterruptCommand::new(
CheckpointId::new(),
WorkflowInterruptId::new(),
WorkflowInterruptDecision::edit(json!({"amount": 40})).unwrap(),
)
.unwrap();
let encoded = serde_json::to_value(&command).unwrap();
assert_eq!(
serde_json::from_value::<WorkflowInterruptCommand>(encoded).unwrap(),
command
);
}
}