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