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)]
133pub struct WorkflowInterruptRequest {
134    /// Stable identity used by the decision command.
135    pub interrupt_id: WorkflowInterruptId,
136    /// Safe application-authored review instruction.
137    pub prompt: String,
138    /// Canonical value proposed by the preceding workflow node.
139    pub proposal: Value,
140}
141
142impl WorkflowInterruptRequest {
143    /// Creates a new durable review request.
144    ///
145    /// # Errors
146    ///
147    /// Rejects blank or oversized prompts and proposals above 1 MiB.
148    pub fn new(prompt: impl Into<String>, proposal: Value) -> Result<Self, WorkflowWaitError> {
149        Self::with_id(WorkflowInterruptId::new(), prompt, proposal)
150    }
151
152    /// Reconstructs a request with an existing durable identity.
153    ///
154    /// # Errors
155    ///
156    /// Rejects blank or oversized prompts and proposals above 1 MiB.
157    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/// Human decision applied to one durable interrupt.
186#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
187#[serde(tag = "kind", rename_all = "snake_case")]
188#[non_exhaustive]
189pub enum WorkflowInterruptDecision {
190    /// Accept the proposed value without modification.
191    Approve,
192    /// Replace the proposed value with a reviewed canonical value.
193    Edit {
194        /// Reviewer-supplied replacement.
195        value: Value,
196    },
197    /// Reject the proposal with a bounded operator-facing explanation.
198    Reject {
199        /// Safe rejection explanation.
200        reason: String,
201    },
202}
203
204impl WorkflowInterruptDecision {
205    /// Creates an approval decision.
206    pub const fn approve() -> Self {
207        Self::Approve
208    }
209
210    /// Creates an edited decision.
211    ///
212    /// # Errors
213    ///
214    /// Rejects values above the 1 MiB durable limit.
215    pub fn edit(value: Value) -> Result<Self, WorkflowWaitError> {
216        validate_interrupt_payload(&value)?;
217        Ok(Self::Edit { value })
218    }
219
220    /// Creates a rejection decision.
221    ///
222    /// # Errors
223    ///
224    /// Rejects blank explanations and values above 4 KiB.
225    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/// Idempotent control-plane command for one human-review request.
249#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
250pub struct WorkflowInterruptCommand {
251    /// Stable publication identity used for duplicate detection.
252    pub decision_id: WorkflowSignalId,
253    /// Workflow task awaiting the decision.
254    pub checkpoint_id: CheckpointId,
255    /// Exact request being decided.
256    pub interrupt_id: WorkflowInterruptId,
257    /// Typed reviewer decision.
258    pub decision: WorkflowInterruptDecision,
259}
260
261impl WorkflowInterruptCommand {
262    /// Creates a command with a generated idempotency identity.
263    ///
264    /// # Errors
265    ///
266    /// Rejects invalid edited values or rejection explanations.
267    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    /// Creates a command with a caller-owned idempotency identity.
281    ///
282    /// # Errors
283    ///
284    /// Rejects invalid edited values or rejection explanations.
285    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/// Result of submitting a typed human-review decision.
313#[derive(Clone, Copy, Debug, Eq, PartialEq)]
314#[non_exhaustive]
315pub enum WorkflowInterruptDecisionOutcome {
316    /// The decision was accepted before the worker committed its wait.
317    Buffered,
318    /// The decision atomically made the suspended workflow claimable.
319    WokeWorkflow,
320    /// An identical idempotency identity and decision already existed.
321    Duplicate,
322    /// The request was stale or the workflow had become terminal.
323    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/// Canonical downstream value produced by a human-review node.
338#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
339#[serde(tag = "kind", rename_all = "snake_case")]
340#[non_exhaustive]
341pub enum WorkflowInterruptOutcome {
342    /// The original proposal was approved.
343    Approved {
344        /// Unmodified proposed value.
345        value: Value,
346    },
347    /// The proposal was replaced by the reviewer.
348    Edited {
349        /// Reviewed replacement value.
350        value: Value,
351    },
352    /// The proposal was rejected.
353    Rejected {
354        /// Safe rejection explanation.
355        reason: String,
356    },
357}
358
359/// Durable reason for releasing a worker lease without completing a workflow.
360#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
361#[serde(tag = "kind", rename_all = "snake_case")]
362#[non_exhaustive]
363pub enum WorkflowWait {
364    /// Wake after a store-authoritative relative delay.
365    Timer {
366        /// Positive delay in whole milliseconds.
367        delay_ms: u64,
368    },
369    /// Wake when a signal with this name targets the workflow checkpoint.
370    Signal {
371        /// Stable signal name.
372        name: WorkflowSignalName,
373    },
374    /// Wake from the named signal or a store-authoritative timeout, whichever wins.
375    SignalOrTimeout {
376        /// Stable signal name.
377        name: WorkflowSignalName,
378        /// Positive timeout in whole milliseconds.
379        timeout_ms: u64,
380    },
381    /// Wake when a typed human-review decision targets this request.
382    Interrupt {
383        /// Persisted prompt, proposal, and stable decision identity.
384        request: WorkflowInterruptRequest,
385    },
386}
387
388impl WorkflowWait {
389    /// Creates a durable relative timer.
390    ///
391    /// # Errors
392    ///
393    /// Rejects zero, sub-millisecond, or overflowing durations.
394    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    /// Creates a named signal wait.
403    pub const fn signal(name: WorkflowSignalName) -> Self {
404        Self::Signal { name }
405    }
406
407    /// Creates a named signal wait with a durable timeout.
408    ///
409    /// # Errors
410    ///
411    /// Rejects zero, sub-millisecond, or overflowing durations.
412    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/// Durable value that caused a suspended workflow to become claimable.
425#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
426#[serde(tag = "kind", rename_all = "snake_case")]
427#[non_exhaustive]
428pub enum WorkflowWake {
429    /// A store-authoritative timer elapsed.
430    Timer,
431    /// The timeout side of a signal-or-timeout wait won.
432    Timeout,
433    /// A matching external signal was consumed.
434    Signal {
435        /// Stable publication identity.
436        signal_id: WorkflowSignalId,
437        /// Matched signal name.
438        name: WorkflowSignalName,
439        /// Canonical signal payload.
440        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/// Canonical output of a signal-or-timeout workflow node.
473#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
474#[serde(tag = "kind", rename_all = "snake_case")]
475#[non_exhaustive]
476pub enum WorkflowWaitOutcome {
477    /// The external signal won.
478    Signal {
479        /// Stable publication identity.
480        signal_id: WorkflowSignalId,
481        /// Matched signal name.
482        name: WorkflowSignalName,
483        /// Canonical signal payload.
484        payload: Value,
485    },
486    /// Store-authoritative time elapsed before a matching signal arrived.
487    TimedOut,
488}
489
490/// Idempotent external event targeted at one workflow checkpoint.
491#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
492pub struct WorkflowSignal {
493    /// Stable publication identity.
494    pub signal_id: WorkflowSignalId,
495    /// Target workflow checkpoint.
496    pub checkpoint_id: CheckpointId,
497    /// Signal name awaited by the workflow definition.
498    pub name: WorkflowSignalName,
499    /// Canonical payload delivered as the wait-node output.
500    pub payload: Value,
501}
502
503impl WorkflowSignal {
504    /// Creates a signal with a generated idempotency identity.
505    ///
506    /// # Errors
507    ///
508    /// Rejects payloads larger than the durable limit.
509    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    /// Creates a signal with a caller-supplied idempotency identity.
518    ///
519    /// # Errors
520    ///
521    /// Rejects payloads larger than the durable limit.
522    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/// Result of an idempotent signal publication.
543#[derive(Clone, Copy, Debug, Eq, PartialEq)]
544#[non_exhaustive]
545pub enum WorkflowSignalOutcome {
546    /// The signal was buffered before its matching wait was installed.
547    Buffered,
548    /// The signal atomically made its waiting workflow claimable.
549    WokeWorkflow,
550    /// An identical signal identity and payload had already been accepted.
551    Duplicate,
552    /// The target was already terminal or the signal lost its durable timeout race.
553    DeadLettered,
554}
555
556/// Durable lifecycle state of an accepted signal identity.
557#[derive(Clone, Copy, Debug, Eq, PartialEq)]
558#[non_exhaustive]
559pub enum WorkflowSignalState {
560    /// Accepted and available to a future matching wait.
561    Pending,
562    /// Atomically consumed by a matching wait.
563    Consumed,
564    /// Retained for audit but no longer eligible for delivery.
565    DeadLettered,
566}
567
568/// Safe signal metadata that deliberately excludes the payload.
569#[derive(Clone, Debug, Eq, PartialEq)]
570pub struct WorkflowSignalSnapshot {
571    /// Stable publication identity.
572    pub signal_id: WorkflowSignalId,
573    /// Tenant that owns the target workflow and signal identity.
574    pub tenant_id: crate::WorkflowTenantId,
575    /// Target workflow checkpoint.
576    pub checkpoint_id: CheckpointId,
577    /// Validated signal name.
578    pub name: WorkflowSignalName,
579    /// Current delivery lifecycle.
580    pub state: WorkflowSignalState,
581    /// Store-authoritative acceptance time.
582    pub accepted_at_ms: u64,
583}
584
585/// Retention period for consumed and dead-letter signal identities.
586#[derive(Clone, Copy, Debug, Eq, PartialEq)]
587pub struct WorkflowSignalRetention(NonZeroU64);
588
589impl WorkflowSignalRetention {
590    /// Creates a positive whole-millisecond retention period.
591    ///
592    /// # Errors
593    ///
594    /// Rejects zero, sub-millisecond, or overflowing durations.
595    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    /// Returns the normalized retention period.
604    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}