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 #[doc(hidden)]
120 pub fn signal_name(self) -> WorkflowSignalName {
121 WorkflowSignalName(format!("{INTERRUPT_SIGNAL_PREFIX}{}", self.0))
122 }
123}
124
125impl Default for WorkflowInterruptId {
126 fn default() -> Self {
127 Self::new()
128 }
129}
130
131#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
133#[doc(alias = "human approval")]
134#[doc(alias = "human in the loop")]
135pub struct WorkflowInterruptRequest {
136 pub interrupt_id: WorkflowInterruptId,
138 pub prompt: String,
140 pub proposal: Value,
142}
143
144impl WorkflowInterruptRequest {
145 pub fn new(prompt: impl Into<String>, proposal: Value) -> Result<Self, WorkflowWaitError> {
151 Self::with_id(WorkflowInterruptId::new(), prompt, proposal)
152 }
153
154 pub fn with_id(
160 interrupt_id: WorkflowInterruptId,
161 prompt: impl Into<String>,
162 proposal: Value,
163 ) -> Result<Self, WorkflowWaitError> {
164 let prompt = prompt.into();
165 Self::validate_prompt(&prompt)?;
166 validate_interrupt_payload(&proposal)?;
167 Ok(Self {
168 interrupt_id,
169 prompt,
170 proposal,
171 })
172 }
173
174 pub(crate) fn validate_prompt(prompt: &str) -> Result<(), WorkflowWaitError> {
175 if prompt.trim().is_empty() || prompt.len() > MAX_INTERRUPT_PROMPT_BYTES {
176 return Err(WorkflowWaitError::InvalidInterruptPrompt);
177 }
178 Ok(())
179 }
180
181 #[doc(hidden)]
182 pub fn signal_name(&self) -> WorkflowSignalName {
183 self.interrupt_id.signal_name()
184 }
185}
186
187#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
189#[serde(tag = "kind", rename_all = "snake_case")]
190#[non_exhaustive]
191pub enum WorkflowInterruptDecision {
192 Approve,
194 Edit {
196 value: Value,
198 },
199 Reject {
201 reason: String,
203 },
204}
205
206impl WorkflowInterruptDecision {
207 pub const fn approve() -> Self {
209 Self::Approve
210 }
211
212 pub fn edit(value: Value) -> Result<Self, WorkflowWaitError> {
218 validate_interrupt_payload(&value)?;
219 Ok(Self::Edit { value })
220 }
221
222 pub fn reject(reason: impl Into<String>) -> Result<Self, WorkflowWaitError> {
228 let reason = reason.into();
229 if reason.trim().is_empty() || reason.len() > MAX_INTERRUPT_REJECTION_BYTES {
230 return Err(WorkflowWaitError::InvalidInterruptRejection);
231 }
232 Ok(Self::Reject { reason })
233 }
234
235 pub(crate) fn validate(&self) -> Result<(), WorkflowWaitError> {
236 match self {
237 Self::Approve => Ok(()),
238 Self::Edit { value } => validate_interrupt_payload(value),
239 Self::Reject { reason } => {
240 if reason.trim().is_empty() || reason.len() > MAX_INTERRUPT_REJECTION_BYTES {
241 Err(WorkflowWaitError::InvalidInterruptRejection)
242 } else {
243 Ok(())
244 }
245 }
246 }
247 }
248}
249
250#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
252pub struct WorkflowInterruptCommand {
253 pub decision_id: WorkflowSignalId,
255 pub checkpoint_id: CheckpointId,
257 pub interrupt_id: WorkflowInterruptId,
259 pub decision: WorkflowInterruptDecision,
261}
262
263impl WorkflowInterruptCommand {
264 pub fn new(
270 checkpoint_id: CheckpointId,
271 interrupt_id: WorkflowInterruptId,
272 decision: WorkflowInterruptDecision,
273 ) -> Result<Self, WorkflowWaitError> {
274 Self::with_id(
275 WorkflowSignalId::new(),
276 checkpoint_id,
277 interrupt_id,
278 decision,
279 )
280 }
281
282 pub fn with_id(
288 decision_id: WorkflowSignalId,
289 checkpoint_id: CheckpointId,
290 interrupt_id: WorkflowInterruptId,
291 decision: WorkflowInterruptDecision,
292 ) -> Result<Self, WorkflowWaitError> {
293 decision.validate()?;
294 Ok(Self {
295 decision_id,
296 checkpoint_id,
297 interrupt_id,
298 decision,
299 })
300 }
301
302 pub(crate) fn into_signal(self) -> Result<WorkflowSignal, WorkflowWaitError> {
303 let payload = serde_json::to_value(self.decision)
304 .map_err(|_| WorkflowWaitError::InterruptPayloadTooLarge)?;
305 WorkflowSignal::with_id(
306 self.decision_id,
307 self.checkpoint_id,
308 self.interrupt_id.signal_name(),
309 payload,
310 )
311 }
312}
313
314#[derive(Clone, Copy, Debug, Eq, PartialEq)]
316#[non_exhaustive]
317pub enum WorkflowInterruptDecisionOutcome {
318 Buffered,
320 WokeWorkflow,
322 Duplicate,
324 DeadLettered,
326}
327
328impl From<WorkflowSignalOutcome> for WorkflowInterruptDecisionOutcome {
329 fn from(value: WorkflowSignalOutcome) -> Self {
330 match value {
331 WorkflowSignalOutcome::Buffered => Self::Buffered,
332 WorkflowSignalOutcome::WokeWorkflow => Self::WokeWorkflow,
333 WorkflowSignalOutcome::Duplicate => Self::Duplicate,
334 WorkflowSignalOutcome::DeadLettered => Self::DeadLettered,
335 }
336 }
337}
338
339#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
341#[serde(tag = "kind", rename_all = "snake_case")]
342#[non_exhaustive]
343pub enum WorkflowInterruptOutcome {
344 Approved {
346 value: Value,
348 },
349 Edited {
351 value: Value,
353 },
354 Rejected {
356 reason: String,
358 },
359}
360
361#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
363#[serde(tag = "kind", rename_all = "snake_case")]
364#[non_exhaustive]
365pub enum WorkflowWait {
366 Timer {
368 delay_ms: u64,
370 },
371 Signal {
373 name: WorkflowSignalName,
375 },
376 SignalOrTimeout {
378 name: WorkflowSignalName,
380 timeout_ms: u64,
382 },
383 Interrupt {
385 request: WorkflowInterruptRequest,
387 },
388}
389
390impl WorkflowWait {
391 pub fn timer(delay: Duration) -> Result<Self, WorkflowWaitError> {
397 let delay_ms = u64::try_from(delay.as_millis())
398 .ok()
399 .filter(|value| *value > 0)
400 .ok_or(WorkflowWaitError::InvalidTimerDuration)?;
401 Ok(Self::Timer { delay_ms })
402 }
403
404 pub const fn signal(name: WorkflowSignalName) -> Self {
406 Self::Signal { name }
407 }
408
409 pub fn signal_or_timeout(
415 name: WorkflowSignalName,
416 timeout: Duration,
417 ) -> Result<Self, WorkflowWaitError> {
418 let timeout_ms = u64::try_from(timeout.as_millis())
419 .ok()
420 .filter(|value| *value > 0)
421 .ok_or(WorkflowWaitError::InvalidTimerDuration)?;
422 Ok(Self::SignalOrTimeout { name, timeout_ms })
423 }
424}
425
426#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
428#[serde(tag = "kind", rename_all = "snake_case")]
429#[non_exhaustive]
430pub enum WorkflowWake {
431 Timer,
433 Timeout,
435 Signal {
437 signal_id: WorkflowSignalId,
439 name: WorkflowSignalName,
441 payload: Value,
443 },
444}
445
446impl WorkflowWake {
447 pub(crate) fn matches(&self, wait: &WorkflowWait) -> bool {
448 match (self, wait) {
449 (Self::Timer, WorkflowWait::Timer { .. })
450 | (Self::Timeout, WorkflowWait::SignalOrTimeout { .. }) => true,
451 (Self::Signal { name: actual, .. }, WorkflowWait::Signal { name: expected }) => {
452 actual == expected
453 }
454 (
455 Self::Signal { name: actual, .. },
456 WorkflowWait::SignalOrTimeout { name: expected, .. },
457 ) => actual == expected,
458 (Self::Signal { name: actual, .. }, WorkflowWait::Interrupt { request }) => {
459 *actual == request.signal_name()
460 }
461 _ => false,
462 }
463 }
464}
465
466fn validate_interrupt_payload(value: &Value) -> Result<(), WorkflowWaitError> {
467 if serde_json::to_vec(value).is_ok_and(|encoded| encoded.len() > MAX_SIGNAL_PAYLOAD_BYTES) {
468 Err(WorkflowWaitError::InterruptPayloadTooLarge)
469 } else {
470 Ok(())
471 }
472}
473
474#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
476#[serde(tag = "kind", rename_all = "snake_case")]
477#[non_exhaustive]
478pub enum WorkflowWaitOutcome {
479 Signal {
481 signal_id: WorkflowSignalId,
483 name: WorkflowSignalName,
485 payload: Value,
487 },
488 TimedOut,
490}
491
492#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
494pub struct WorkflowSignal {
495 pub signal_id: WorkflowSignalId,
497 pub checkpoint_id: CheckpointId,
499 pub name: WorkflowSignalName,
501 pub payload: Value,
503}
504
505impl WorkflowSignal {
506 pub fn new(
512 checkpoint_id: CheckpointId,
513 name: WorkflowSignalName,
514 payload: Value,
515 ) -> Result<Self, WorkflowWaitError> {
516 Self::with_id(WorkflowSignalId::new(), checkpoint_id, name, payload)
517 }
518
519 pub fn with_id(
525 signal_id: WorkflowSignalId,
526 checkpoint_id: CheckpointId,
527 name: WorkflowSignalName,
528 payload: Value,
529 ) -> Result<Self, WorkflowWaitError> {
530 if serde_json::to_vec(&payload)
531 .is_ok_and(|encoded| encoded.len() > MAX_SIGNAL_PAYLOAD_BYTES)
532 {
533 return Err(WorkflowWaitError::SignalPayloadTooLarge);
534 }
535 Ok(Self {
536 signal_id,
537 checkpoint_id,
538 name,
539 payload,
540 })
541 }
542}
543
544#[derive(Clone, Copy, Debug, Eq, PartialEq)]
546#[non_exhaustive]
547pub enum WorkflowSignalOutcome {
548 Buffered,
550 WokeWorkflow,
552 Duplicate,
554 DeadLettered,
556}
557
558#[derive(Clone, Copy, Debug, Eq, PartialEq)]
560#[non_exhaustive]
561pub enum WorkflowSignalState {
562 Pending,
564 Consumed,
566 DeadLettered,
568}
569
570#[derive(Clone, Debug, Eq, PartialEq)]
572pub struct WorkflowSignalSnapshot {
573 pub signal_id: WorkflowSignalId,
575 pub tenant_id: crate::WorkflowTenantId,
577 pub checkpoint_id: CheckpointId,
579 pub name: WorkflowSignalName,
581 pub state: WorkflowSignalState,
583 pub accepted_at_ms: u64,
585}
586
587#[derive(Clone, Copy, Debug, Eq, PartialEq)]
589pub struct WorkflowSignalRetention(NonZeroU64);
590
591impl WorkflowSignalRetention {
592 pub fn new(duration: Duration) -> Result<Self, WorkflowWaitError> {
598 let millis = u64::try_from(duration.as_millis())
599 .ok()
600 .and_then(NonZeroU64::new)
601 .ok_or(WorkflowWaitError::InvalidRetention)?;
602 Ok(Self(millis))
603 }
604
605 pub const fn as_millis(self) -> u64 {
607 self.0.get()
608 }
609}
610
611#[cfg(test)]
612mod tests {
613 use serde_json::json;
614
615 use super::*;
616
617 #[test]
618 fn wait_inputs_enforce_portable_bounded_values() {
619 assert!(WorkflowSignalName::parse("approval.received").is_ok());
620 assert!(WorkflowSignalName::parse("approval received").is_err());
621 assert!(WorkflowWait::timer(Duration::ZERO).is_err());
622 assert!(WorkflowWait::timer(Duration::from_nanos(1)).is_err());
623 assert!(WorkflowWait::timer(Duration::from_millis(1)).is_ok());
624 assert!(
625 WorkflowWait::signal_or_timeout(
626 WorkflowSignalName::parse("approval").unwrap(),
627 Duration::ZERO,
628 )
629 .is_err()
630 );
631 }
632
633 #[test]
634 fn signal_payload_is_bounded_before_persistence() {
635 let oversized = Value::String("x".repeat(MAX_SIGNAL_PAYLOAD_BYTES));
636 let error = WorkflowSignal::new(
637 CheckpointId::new(),
638 WorkflowSignalName::parse("payload").unwrap(),
639 oversized,
640 )
641 .unwrap_err();
642
643 assert_eq!(error, WorkflowWaitError::SignalPayloadTooLarge);
644 assert!(
645 WorkflowSignal::new(
646 CheckpointId::new(),
647 WorkflowSignalName::parse("payload").unwrap(),
648 json!({"small": true}),
649 )
650 .is_ok()
651 );
652 }
653
654 #[test]
655 fn interrupt_inputs_are_bounded_and_reserved_from_external_signals() {
656 assert!(WorkflowSignalName::parse(format!("{INTERRUPT_SIGNAL_PREFIX}forged")).is_err());
657 assert!(WorkflowInterruptRequest::new(" ", json!({"amount": 42})).is_err());
658 assert!(
659 WorkflowInterruptRequest::new(
660 "x".repeat(MAX_INTERRUPT_PROMPT_BYTES + 1),
661 json!({"amount": 42}),
662 )
663 .is_err()
664 );
665 assert!(
666 WorkflowInterruptDecision::edit(Value::String("x".repeat(MAX_SIGNAL_PAYLOAD_BYTES)))
667 .is_err()
668 );
669 assert!(WorkflowInterruptDecision::reject(" ").is_err());
670 assert!(
671 WorkflowInterruptDecision::reject("x".repeat(MAX_INTERRUPT_REJECTION_BYTES + 1))
672 .is_err()
673 );
674 }
675
676 #[test]
677 fn interrupt_command_round_trips_for_remote_control_planes() {
678 let command = WorkflowInterruptCommand::new(
679 CheckpointId::new(),
680 WorkflowInterruptId::new(),
681 WorkflowInterruptDecision::edit(json!({"amount": 40})).unwrap(),
682 )
683 .unwrap();
684
685 let encoded = serde_json::to_value(&command).unwrap();
686 assert_eq!(
687 serde_json::from_value::<WorkflowInterruptCommand>(encoded).unwrap(),
688 command
689 );
690 }
691}