1use std::{num::NonZeroU64, time::Duration};
2
3use runifold_core::CheckpointId;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use thiserror::Error;
7
8const MAX_SIGNAL_PAYLOAD_BYTES: usize = 1_048_576;
9const MAX_INTERRUPT_PROMPT_BYTES: usize = 16_384;
10const MAX_INTERRUPT_REJECTION_BYTES: usize = 4_096;
11const INTERRUPT_SIGNAL_PREFIX: &str = "__runifold.interrupt.";
12
13#[derive(Clone, Debug, Error, Eq, PartialEq)]
15#[non_exhaustive]
16pub enum WorkflowWaitError {
17 #[error("signal name must contain 1..=128 portable ASCII characters")]
19 InvalidSignalName,
20 #[error("durable timer must fit in a positive whole-millisecond duration")]
22 InvalidTimerDuration,
23 #[error("signal retention must fit in a positive whole-millisecond duration")]
25 InvalidRetention,
26 #[error("signal payload exceeds the 1 MiB durable limit")]
28 SignalPayloadTooLarge,
29 #[error("interrupt prompt must contain 1..=16384 bytes")]
31 InvalidInterruptPrompt,
32 #[error("interrupt decision payload exceeds the 1 MiB durable limit")]
34 InterruptPayloadTooLarge,
35 #[error("interrupt rejection reason must contain 1..=4096 bytes")]
37 InvalidInterruptRejection,
38}
39
40#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
42#[serde(transparent)]
43pub struct WorkflowSignalName(String);
44
45impl WorkflowSignalName {
46 pub fn parse(value: impl Into<String>) -> Result<Self, WorkflowWaitError> {
52 let value = value.into();
53 let valid = !value.is_empty()
54 && value.len() <= 128
55 && !value.starts_with(INTERRUPT_SIGNAL_PREFIX)
56 && value
57 .bytes()
58 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'));
59 valid
60 .then_some(Self(value))
61 .ok_or(WorkflowWaitError::InvalidSignalName)
62 }
63
64 pub fn as_str(&self) -> &str {
66 &self.0
67 }
68}
69
70#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
72#[serde(transparent)]
73pub struct WorkflowSignalId(CheckpointId);
74
75impl WorkflowSignalId {
76 pub fn new() -> Self {
78 Self(CheckpointId::new())
79 }
80
81 pub const fn from_checkpoint_id(id: CheckpointId) -> Self {
83 Self(id)
84 }
85
86 pub const fn as_checkpoint_id(self) -> CheckpointId {
88 self.0
89 }
90}
91
92impl Default for WorkflowSignalId {
93 fn default() -> Self {
94 Self::new()
95 }
96}
97
98#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
100#[serde(transparent)]
101pub struct WorkflowInterruptId(CheckpointId);
102
103impl WorkflowInterruptId {
104 pub fn new() -> Self {
106 Self(CheckpointId::new())
107 }
108
109 pub const fn from_checkpoint_id(id: CheckpointId) -> Self {
111 Self(id)
112 }
113
114 pub const fn as_checkpoint_id(self) -> CheckpointId {
116 self.0
117 }
118
119 pub(crate) fn signal_name(self) -> WorkflowSignalName {
120 WorkflowSignalName(format!("{INTERRUPT_SIGNAL_PREFIX}{}", self.0))
121 }
122}
123
124impl Default for WorkflowInterruptId {
125 fn default() -> Self {
126 Self::new()
127 }
128}
129
130#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
132pub struct WorkflowInterruptRequest {
133 pub interrupt_id: WorkflowInterruptId,
135 pub prompt: String,
137 pub proposal: Value,
139}
140
141impl WorkflowInterruptRequest {
142 pub fn new(prompt: impl Into<String>, proposal: Value) -> Result<Self, WorkflowWaitError> {
148 Self::with_id(WorkflowInterruptId::new(), prompt, proposal)
149 }
150
151 pub fn with_id(
157 interrupt_id: WorkflowInterruptId,
158 prompt: impl Into<String>,
159 proposal: Value,
160 ) -> Result<Self, WorkflowWaitError> {
161 let prompt = prompt.into();
162 Self::validate_prompt(&prompt)?;
163 validate_interrupt_payload(&proposal)?;
164 Ok(Self {
165 interrupt_id,
166 prompt,
167 proposal,
168 })
169 }
170
171 pub(crate) fn validate_prompt(prompt: &str) -> Result<(), WorkflowWaitError> {
172 if prompt.trim().is_empty() || prompt.len() > MAX_INTERRUPT_PROMPT_BYTES {
173 return Err(WorkflowWaitError::InvalidInterruptPrompt);
174 }
175 Ok(())
176 }
177
178 #[doc(hidden)]
179 pub fn signal_name(&self) -> WorkflowSignalName {
180 self.interrupt_id.signal_name()
181 }
182}
183
184#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
186#[serde(tag = "kind", rename_all = "snake_case")]
187#[non_exhaustive]
188pub enum WorkflowInterruptDecision {
189 Approve,
191 Edit {
193 value: Value,
195 },
196 Reject {
198 reason: String,
200 },
201}
202
203impl WorkflowInterruptDecision {
204 pub const fn approve() -> Self {
206 Self::Approve
207 }
208
209 pub fn edit(value: Value) -> Result<Self, WorkflowWaitError> {
215 validate_interrupt_payload(&value)?;
216 Ok(Self::Edit { value })
217 }
218
219 pub fn reject(reason: impl Into<String>) -> Result<Self, WorkflowWaitError> {
225 let reason = reason.into();
226 if reason.trim().is_empty() || reason.len() > MAX_INTERRUPT_REJECTION_BYTES {
227 return Err(WorkflowWaitError::InvalidInterruptRejection);
228 }
229 Ok(Self::Reject { reason })
230 }
231
232 pub(crate) fn validate(&self) -> Result<(), WorkflowWaitError> {
233 match self {
234 Self::Approve => Ok(()),
235 Self::Edit { value } => validate_interrupt_payload(value),
236 Self::Reject { reason } => {
237 if reason.trim().is_empty() || reason.len() > MAX_INTERRUPT_REJECTION_BYTES {
238 Err(WorkflowWaitError::InvalidInterruptRejection)
239 } else {
240 Ok(())
241 }
242 }
243 }
244 }
245}
246
247#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
249pub struct WorkflowInterruptCommand {
250 pub decision_id: WorkflowSignalId,
252 pub checkpoint_id: CheckpointId,
254 pub interrupt_id: WorkflowInterruptId,
256 pub decision: WorkflowInterruptDecision,
258}
259
260impl WorkflowInterruptCommand {
261 pub fn new(
267 checkpoint_id: CheckpointId,
268 interrupt_id: WorkflowInterruptId,
269 decision: WorkflowInterruptDecision,
270 ) -> Result<Self, WorkflowWaitError> {
271 Self::with_id(
272 WorkflowSignalId::new(),
273 checkpoint_id,
274 interrupt_id,
275 decision,
276 )
277 }
278
279 pub fn with_id(
285 decision_id: WorkflowSignalId,
286 checkpoint_id: CheckpointId,
287 interrupt_id: WorkflowInterruptId,
288 decision: WorkflowInterruptDecision,
289 ) -> Result<Self, WorkflowWaitError> {
290 decision.validate()?;
291 Ok(Self {
292 decision_id,
293 checkpoint_id,
294 interrupt_id,
295 decision,
296 })
297 }
298
299 pub(crate) fn into_signal(self) -> Result<WorkflowSignal, WorkflowWaitError> {
300 let payload = serde_json::to_value(self.decision)
301 .map_err(|_| WorkflowWaitError::InterruptPayloadTooLarge)?;
302 WorkflowSignal::with_id(
303 self.decision_id,
304 self.checkpoint_id,
305 self.interrupt_id.signal_name(),
306 payload,
307 )
308 }
309}
310
311#[derive(Clone, Copy, Debug, Eq, PartialEq)]
313#[non_exhaustive]
314pub enum WorkflowInterruptDecisionOutcome {
315 Buffered,
317 WokeWorkflow,
319 Duplicate,
321 DeadLettered,
323}
324
325impl From<WorkflowSignalOutcome> for WorkflowInterruptDecisionOutcome {
326 fn from(value: WorkflowSignalOutcome) -> Self {
327 match value {
328 WorkflowSignalOutcome::Buffered => Self::Buffered,
329 WorkflowSignalOutcome::WokeWorkflow => Self::WokeWorkflow,
330 WorkflowSignalOutcome::Duplicate => Self::Duplicate,
331 WorkflowSignalOutcome::DeadLettered => Self::DeadLettered,
332 }
333 }
334}
335
336#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
338#[serde(tag = "kind", rename_all = "snake_case")]
339#[non_exhaustive]
340pub enum WorkflowInterruptOutcome {
341 Approved {
343 value: Value,
345 },
346 Edited {
348 value: Value,
350 },
351 Rejected {
353 reason: String,
355 },
356}
357
358#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
360#[serde(tag = "kind", rename_all = "snake_case")]
361#[non_exhaustive]
362pub enum WorkflowWait {
363 Timer {
365 delay_ms: u64,
367 },
368 Signal {
370 name: WorkflowSignalName,
372 },
373 SignalOrTimeout {
375 name: WorkflowSignalName,
377 timeout_ms: u64,
379 },
380 Interrupt {
382 request: WorkflowInterruptRequest,
384 },
385}
386
387impl WorkflowWait {
388 pub fn timer(delay: Duration) -> Result<Self, WorkflowWaitError> {
394 let delay_ms = u64::try_from(delay.as_millis())
395 .ok()
396 .filter(|value| *value > 0)
397 .ok_or(WorkflowWaitError::InvalidTimerDuration)?;
398 Ok(Self::Timer { delay_ms })
399 }
400
401 pub const fn signal(name: WorkflowSignalName) -> Self {
403 Self::Signal { name }
404 }
405
406 pub fn signal_or_timeout(
412 name: WorkflowSignalName,
413 timeout: Duration,
414 ) -> Result<Self, WorkflowWaitError> {
415 let timeout_ms = u64::try_from(timeout.as_millis())
416 .ok()
417 .filter(|value| *value > 0)
418 .ok_or(WorkflowWaitError::InvalidTimerDuration)?;
419 Ok(Self::SignalOrTimeout { name, timeout_ms })
420 }
421}
422
423#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
425#[serde(tag = "kind", rename_all = "snake_case")]
426#[non_exhaustive]
427pub enum WorkflowWake {
428 Timer,
430 Timeout,
432 Signal {
434 signal_id: WorkflowSignalId,
436 name: WorkflowSignalName,
438 payload: Value,
440 },
441}
442
443impl WorkflowWake {
444 pub(crate) fn matches(&self, wait: &WorkflowWait) -> bool {
445 match (self, wait) {
446 (Self::Timer, WorkflowWait::Timer { .. })
447 | (Self::Timeout, WorkflowWait::SignalOrTimeout { .. }) => true,
448 (Self::Signal { name: actual, .. }, WorkflowWait::Signal { name: expected }) => {
449 actual == expected
450 }
451 (
452 Self::Signal { name: actual, .. },
453 WorkflowWait::SignalOrTimeout { name: expected, .. },
454 ) => actual == expected,
455 (Self::Signal { name: actual, .. }, WorkflowWait::Interrupt { request }) => {
456 *actual == request.signal_name()
457 }
458 _ => false,
459 }
460 }
461}
462
463fn validate_interrupt_payload(value: &Value) -> Result<(), WorkflowWaitError> {
464 if serde_json::to_vec(value).is_ok_and(|encoded| encoded.len() > MAX_SIGNAL_PAYLOAD_BYTES) {
465 Err(WorkflowWaitError::InterruptPayloadTooLarge)
466 } else {
467 Ok(())
468 }
469}
470
471#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
473#[serde(tag = "kind", rename_all = "snake_case")]
474#[non_exhaustive]
475pub enum WorkflowWaitOutcome {
476 Signal {
478 signal_id: WorkflowSignalId,
480 name: WorkflowSignalName,
482 payload: Value,
484 },
485 TimedOut,
487}
488
489#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
491pub struct WorkflowSignal {
492 pub signal_id: WorkflowSignalId,
494 pub checkpoint_id: CheckpointId,
496 pub name: WorkflowSignalName,
498 pub payload: Value,
500}
501
502impl WorkflowSignal {
503 pub fn new(
509 checkpoint_id: CheckpointId,
510 name: WorkflowSignalName,
511 payload: Value,
512 ) -> Result<Self, WorkflowWaitError> {
513 Self::with_id(WorkflowSignalId::new(), checkpoint_id, name, payload)
514 }
515
516 pub fn with_id(
522 signal_id: WorkflowSignalId,
523 checkpoint_id: CheckpointId,
524 name: WorkflowSignalName,
525 payload: Value,
526 ) -> Result<Self, WorkflowWaitError> {
527 if serde_json::to_vec(&payload)
528 .is_ok_and(|encoded| encoded.len() > MAX_SIGNAL_PAYLOAD_BYTES)
529 {
530 return Err(WorkflowWaitError::SignalPayloadTooLarge);
531 }
532 Ok(Self {
533 signal_id,
534 checkpoint_id,
535 name,
536 payload,
537 })
538 }
539}
540
541#[derive(Clone, Copy, Debug, Eq, PartialEq)]
543#[non_exhaustive]
544pub enum WorkflowSignalOutcome {
545 Buffered,
547 WokeWorkflow,
549 Duplicate,
551 DeadLettered,
553}
554
555#[derive(Clone, Copy, Debug, Eq, PartialEq)]
557#[non_exhaustive]
558pub enum WorkflowSignalState {
559 Pending,
561 Consumed,
563 DeadLettered,
565}
566
567#[derive(Clone, Debug, Eq, PartialEq)]
569pub struct WorkflowSignalSnapshot {
570 pub signal_id: WorkflowSignalId,
572 pub tenant_id: crate::WorkflowTenantId,
574 pub checkpoint_id: CheckpointId,
576 pub name: WorkflowSignalName,
578 pub state: WorkflowSignalState,
580 pub accepted_at_ms: u64,
582}
583
584#[derive(Clone, Copy, Debug, Eq, PartialEq)]
586pub struct WorkflowSignalRetention(NonZeroU64);
587
588impl WorkflowSignalRetention {
589 pub fn new(duration: Duration) -> Result<Self, WorkflowWaitError> {
595 let millis = u64::try_from(duration.as_millis())
596 .ok()
597 .and_then(NonZeroU64::new)
598 .ok_or(WorkflowWaitError::InvalidRetention)?;
599 Ok(Self(millis))
600 }
601
602 pub const fn as_millis(self) -> u64 {
604 self.0.get()
605 }
606}
607
608#[cfg(test)]
609mod tests {
610 use serde_json::json;
611
612 use super::*;
613
614 #[test]
615 fn wait_inputs_enforce_portable_bounded_values() {
616 assert!(WorkflowSignalName::parse("approval.received").is_ok());
617 assert!(WorkflowSignalName::parse("approval received").is_err());
618 assert!(WorkflowWait::timer(Duration::ZERO).is_err());
619 assert!(WorkflowWait::timer(Duration::from_nanos(1)).is_err());
620 assert!(WorkflowWait::timer(Duration::from_millis(1)).is_ok());
621 assert!(
622 WorkflowWait::signal_or_timeout(
623 WorkflowSignalName::parse("approval").unwrap(),
624 Duration::ZERO,
625 )
626 .is_err()
627 );
628 }
629
630 #[test]
631 fn signal_payload_is_bounded_before_persistence() {
632 let oversized = Value::String("x".repeat(MAX_SIGNAL_PAYLOAD_BYTES));
633 let error = WorkflowSignal::new(
634 CheckpointId::new(),
635 WorkflowSignalName::parse("payload").unwrap(),
636 oversized,
637 )
638 .unwrap_err();
639
640 assert_eq!(error, WorkflowWaitError::SignalPayloadTooLarge);
641 assert!(
642 WorkflowSignal::new(
643 CheckpointId::new(),
644 WorkflowSignalName::parse("payload").unwrap(),
645 json!({"small": true}),
646 )
647 .is_ok()
648 );
649 }
650
651 #[test]
652 fn interrupt_inputs_are_bounded_and_reserved_from_external_signals() {
653 assert!(WorkflowSignalName::parse(format!("{INTERRUPT_SIGNAL_PREFIX}forged")).is_err());
654 assert!(WorkflowInterruptRequest::new(" ", json!({"amount": 42})).is_err());
655 assert!(
656 WorkflowInterruptRequest::new(
657 "x".repeat(MAX_INTERRUPT_PROMPT_BYTES + 1),
658 json!({"amount": 42}),
659 )
660 .is_err()
661 );
662 assert!(
663 WorkflowInterruptDecision::edit(Value::String("x".repeat(MAX_SIGNAL_PAYLOAD_BYTES)))
664 .is_err()
665 );
666 assert!(WorkflowInterruptDecision::reject(" ").is_err());
667 assert!(
668 WorkflowInterruptDecision::reject("x".repeat(MAX_INTERRUPT_REJECTION_BYTES + 1))
669 .is_err()
670 );
671 }
672
673 #[test]
674 fn interrupt_command_round_trips_for_remote_control_planes() {
675 let command = WorkflowInterruptCommand::new(
676 CheckpointId::new(),
677 WorkflowInterruptId::new(),
678 WorkflowInterruptDecision::edit(json!({"amount": 40})).unwrap(),
679 )
680 .unwrap();
681
682 let encoded = serde_json::to_value(&command).unwrap();
683 assert_eq!(
684 serde_json::from_value::<WorkflowInterruptCommand>(encoded).unwrap(),
685 command
686 );
687 }
688}