Skip to main content

runifold_workflow/
wait.rs

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/// Invalid durable-wait or external-signal input.
14#[derive(Clone, Debug, Error, Eq, PartialEq)]
15#[non_exhaustive]
16pub enum WorkflowWaitError {
17    /// A portable signal name was required.
18    #[error("signal name must contain 1..=128 portable ASCII characters")]
19    InvalidSignalName,
20    /// Timer durations must fit in positive whole milliseconds.
21    #[error("durable timer must fit in a positive whole-millisecond duration")]
22    InvalidTimerDuration,
23    /// Retention periods must fit in positive whole milliseconds.
24    #[error("signal retention must fit in a positive whole-millisecond duration")]
25    InvalidRetention,
26    /// Signal payloads are deliberately bounded before persistence.
27    #[error("signal payload exceeds the 1 MiB durable limit")]
28    SignalPayloadTooLarge,
29    /// Human-review prompts are deliberately bounded before persistence.
30    #[error("interrupt prompt must contain 1..=16384 bytes")]
31    InvalidInterruptPrompt,
32    /// Edited human-review values are deliberately bounded before persistence.
33    #[error("interrupt decision payload exceeds the 1 MiB durable limit")]
34    InterruptPayloadTooLarge,
35    /// Rejection explanations are deliberately bounded before persistence.
36    #[error("interrupt rejection reason must contain 1..=4096 bytes")]
37    InvalidInterruptRejection,
38}
39
40/// Validated external-signal name.
41#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
42#[serde(transparent)]
43pub struct WorkflowSignalName(String);
44
45impl WorkflowSignalName {
46    /// Validates a portable signal name.
47    ///
48    /// # Errors
49    ///
50    /// Rejects blank, oversized, or non-portable names.
51    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    /// Returns the validated signal name.
65    pub fn as_str(&self) -> &str {
66        &self.0
67    }
68}
69
70/// Globally stable idempotency identity of one external signal publication.
71#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
72#[serde(transparent)]
73pub struct WorkflowSignalId(CheckpointId);
74
75impl WorkflowSignalId {
76    /// Generates a time-ordered signal identity.
77    pub fn new() -> Self {
78        Self(CheckpointId::new())
79    }
80
81    /// Uses an existing durable identity.
82    pub const fn from_checkpoint_id(id: CheckpointId) -> Self {
83        Self(id)
84    }
85
86    /// Returns the underlying UUID-backed identity.
87    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/// Stable identity of one durable human-review request.
99#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
100#[serde(transparent)]
101pub struct WorkflowInterruptId(CheckpointId);
102
103impl WorkflowInterruptId {
104    /// Generates a time-ordered interrupt identity.
105    pub fn new() -> Self {
106        Self(CheckpointId::new())
107    }
108
109    /// Uses an existing durable identity.
110    pub const fn from_checkpoint_id(id: CheckpointId) -> Self {
111        Self(id)
112    }
113
114    /// Returns the underlying UUID-backed identity.
115    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/// Persisted request presented to a human reviewer.
132#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
133#[doc(alias = "human approval")]
134#[doc(alias = "human in the loop")]
135pub struct WorkflowInterruptRequest {
136    /// Stable identity used by the decision command.
137    pub interrupt_id: WorkflowInterruptId,
138    /// Safe application-authored review instruction.
139    pub prompt: String,
140    /// Canonical value proposed by the preceding workflow node.
141    pub proposal: Value,
142}
143
144impl WorkflowInterruptRequest {
145    /// Creates a new durable review request.
146    ///
147    /// # Errors
148    ///
149    /// Rejects blank or oversized prompts and proposals above 1 MiB.
150    pub fn new(prompt: impl Into<String>, proposal: Value) -> Result<Self, WorkflowWaitError> {
151        Self::with_id(WorkflowInterruptId::new(), prompt, proposal)
152    }
153
154    /// Reconstructs a request with an existing durable identity.
155    ///
156    /// # Errors
157    ///
158    /// Rejects blank or oversized prompts and proposals above 1 MiB.
159    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/// Human decision applied to one durable interrupt.
188#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
189#[serde(tag = "kind", rename_all = "snake_case")]
190#[non_exhaustive]
191pub enum WorkflowInterruptDecision {
192    /// Accept the proposed value without modification.
193    Approve,
194    /// Replace the proposed value with a reviewed canonical value.
195    Edit {
196        /// Reviewer-supplied replacement.
197        value: Value,
198    },
199    /// Reject the proposal with a bounded operator-facing explanation.
200    Reject {
201        /// Safe rejection explanation.
202        reason: String,
203    },
204}
205
206impl WorkflowInterruptDecision {
207    /// Creates an approval decision.
208    pub const fn approve() -> Self {
209        Self::Approve
210    }
211
212    /// Creates an edited decision.
213    ///
214    /// # Errors
215    ///
216    /// Rejects values above the 1 MiB durable limit.
217    pub fn edit(value: Value) -> Result<Self, WorkflowWaitError> {
218        validate_interrupt_payload(&value)?;
219        Ok(Self::Edit { value })
220    }
221
222    /// Creates a rejection decision.
223    ///
224    /// # Errors
225    ///
226    /// Rejects blank explanations and values above 4 KiB.
227    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/// Idempotent control-plane command for one human-review request.
251#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
252pub struct WorkflowInterruptCommand {
253    /// Stable publication identity used for duplicate detection.
254    pub decision_id: WorkflowSignalId,
255    /// Workflow task awaiting the decision.
256    pub checkpoint_id: CheckpointId,
257    /// Exact request being decided.
258    pub interrupt_id: WorkflowInterruptId,
259    /// Typed reviewer decision.
260    pub decision: WorkflowInterruptDecision,
261}
262
263impl WorkflowInterruptCommand {
264    /// Creates a command with a generated idempotency identity.
265    ///
266    /// # Errors
267    ///
268    /// Rejects invalid edited values or rejection explanations.
269    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    /// Creates a command with a caller-owned idempotency identity.
283    ///
284    /// # Errors
285    ///
286    /// Rejects invalid edited values or rejection explanations.
287    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/// Result of submitting a typed human-review decision.
315#[derive(Clone, Copy, Debug, Eq, PartialEq)]
316#[non_exhaustive]
317pub enum WorkflowInterruptDecisionOutcome {
318    /// The decision was accepted before the worker committed its wait.
319    Buffered,
320    /// The decision atomically made the suspended workflow claimable.
321    WokeWorkflow,
322    /// An identical idempotency identity and decision already existed.
323    Duplicate,
324    /// The request was stale or the workflow had become terminal.
325    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/// Canonical downstream value produced by a human-review node.
340#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
341#[serde(tag = "kind", rename_all = "snake_case")]
342#[non_exhaustive]
343pub enum WorkflowInterruptOutcome {
344    /// The original proposal was approved.
345    Approved {
346        /// Unmodified proposed value.
347        value: Value,
348    },
349    /// The proposal was replaced by the reviewer.
350    Edited {
351        /// Reviewed replacement value.
352        value: Value,
353    },
354    /// The proposal was rejected.
355    Rejected {
356        /// Safe rejection explanation.
357        reason: String,
358    },
359}
360
361/// Durable reason for releasing a worker lease without completing a workflow.
362#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
363#[serde(tag = "kind", rename_all = "snake_case")]
364#[non_exhaustive]
365pub enum WorkflowWait {
366    /// Wake after a store-authoritative relative delay.
367    Timer {
368        /// Positive delay in whole milliseconds.
369        delay_ms: u64,
370    },
371    /// Wake when a signal with this name targets the workflow checkpoint.
372    Signal {
373        /// Stable signal name.
374        name: WorkflowSignalName,
375    },
376    /// Wake from the named signal or a store-authoritative timeout, whichever wins.
377    SignalOrTimeout {
378        /// Stable signal name.
379        name: WorkflowSignalName,
380        /// Positive timeout in whole milliseconds.
381        timeout_ms: u64,
382    },
383    /// Wake when a typed human-review decision targets this request.
384    Interrupt {
385        /// Persisted prompt, proposal, and stable decision identity.
386        request: WorkflowInterruptRequest,
387    },
388}
389
390impl WorkflowWait {
391    /// Creates a durable relative timer.
392    ///
393    /// # Errors
394    ///
395    /// Rejects zero, sub-millisecond, or overflowing durations.
396    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    /// Creates a named signal wait.
405    pub const fn signal(name: WorkflowSignalName) -> Self {
406        Self::Signal { name }
407    }
408
409    /// Creates a named signal wait with a durable timeout.
410    ///
411    /// # Errors
412    ///
413    /// Rejects zero, sub-millisecond, or overflowing durations.
414    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/// Durable value that caused a suspended workflow to become claimable.
427#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
428#[serde(tag = "kind", rename_all = "snake_case")]
429#[non_exhaustive]
430pub enum WorkflowWake {
431    /// A store-authoritative timer elapsed.
432    Timer,
433    /// The timeout side of a signal-or-timeout wait won.
434    Timeout,
435    /// A matching external signal was consumed.
436    Signal {
437        /// Stable publication identity.
438        signal_id: WorkflowSignalId,
439        /// Matched signal name.
440        name: WorkflowSignalName,
441        /// Canonical signal payload.
442        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/// Canonical output of a signal-or-timeout workflow node.
475#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
476#[serde(tag = "kind", rename_all = "snake_case")]
477#[non_exhaustive]
478pub enum WorkflowWaitOutcome {
479    /// The external signal won.
480    Signal {
481        /// Stable publication identity.
482        signal_id: WorkflowSignalId,
483        /// Matched signal name.
484        name: WorkflowSignalName,
485        /// Canonical signal payload.
486        payload: Value,
487    },
488    /// Store-authoritative time elapsed before a matching signal arrived.
489    TimedOut,
490}
491
492/// Idempotent external event targeted at one workflow checkpoint.
493#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
494pub struct WorkflowSignal {
495    /// Stable publication identity.
496    pub signal_id: WorkflowSignalId,
497    /// Target workflow checkpoint.
498    pub checkpoint_id: CheckpointId,
499    /// Signal name awaited by the workflow definition.
500    pub name: WorkflowSignalName,
501    /// Canonical payload delivered as the wait-node output.
502    pub payload: Value,
503}
504
505impl WorkflowSignal {
506    /// Creates a signal with a generated idempotency identity.
507    ///
508    /// # Errors
509    ///
510    /// Rejects payloads larger than the durable limit.
511    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    /// Creates a signal with a caller-supplied idempotency identity.
520    ///
521    /// # Errors
522    ///
523    /// Rejects payloads larger than the durable limit.
524    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/// Result of an idempotent signal publication.
545#[derive(Clone, Copy, Debug, Eq, PartialEq)]
546#[non_exhaustive]
547pub enum WorkflowSignalOutcome {
548    /// The signal was buffered before its matching wait was installed.
549    Buffered,
550    /// The signal atomically made its waiting workflow claimable.
551    WokeWorkflow,
552    /// An identical signal identity and payload had already been accepted.
553    Duplicate,
554    /// The target was already terminal or the signal lost its durable timeout race.
555    DeadLettered,
556}
557
558/// Durable lifecycle state of an accepted signal identity.
559#[derive(Clone, Copy, Debug, Eq, PartialEq)]
560#[non_exhaustive]
561pub enum WorkflowSignalState {
562    /// Accepted and available to a future matching wait.
563    Pending,
564    /// Atomically consumed by a matching wait.
565    Consumed,
566    /// Retained for audit but no longer eligible for delivery.
567    DeadLettered,
568}
569
570/// Safe signal metadata that deliberately excludes the payload.
571#[derive(Clone, Debug, Eq, PartialEq)]
572pub struct WorkflowSignalSnapshot {
573    /// Stable publication identity.
574    pub signal_id: WorkflowSignalId,
575    /// Tenant that owns the target workflow and signal identity.
576    pub tenant_id: crate::WorkflowTenantId,
577    /// Target workflow checkpoint.
578    pub checkpoint_id: CheckpointId,
579    /// Validated signal name.
580    pub name: WorkflowSignalName,
581    /// Current delivery lifecycle.
582    pub state: WorkflowSignalState,
583    /// Store-authoritative acceptance time.
584    pub accepted_at_ms: u64,
585}
586
587/// Retention period for consumed and dead-letter signal identities.
588#[derive(Clone, Copy, Debug, Eq, PartialEq)]
589pub struct WorkflowSignalRetention(NonZeroU64);
590
591impl WorkflowSignalRetention {
592    /// Creates a positive whole-millisecond retention period.
593    ///
594    /// # Errors
595    ///
596    /// Rejects zero, sub-millisecond, or overflowing durations.
597    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    /// Returns the normalized retention period.
606    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}