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