1use ff_core::error::ErrorClass;
8
9use crate::retry::is_retryable_kind;
10
11#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum ScriptError {
21 #[error("stale_lease: lease superseded by reclaim")]
24 StaleLease,
25
26 #[error("lease_expired: lease TTL elapsed")]
28 LeaseExpired,
29
30 #[error("lease_revoked: operator revoked lease")]
32 LeaseRevoked,
33
34 #[error("execution_not_active: execution is not in active state")]
36 ExecutionNotActive,
37
38 #[error("no_active_lease: target has no active lease")]
40 NoActiveLease,
41
42 #[error("active_attempt_exists: invariant violation")]
44 ActiveAttemptExists,
45
46 #[error("use_claim_resumed_execution: attempt_interrupted, use resume claim path")]
49 UseClaimResumedExecution,
50
51 #[error("not_a_resumed_execution: use normal claim path")]
53 NotAResumedExecution,
54
55 #[error("execution_not_leaseable: state changed since grant")]
57 ExecutionNotLeaseable,
58
59 #[error("lease_conflict: another worker holds lease")]
61 LeaseConflict,
62
63 #[error("invalid_claim_grant: grant missing or mismatched")]
65 InvalidClaimGrant,
66
67 #[error("claim_grant_expired: grant TTL elapsed")]
69 ClaimGrantExpired,
70
71 #[error("no_eligible_execution: no execution available")]
73 NoEligibleExecution,
74
75 #[error("budget_exceeded: hard budget limit reached")]
78 BudgetExceeded,
79
80 #[error("budget_soft_exceeded: soft budget limit reached")]
82 BudgetSoftExceeded,
83
84 #[error("execution_not_suspended: already resumed or cancelled")]
87 ExecutionNotSuspended,
88
89 #[error("already_suspended: suspension already active")]
91 AlreadySuspended,
92
93 #[error("waitpoint_closed: waitpoint already closed")]
95 WaitpointClosed,
96
97 #[error("waitpoint_not_found: waitpoint does not exist yet")]
99 WaitpointNotFound,
100
101 #[error("target_not_signalable: no valid signal target")]
103 TargetNotSignalable,
104
105 #[error("waitpoint_pending_use_buffer_script: route to buffer script")]
107 WaitpointPendingUseBufferScript,
108
109 #[error("duplicate_signal: signal already delivered")]
111 DuplicateSignal,
112
113 #[error("payload_too_large: signal payload exceeds 64KB")]
115 PayloadTooLarge,
116
117 #[error("signal_limit_exceeded: max signals per execution reached")]
119 SignalLimitExceeded,
120
121 #[error("invalid_waitpoint_key: MAC verification failed")]
123 InvalidWaitpointKey,
124
125 #[error("invalid_lease_for_suspend: lease/attempt binding mismatch")]
127 InvalidLeaseForSuspend,
128
129 #[error("resume_condition_not_met: resume conditions not satisfied")]
131 ResumeConditionNotMet,
132
133 #[error("waitpoint_not_pending: waitpoint is not in pending state")]
135 WaitpointNotPending,
136
137 #[error("pending_waitpoint_expired: pending waitpoint aged out")]
139 PendingWaitpointExpired,
140
141 #[error("invalid_waitpoint_for_execution: waitpoint does not belong to execution")]
143 InvalidWaitpointForExecution,
144
145 #[error("waitpoint_already_exists: waitpoint already exists")]
147 WaitpointAlreadyExists,
148
149 #[error("waitpoint_not_open: waitpoint is not pending or active")]
151 WaitpointNotOpen,
152
153 #[error("execution_not_terminal: cannot replay non-terminal execution")]
156 ExecutionNotTerminal,
157
158 #[error("max_replays_exhausted: replay limit reached")]
160 MaxReplaysExhausted,
161
162 #[error("stream_closed: attempt terminal, no appends allowed")]
165 StreamClosed,
166
167 #[error("stale_owner_cannot_append: lease mismatch on append")]
169 StaleOwnerCannotAppend,
170
171 #[error("retention_limit_exceeded: frame exceeds size limit")]
173 RetentionLimitExceeded,
174
175 #[error("execution_not_eligible: state changed")]
178 ExecutionNotEligible,
179
180 #[error("execution_not_in_eligible_set: removed by another scheduler")]
182 ExecutionNotInEligibleSet,
183
184 #[error("grant_already_exists: grant already active")]
186 GrantAlreadyExists,
187
188 #[error("execution_not_reclaimable: already reclaimed or cancelled")]
190 ExecutionNotReclaimable,
191
192 #[error("invalid_dependency: dependency edge not found")]
195 InvalidDependency,
196
197 #[error("stale_graph_revision: graph has been updated")]
199 StaleGraphRevision,
200
201 #[error("execution_already_in_flow: execution belongs to another flow")]
203 ExecutionAlreadyInFlow,
204
205 #[error("cycle_detected: dependency edge would create cycle")]
207 CycleDetected,
208
209 #[error("flow_not_found: flow does not exist")]
211 FlowNotFound,
212
213 #[error("execution_not_in_flow: execution not in flow")]
215 ExecutionNotInFlow,
216
217 #[error("dependency_already_exists: edge already exists")]
219 DependencyAlreadyExists,
220
221 #[error("self_referencing_edge: upstream and downstream are the same")]
223 SelfReferencingEdge,
224
225 #[error("flow_already_terminal: flow is already terminal")]
227 FlowAlreadyTerminal,
228
229 #[error("deps_not_satisfied: dependencies still unresolved")]
231 DepsNotSatisfied,
232
233 #[error("not_blocked_by_deps: execution not blocked by dependencies")]
235 NotBlockedByDeps,
236
237 #[error("not_runnable: execution is not in runnable state")]
239 NotRunnable,
240
241 #[error("terminal: execution is already terminal")]
243 Terminal,
244
245 #[error("invalid_blocking_reason: unrecognized blocking reason")]
247 InvalidBlockingReason,
248
249 #[error("ok_already_applied: usage seq already processed")]
252 OkAlreadyApplied,
253
254 #[error("attempt_not_found: attempt index does not exist")]
257 AttemptNotFound,
258
259 #[error("attempt_not_in_created_state: internal sequencing error")]
261 AttemptNotInCreatedState,
262
263 #[error("attempt_not_started: attempt not in started state")]
265 AttemptNotStarted,
266
267 #[error("attempt_already_terminal: attempt already ended")]
269 AttemptAlreadyTerminal,
270
271 #[error("execution_not_found: execution does not exist")]
273 ExecutionNotFound,
274
275 #[error("execution_not_eligible_for_attempt: wrong state for new attempt")]
277 ExecutionNotEligibleForAttempt,
278
279 #[error("replay_not_allowed: execution not terminal or limit reached")]
281 ReplayNotAllowed,
282
283 #[error("max_retries_exhausted: retry limit reached")]
285 MaxRetriesExhausted,
286
287 #[error("stream_not_found: no frames appended yet")]
290 StreamNotFound,
291
292 #[error("stream_already_closed: stream already closed")]
294 StreamAlreadyClosed,
295
296 #[error("invalid_frame_type: unrecognized frame type")]
298 InvalidFrameType,
299
300 #[error("invalid_offset: invalid stream ID offset")]
302 InvalidOffset,
303
304 #[error("unauthorized: authentication/authorization failed")]
306 Unauthorized,
307
308 #[error("budget_not_found: budget does not exist")]
311 BudgetNotFound,
312
313 #[error("invalid_budget_scope: malformed budget scope")]
315 InvalidBudgetScope,
316
317 #[error("budget_attach_conflict: budget attachment conflict")]
319 BudgetAttachConflict,
320
321 #[error("budget_override_not_allowed: insufficient privileges")]
323 BudgetOverrideNotAllowed,
324
325 #[error("quota_policy_not_found: quota policy does not exist")]
327 QuotaPolicyNotFound,
328
329 #[error("rate_limit_exceeded: rate limit window full")]
331 RateLimitExceeded,
332
333 #[error("concurrency_limit_exceeded: concurrency cap reached")]
335 ConcurrencyLimitExceeded,
336
337 #[error("quota_attach_conflict: quota policy already attached")]
339 QuotaAttachConflict,
340
341 #[error("invalid_quota_spec: malformed quota policy definition")]
343 InvalidQuotaSpec,
344
345 #[error("invalid_input: {0}")]
347 InvalidInput(String),
348
349 #[error("capability_mismatch: missing {0}")]
353 CapabilityMismatch(String),
354
355 #[error("invalid_capabilities: {0}")]
359 InvalidCapabilities(String),
360
361 #[error("invalid_policy_json: {0}")]
368 InvalidPolicyJson(String),
369
370 #[error("waitpoint_not_token_bound")]
378 WaitpointNotTokenBound,
379
380 #[error("invalid_kid: kid must be non-empty and contain no ':'")]
382 InvalidKid,
383
384 #[error("invalid_secret_hex: secret must be a non-empty even-length hex string")]
386 InvalidSecretHex,
387
388 #[error("invalid_grace_ms: grace_ms must be a non-negative integer")]
390 InvalidGraceMs,
391
392 #[error("rotation_conflict: kid {0} already installed with a different secret")]
396 RotationConflict(String),
397
398 #[error("valkey: {0}")]
402 Valkey(#[from] ferriskey::Error),
403
404 #[error("parse error: {0}")]
406 Parse(String),
407}
408
409impl ScriptError {
410 pub fn valkey_kind(&self) -> Option<ferriskey::ErrorKind> {
412 match self {
413 Self::Valkey(e) => Some(e.kind()),
414 _ => None,
415 }
416 }
417
418 pub fn class(&self) -> ErrorClass {
420 match self {
421 Self::StaleLease
423 | Self::LeaseExpired
424 | Self::LeaseRevoked
425 | Self::ExecutionNotActive
426 | Self::TargetNotSignalable
427 | Self::PayloadTooLarge
428 | Self::SignalLimitExceeded
429 | Self::InvalidWaitpointKey
430 | Self::ExecutionNotTerminal
431 | Self::MaxReplaysExhausted
432 | Self::StreamClosed
433 | Self::StaleOwnerCannotAppend
434 | Self::RetentionLimitExceeded
435 | Self::InvalidLeaseForSuspend
436 | Self::ResumeConditionNotMet
437 | Self::InvalidDependency
438 | Self::ExecutionAlreadyInFlow
439 | Self::CycleDetected
440 | Self::FlowNotFound
441 | Self::ExecutionNotInFlow
442 | Self::DependencyAlreadyExists
443 | Self::SelfReferencingEdge
444 | Self::FlowAlreadyTerminal
445 | Self::InvalidWaitpointForExecution
446 | Self::InvalidBlockingReason
447 | Self::NotRunnable
448 | Self::Terminal
449 | Self::AttemptNotFound
450 | Self::AttemptNotStarted
451 | Self::ExecutionNotFound
452 | Self::ExecutionNotEligibleForAttempt
453 | Self::ReplayNotAllowed
454 | Self::MaxRetriesExhausted
455 | Self::Unauthorized
456 | Self::BudgetNotFound
457 | Self::InvalidBudgetScope
458 | Self::BudgetAttachConflict
459 | Self::BudgetOverrideNotAllowed
460 | Self::QuotaPolicyNotFound
461 | Self::QuotaAttachConflict
462 | Self::InvalidQuotaSpec
463 | Self::InvalidInput(_)
464 | Self::InvalidCapabilities(_)
465 | Self::InvalidPolicyJson(_)
466 | Self::WaitpointNotTokenBound
467 | Self::InvalidKid
468 | Self::InvalidSecretHex
469 | Self::InvalidGraceMs
470 | Self::RotationConflict(_)
471 | Self::Parse(_) => ErrorClass::Terminal,
472
473 Self::Valkey(e) => {
478 if is_retryable_kind(e.kind()) {
479 ErrorClass::Retryable
480 } else {
481 ErrorClass::Terminal
482 }
483 }
484
485 Self::UseClaimResumedExecution
487 | Self::NotAResumedExecution
488 | Self::ExecutionNotLeaseable
489 | Self::LeaseConflict
490 | Self::InvalidClaimGrant
491 | Self::ClaimGrantExpired
492 | Self::NoEligibleExecution
493 | Self::WaitpointNotFound
494 | Self::WaitpointPendingUseBufferScript
495 | Self::StaleGraphRevision
496 | Self::RateLimitExceeded
497 | Self::ConcurrencyLimitExceeded
498 | Self::CapabilityMismatch(_)
499 | Self::InvalidOffset => ErrorClass::Retryable,
500
501 Self::BudgetExceeded => ErrorClass::Cooperative,
503
504 Self::ExecutionNotSuspended
506 | Self::AlreadySuspended
507 | Self::WaitpointClosed
508 | Self::DuplicateSignal
509 | Self::ExecutionNotEligible
510 | Self::ExecutionNotInEligibleSet
511 | Self::GrantAlreadyExists
512 | Self::ExecutionNotReclaimable
513 | Self::NoActiveLease
514 | Self::OkAlreadyApplied
515 | Self::AttemptAlreadyTerminal
516 | Self::StreamAlreadyClosed
517 | Self::BudgetSoftExceeded
518 | Self::WaitpointAlreadyExists
519 | Self::WaitpointNotOpen
520 | Self::WaitpointNotPending
521 | Self::PendingWaitpointExpired
522 | Self::NotBlockedByDeps
523 | Self::DepsNotSatisfied => ErrorClass::Informational,
524
525 Self::ActiveAttemptExists | Self::AttemptNotInCreatedState => ErrorClass::Bug,
527
528 Self::StreamNotFound => ErrorClass::Expected,
530
531 Self::InvalidFrameType => ErrorClass::SoftError,
533 }
534 }
535
536 pub fn from_code(code: &str) -> Option<Self> {
538 Some(match code {
539 "stale_lease" => Self::StaleLease,
540 "lease_expired" => Self::LeaseExpired,
541 "lease_revoked" => Self::LeaseRevoked,
542 "execution_not_active" => Self::ExecutionNotActive,
543 "no_active_lease" => Self::NoActiveLease,
544 "active_attempt_exists" => Self::ActiveAttemptExists,
545 "use_claim_resumed_execution" => Self::UseClaimResumedExecution,
546 "not_a_resumed_execution" => Self::NotAResumedExecution,
547 "execution_not_leaseable" => Self::ExecutionNotLeaseable,
548 "lease_conflict" => Self::LeaseConflict,
549 "invalid_claim_grant" => Self::InvalidClaimGrant,
550 "claim_grant_expired" => Self::ClaimGrantExpired,
551 "no_eligible_execution" => Self::NoEligibleExecution,
552 "budget_exceeded" => Self::BudgetExceeded,
553 "budget_soft_exceeded" => Self::BudgetSoftExceeded,
554 "execution_not_suspended" => Self::ExecutionNotSuspended,
555 "already_suspended" => Self::AlreadySuspended,
556 "waitpoint_closed" => Self::WaitpointClosed,
557 "waitpoint_not_found" => Self::WaitpointNotFound,
558 "target_not_signalable" => Self::TargetNotSignalable,
559 "waitpoint_pending_use_buffer_script" => Self::WaitpointPendingUseBufferScript,
560 "duplicate_signal" => Self::DuplicateSignal,
561 "payload_too_large" => Self::PayloadTooLarge,
562 "signal_limit_exceeded" => Self::SignalLimitExceeded,
563 "invalid_waitpoint_key" => Self::InvalidWaitpointKey,
564 "invalid_lease_for_suspend" => Self::InvalidLeaseForSuspend,
565 "resume_condition_not_met" => Self::ResumeConditionNotMet,
566 "waitpoint_not_pending" => Self::WaitpointNotPending,
567 "pending_waitpoint_expired" => Self::PendingWaitpointExpired,
568 "invalid_waitpoint_for_execution" => Self::InvalidWaitpointForExecution,
569 "waitpoint_already_exists" => Self::WaitpointAlreadyExists,
570 "waitpoint_not_open" => Self::WaitpointNotOpen,
571 "execution_not_terminal" => Self::ExecutionNotTerminal,
572 "max_replays_exhausted" => Self::MaxReplaysExhausted,
573 "stream_closed" => Self::StreamClosed,
574 "stale_owner_cannot_append" => Self::StaleOwnerCannotAppend,
575 "retention_limit_exceeded" => Self::RetentionLimitExceeded,
576 "execution_not_eligible" => Self::ExecutionNotEligible,
577 "execution_not_in_eligible_set" => Self::ExecutionNotInEligibleSet,
578 "grant_already_exists" => Self::GrantAlreadyExists,
579 "execution_not_reclaimable" => Self::ExecutionNotReclaimable,
580 "invalid_dependency" => Self::InvalidDependency,
581 "stale_graph_revision" => Self::StaleGraphRevision,
582 "execution_already_in_flow" => Self::ExecutionAlreadyInFlow,
583 "cycle_detected" => Self::CycleDetected,
584 "flow_not_found" => Self::FlowNotFound,
585 "execution_not_in_flow" => Self::ExecutionNotInFlow,
586 "dependency_already_exists" => Self::DependencyAlreadyExists,
587 "self_referencing_edge" => Self::SelfReferencingEdge,
588 "flow_already_terminal" => Self::FlowAlreadyTerminal,
589 "deps_not_satisfied" => Self::DepsNotSatisfied,
590 "not_blocked_by_deps" => Self::NotBlockedByDeps,
591 "not_runnable" => Self::NotRunnable,
592 "terminal" => Self::Terminal,
593 "invalid_blocking_reason" => Self::InvalidBlockingReason,
594 "ok_already_applied" => Self::OkAlreadyApplied,
595 "attempt_not_found" => Self::AttemptNotFound,
596 "attempt_not_in_created_state" => Self::AttemptNotInCreatedState,
597 "attempt_not_started" => Self::AttemptNotStarted,
598 "attempt_already_terminal" => Self::AttemptAlreadyTerminal,
599 "execution_not_found" => Self::ExecutionNotFound,
600 "execution_not_eligible_for_attempt" => Self::ExecutionNotEligibleForAttempt,
601 "replay_not_allowed" => Self::ReplayNotAllowed,
602 "max_retries_exhausted" => Self::MaxRetriesExhausted,
603 "stream_not_found" => Self::StreamNotFound,
604 "stream_already_closed" => Self::StreamAlreadyClosed,
605 "invalid_frame_type" => Self::InvalidFrameType,
606 "invalid_offset" => Self::InvalidOffset,
607 "unauthorized" => Self::Unauthorized,
608 "budget_not_found" => Self::BudgetNotFound,
609 "invalid_budget_scope" => Self::InvalidBudgetScope,
610 "budget_attach_conflict" => Self::BudgetAttachConflict,
611 "budget_override_not_allowed" => Self::BudgetOverrideNotAllowed,
612 "quota_policy_not_found" => Self::QuotaPolicyNotFound,
613 "rate_limit_exceeded" => Self::RateLimitExceeded,
614 "concurrency_limit_exceeded" => Self::ConcurrencyLimitExceeded,
615 "quota_attach_conflict" => Self::QuotaAttachConflict,
616 "invalid_quota_spec" => Self::InvalidQuotaSpec,
617 "invalid_input" => Self::InvalidInput(String::new()),
618 "capability_mismatch" => Self::CapabilityMismatch(String::new()),
619 "invalid_capabilities" => Self::InvalidCapabilities(String::new()),
620 "invalid_policy_json" => Self::InvalidPolicyJson(String::new()),
621 "waitpoint_not_token_bound" => Self::WaitpointNotTokenBound,
622 "invalid_kid" => Self::InvalidKid,
623 "invalid_secret_hex" => Self::InvalidSecretHex,
624 "invalid_grace_ms" => Self::InvalidGraceMs,
625 "rotation_conflict" => Self::RotationConflict(String::new()),
626 _ => return None,
627 })
628 }
629
630 pub fn from_code_with_detail(code: &str, detail: &str) -> Option<Self> {
638 let base = Self::from_code(code)?;
639 Some(match base {
640 Self::CapabilityMismatch(_) => Self::CapabilityMismatch(detail.to_owned()),
641 Self::InvalidCapabilities(_) => Self::InvalidCapabilities(detail.to_owned()),
642 Self::InvalidPolicyJson(_) => Self::InvalidPolicyJson(detail.to_owned()),
643 Self::InvalidInput(_) => Self::InvalidInput(detail.to_owned()),
644 Self::RotationConflict(_) => Self::RotationConflict(detail.to_owned()),
645 other => other,
646 })
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 #[test]
655 fn error_classification_terminal() {
656 assert_eq!(ScriptError::StaleLease.class(), ErrorClass::Terminal);
657 assert_eq!(ScriptError::LeaseExpired.class(), ErrorClass::Terminal);
658 assert_eq!(ScriptError::ExecutionNotFound.class(), ErrorClass::Terminal);
659 }
660
661 #[test]
662 fn error_classification_retryable() {
663 assert_eq!(
664 ScriptError::UseClaimResumedExecution.class(),
665 ErrorClass::Retryable
666 );
667 assert_eq!(
668 ScriptError::NoEligibleExecution.class(),
669 ErrorClass::Retryable
670 );
671 assert_eq!(
672 ScriptError::WaitpointNotFound.class(),
673 ErrorClass::Retryable
674 );
675 assert_eq!(
676 ScriptError::RateLimitExceeded.class(),
677 ErrorClass::Retryable
678 );
679 }
680
681 #[test]
682 fn error_classification_cooperative() {
683 assert_eq!(ScriptError::BudgetExceeded.class(), ErrorClass::Cooperative);
684 }
685
686 #[test]
687 fn error_classification_valkey_transient_is_retryable() {
688 use ferriskey::ErrorKind;
689 let transient = ScriptError::Valkey(ferriskey::Error::from((
690 ErrorKind::IoError,
691 "connection dropped",
692 )));
693 assert_eq!(transient.class(), ErrorClass::Retryable);
694 }
695
696 #[test]
697 fn error_classification_valkey_permanent_is_terminal() {
698 use ferriskey::ErrorKind;
699 let permanent = ScriptError::Valkey(ferriskey::Error::from((
700 ErrorKind::AuthenticationFailed,
701 "bad creds",
702 )));
703 assert_eq!(permanent.class(), ErrorClass::Terminal);
704
705 let fatal_recv = ScriptError::Valkey(ferriskey::Error::from((
708 ErrorKind::FatalReceiveError,
709 "response lost",
710 )));
711 assert_eq!(fatal_recv.class(), ErrorClass::Terminal);
712 }
713
714 #[test]
715 fn error_classification_informational() {
716 assert_eq!(
717 ScriptError::ExecutionNotSuspended.class(),
718 ErrorClass::Informational
719 );
720 assert_eq!(
721 ScriptError::DuplicateSignal.class(),
722 ErrorClass::Informational
723 );
724 assert_eq!(
725 ScriptError::OkAlreadyApplied.class(),
726 ErrorClass::Informational
727 );
728 }
729
730 #[test]
731 fn error_classification_bug() {
732 assert_eq!(ScriptError::ActiveAttemptExists.class(), ErrorClass::Bug);
733 assert_eq!(
734 ScriptError::AttemptNotInCreatedState.class(),
735 ErrorClass::Bug
736 );
737 }
738
739 #[test]
740 fn error_classification_expected() {
741 assert_eq!(ScriptError::StreamNotFound.class(), ErrorClass::Expected);
742 }
743
744 #[test]
745 fn error_classification_budget_soft_exceeded() {
746 assert_eq!(
748 ScriptError::BudgetSoftExceeded.class(),
749 ErrorClass::Informational
750 );
751 }
752
753 #[test]
754 fn error_classification_soft_error() {
755 assert_eq!(ScriptError::InvalidFrameType.class(), ErrorClass::SoftError);
756 }
757
758 #[test]
759 fn from_code_roundtrip() {
760 let codes = [
761 "stale_lease", "lease_expired", "lease_revoked",
762 "execution_not_active", "no_active_lease", "active_attempt_exists",
763 "use_claim_resumed_execution", "not_a_resumed_execution",
764 "execution_not_leaseable", "lease_conflict",
765 "invalid_claim_grant", "claim_grant_expired",
766 "budget_exceeded", "budget_soft_exceeded",
767 "execution_not_suspended", "already_suspended",
768 "waitpoint_closed", "waitpoint_not_found",
769 "target_not_signalable", "waitpoint_pending_use_buffer_script",
770 "invalid_lease_for_suspend", "resume_condition_not_met",
771 "signal_limit_exceeded",
772 "execution_not_terminal", "max_replays_exhausted",
773 "stream_closed", "stale_owner_cannot_append", "retention_limit_exceeded",
774 "execution_not_eligible", "execution_not_in_eligible_set",
775 "grant_already_exists", "execution_not_reclaimable",
776 "invalid_dependency", "stale_graph_revision",
777 "execution_already_in_flow", "cycle_detected",
778 "execution_not_found", "max_retries_exhausted",
779 "flow_not_found", "execution_not_in_flow",
780 "dependency_already_exists", "self_referencing_edge",
781 "flow_already_terminal",
782 "deps_not_satisfied", "not_blocked_by_deps",
783 "not_runnable", "terminal", "invalid_blocking_reason",
784 "waitpoint_not_pending", "pending_waitpoint_expired",
785 "invalid_waitpoint_for_execution", "waitpoint_already_exists",
786 "waitpoint_not_open",
787 ];
788 for code in codes {
789 let err = ScriptError::from_code(code);
790 assert!(err.is_some(), "failed to parse code: {code}");
791 }
792 }
793
794 #[test]
795 fn from_code_unknown_returns_none() {
796 assert!(ScriptError::from_code("nonexistent_error").is_none());
797 }
798}