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(
43 "execution_not_active: lifecycle_phase={lifecycle_phase} terminal_outcome={terminal_outcome} lease_epoch={lease_epoch} attempt_id={attempt_id}"
44 )]
45 ExecutionNotActive {
46 terminal_outcome: String,
47 lease_epoch: String,
48 lifecycle_phase: String,
49 attempt_id: String,
50 },
51
52 #[error("no_active_lease: target has no active lease")]
54 NoActiveLease,
55
56 #[error("fence_required: lease fence triple is mandatory for this FCALL")]
65 FenceRequired,
66
67 #[error("partial_fence_triple: lease_id/lease_epoch/attempt_id must be all set or all empty")]
72 PartialFenceTriple,
73
74 #[error("active_attempt_exists: invariant violation")]
76 ActiveAttemptExists,
77
78 #[error("use_claim_resumed_execution: attempt_interrupted, use resume claim path")]
81 UseClaimResumedExecution,
82
83 #[error("not_a_resumed_execution: use normal claim path")]
85 NotAResumedExecution,
86
87 #[error("execution_not_leaseable: state changed since grant")]
89 ExecutionNotLeaseable,
90
91 #[error("lease_conflict: another worker holds lease")]
93 LeaseConflict,
94
95 #[error("invalid_claim_grant: grant missing or mismatched")]
97 InvalidClaimGrant,
98
99 #[error("claim_grant_expired: grant TTL elapsed")]
101 ClaimGrantExpired,
102
103 #[error("no_eligible_execution: no execution available")]
105 NoEligibleExecution,
106
107 #[error("budget_exceeded: hard budget limit reached")]
110 BudgetExceeded,
111
112 #[error("budget_soft_exceeded: soft budget limit reached")]
114 BudgetSoftExceeded,
115
116 #[error("execution_not_suspended: already resumed or cancelled")]
119 ExecutionNotSuspended,
120
121 #[error("already_suspended: suspension already active")]
123 AlreadySuspended,
124
125 #[error("waitpoint_closed: waitpoint already closed")]
127 WaitpointClosed,
128
129 #[error("waitpoint_not_found: waitpoint does not exist yet")]
131 WaitpointNotFound,
132
133 #[error("target_not_signalable: no valid signal target")]
135 TargetNotSignalable,
136
137 #[error("waitpoint_pending_use_buffer_script: route to buffer script")]
139 WaitpointPendingUseBufferScript,
140
141 #[error("duplicate_signal: signal already delivered")]
143 DuplicateSignal,
144
145 #[error("payload_too_large: signal payload exceeds 64KB")]
147 PayloadTooLarge,
148
149 #[error("signal_limit_exceeded: max signals per execution reached")]
151 SignalLimitExceeded,
152
153 #[error("invalid_waitpoint_key: MAC verification failed")]
155 InvalidWaitpointKey,
156
157 #[error("invalid_lease_for_suspend: lease/attempt binding mismatch")]
159 InvalidLeaseForSuspend,
160
161 #[error("resume_condition_not_met: resume conditions not satisfied")]
163 ResumeConditionNotMet,
164
165 #[error("waitpoint_not_pending: waitpoint is not in pending state")]
167 WaitpointNotPending,
168
169 #[error("pending_waitpoint_expired: pending waitpoint aged out")]
171 PendingWaitpointExpired,
172
173 #[error("invalid_waitpoint_for_execution: waitpoint does not belong to execution")]
175 InvalidWaitpointForExecution,
176
177 #[error("waitpoint_already_exists: waitpoint already exists")]
179 WaitpointAlreadyExists,
180
181 #[error("waitpoint_not_open: waitpoint is not pending or active")]
183 WaitpointNotOpen,
184
185 #[error("execution_not_terminal: cannot replay non-terminal execution")]
188 ExecutionNotTerminal,
189
190 #[error("max_replays_exhausted: replay limit reached")]
192 MaxReplaysExhausted,
193
194 #[error("stream_closed: attempt terminal, no appends allowed")]
197 StreamClosed,
198
199 #[error("stale_owner_cannot_append: lease mismatch on append")]
201 StaleOwnerCannotAppend,
202
203 #[error("retention_limit_exceeded: frame exceeds size limit")]
205 RetentionLimitExceeded,
206
207 #[error("execution_not_eligible: state changed")]
210 ExecutionNotEligible,
211
212 #[error("execution_not_in_eligible_set: removed by another scheduler")]
214 ExecutionNotInEligibleSet,
215
216 #[error("grant_already_exists: grant already active")]
218 GrantAlreadyExists,
219
220 #[error("execution_not_reclaimable: already reclaimed or cancelled")]
222 ExecutionNotReclaimable,
223
224 #[error("invalid_dependency: dependency edge not found")]
227 InvalidDependency,
228
229 #[error("stale_graph_revision: graph has been updated")]
231 StaleGraphRevision,
232
233 #[error("execution_already_in_flow: execution belongs to another flow")]
235 ExecutionAlreadyInFlow,
236
237 #[error("cycle_detected: dependency edge would create cycle")]
239 CycleDetected,
240
241 #[error("flow_not_found: flow does not exist")]
243 FlowNotFound,
244
245 #[error("execution_not_in_flow: execution not in flow")]
247 ExecutionNotInFlow,
248
249 #[error("dependency_already_exists: edge already exists")]
251 DependencyAlreadyExists,
252
253 #[error("self_referencing_edge: upstream and downstream are the same")]
255 SelfReferencingEdge,
256
257 #[error("flow_already_terminal: flow is already terminal")]
259 FlowAlreadyTerminal,
260
261 #[error("deps_not_satisfied: dependencies still unresolved")]
263 DepsNotSatisfied,
264
265 #[error("not_blocked_by_deps: execution not blocked by dependencies")]
267 NotBlockedByDeps,
268
269 #[error("not_runnable: execution is not in runnable state")]
271 NotRunnable,
272
273 #[error("terminal: execution is already terminal")]
275 Terminal,
276
277 #[error("invalid_blocking_reason: unrecognized blocking reason")]
279 InvalidBlockingReason,
280
281 #[error("ok_already_applied: usage seq already processed")]
284 OkAlreadyApplied,
285
286 #[error("attempt_not_found: attempt index does not exist")]
289 AttemptNotFound,
290
291 #[error("attempt_not_in_created_state: internal sequencing error")]
293 AttemptNotInCreatedState,
294
295 #[error("attempt_not_started: attempt not in started state")]
297 AttemptNotStarted,
298
299 #[error("attempt_already_terminal: attempt already ended")]
301 AttemptAlreadyTerminal,
302
303 #[error("execution_not_found: execution does not exist")]
305 ExecutionNotFound,
306
307 #[error("execution_not_eligible_for_attempt: wrong state for new attempt")]
309 ExecutionNotEligibleForAttempt,
310
311 #[error("replay_not_allowed: execution not terminal or limit reached")]
313 ReplayNotAllowed,
314
315 #[error("max_retries_exhausted: retry limit reached")]
317 MaxRetriesExhausted,
318
319 #[error("stream_not_found: no frames appended yet")]
322 StreamNotFound,
323
324 #[error("stream_already_closed: stream already closed")]
326 StreamAlreadyClosed,
327
328 #[error("invalid_frame_type: unrecognized frame type")]
330 InvalidFrameType,
331
332 #[error("invalid_offset: invalid stream ID offset")]
334 InvalidOffset,
335
336 #[error("unauthorized: authentication/authorization failed")]
338 Unauthorized,
339
340 #[error("budget_not_found: budget does not exist")]
343 BudgetNotFound,
344
345 #[error("invalid_budget_scope: malformed budget scope")]
347 InvalidBudgetScope,
348
349 #[error("budget_attach_conflict: budget attachment conflict")]
351 BudgetAttachConflict,
352
353 #[error("budget_override_not_allowed: insufficient privileges")]
355 BudgetOverrideNotAllowed,
356
357 #[error("quota_policy_not_found: quota policy does not exist")]
359 QuotaPolicyNotFound,
360
361 #[error("rate_limit_exceeded: rate limit window full")]
363 RateLimitExceeded,
364
365 #[error("concurrency_limit_exceeded: concurrency cap reached")]
367 ConcurrencyLimitExceeded,
368
369 #[error("quota_attach_conflict: quota policy already attached")]
371 QuotaAttachConflict,
372
373 #[error("invalid_quota_spec: malformed quota policy definition")]
375 InvalidQuotaSpec,
376
377 #[error("invalid_input: {0}")]
379 InvalidInput(String),
380
381 #[error("capability_mismatch: missing {0}")]
385 CapabilityMismatch(String),
386
387 #[error("invalid_capabilities: {0}")]
391 InvalidCapabilities(String),
392
393 #[error("invalid_policy_json: {0}")]
400 InvalidPolicyJson(String),
401
402 #[error("waitpoint_not_token_bound")]
410 WaitpointNotTokenBound,
411
412 #[error("invalid_kid: kid must be non-empty and contain no ':'")]
414 InvalidKid,
415
416 #[error("invalid_secret_hex: secret must be a non-empty even-length hex string")]
418 InvalidSecretHex,
419
420 #[error("invalid_grace_ms: grace_ms must be a non-negative integer")]
422 InvalidGraceMs,
423
424 #[error("rotation_conflict: kid {0} already installed with a different secret")]
428 RotationConflict(String),
429
430 #[error("invalid_tag_key: {0}")]
436 InvalidTagKey(String),
437
438 #[error("valkey: {0}")]
442 Valkey(#[from] ferriskey::Error),
443
444 #[error("{}", fmt_parse(.fcall, .execution_id.as_deref(), .message))]
451 Parse {
452 fcall: String,
453 execution_id: Option<String>,
454 message: String,
455 },
456}
457
458fn fmt_parse(fcall: &str, execution_id: Option<&str>, message: &str) -> String {
462 match execution_id {
463 Some(eid) => format!("parse error: {fcall}[exec={eid}]: {message}"),
464 None => format!("parse error: {fcall}: {message}"),
465 }
466}
467
468impl ScriptError {
469 pub fn valkey_kind(&self) -> Option<ferriskey::ErrorKind> {
471 match self {
472 Self::Valkey(e) => Some(e.kind()),
473 _ => None,
474 }
475 }
476
477 pub fn class(&self) -> ErrorClass {
479 match self {
480 Self::StaleLease
482 | Self::LeaseExpired
483 | Self::LeaseRevoked
484 | Self::ExecutionNotActive { .. }
485 | Self::TargetNotSignalable
486 | Self::PayloadTooLarge
487 | Self::SignalLimitExceeded
488 | Self::InvalidWaitpointKey
489 | Self::ExecutionNotTerminal
490 | Self::MaxReplaysExhausted
491 | Self::StreamClosed
492 | Self::StaleOwnerCannotAppend
493 | Self::RetentionLimitExceeded
494 | Self::InvalidLeaseForSuspend
495 | Self::ResumeConditionNotMet
496 | Self::InvalidDependency
497 | Self::ExecutionAlreadyInFlow
498 | Self::CycleDetected
499 | Self::FlowNotFound
500 | Self::ExecutionNotInFlow
501 | Self::DependencyAlreadyExists
502 | Self::SelfReferencingEdge
503 | Self::FlowAlreadyTerminal
504 | Self::InvalidWaitpointForExecution
505 | Self::InvalidBlockingReason
506 | Self::NotRunnable
507 | Self::Terminal
508 | Self::AttemptNotFound
509 | Self::AttemptNotStarted
510 | Self::ExecutionNotFound
511 | Self::ExecutionNotEligibleForAttempt
512 | Self::ReplayNotAllowed
513 | Self::MaxRetriesExhausted
514 | Self::Unauthorized
515 | Self::BudgetNotFound
516 | Self::InvalidBudgetScope
517 | Self::BudgetAttachConflict
518 | Self::BudgetOverrideNotAllowed
519 | Self::QuotaPolicyNotFound
520 | Self::QuotaAttachConflict
521 | Self::InvalidQuotaSpec
522 | Self::InvalidInput(_)
523 | Self::InvalidCapabilities(_)
524 | Self::InvalidPolicyJson(_)
525 | Self::WaitpointNotTokenBound
526 | Self::InvalidKid
527 | Self::InvalidSecretHex
528 | Self::InvalidGraceMs
529 | Self::RotationConflict(_)
530 | Self::InvalidTagKey(_)
531 | Self::FenceRequired
532 | Self::PartialFenceTriple
533 | Self::Parse { .. } => ErrorClass::Terminal,
534
535 Self::Valkey(e) => {
540 if is_retryable_kind(e.kind()) {
541 ErrorClass::Retryable
542 } else {
543 ErrorClass::Terminal
544 }
545 }
546
547 Self::UseClaimResumedExecution
549 | Self::NotAResumedExecution
550 | Self::ExecutionNotLeaseable
551 | Self::LeaseConflict
552 | Self::InvalidClaimGrant
553 | Self::ClaimGrantExpired
554 | Self::NoEligibleExecution
555 | Self::WaitpointNotFound
556 | Self::WaitpointPendingUseBufferScript
557 | Self::StaleGraphRevision
558 | Self::RateLimitExceeded
559 | Self::ConcurrencyLimitExceeded
560 | Self::CapabilityMismatch(_)
561 | Self::InvalidOffset => ErrorClass::Retryable,
562
563 Self::BudgetExceeded => ErrorClass::Cooperative,
565
566 Self::ExecutionNotSuspended
568 | Self::AlreadySuspended
569 | Self::WaitpointClosed
570 | Self::DuplicateSignal
571 | Self::ExecutionNotEligible
572 | Self::ExecutionNotInEligibleSet
573 | Self::GrantAlreadyExists
574 | Self::ExecutionNotReclaimable
575 | Self::NoActiveLease
576 | Self::OkAlreadyApplied
577 | Self::AttemptAlreadyTerminal
578 | Self::StreamAlreadyClosed
579 | Self::BudgetSoftExceeded
580 | Self::WaitpointAlreadyExists
581 | Self::WaitpointNotOpen
582 | Self::WaitpointNotPending
583 | Self::PendingWaitpointExpired
584 | Self::NotBlockedByDeps
585 | Self::DepsNotSatisfied => ErrorClass::Informational,
586
587 Self::ActiveAttemptExists | Self::AttemptNotInCreatedState => ErrorClass::Bug,
589
590 Self::StreamNotFound => ErrorClass::Expected,
592
593 Self::InvalidFrameType => ErrorClass::SoftError,
595 }
596 }
597
598 pub fn from_code(code: &str) -> Option<Self> {
600 Some(match code {
601 "stale_lease" => Self::StaleLease,
602 "lease_expired" => Self::LeaseExpired,
603 "lease_revoked" => Self::LeaseRevoked,
604 "execution_not_active" => Self::ExecutionNotActive {
605 terminal_outcome: String::new(),
606 lease_epoch: String::new(),
607 lifecycle_phase: String::new(),
608 attempt_id: String::new(),
609 },
610 "no_active_lease" => Self::NoActiveLease,
611 "active_attempt_exists" => Self::ActiveAttemptExists,
612 "use_claim_resumed_execution" => Self::UseClaimResumedExecution,
613 "not_a_resumed_execution" => Self::NotAResumedExecution,
614 "execution_not_leaseable" => Self::ExecutionNotLeaseable,
615 "lease_conflict" => Self::LeaseConflict,
616 "invalid_claim_grant" => Self::InvalidClaimGrant,
617 "claim_grant_expired" => Self::ClaimGrantExpired,
618 "no_eligible_execution" => Self::NoEligibleExecution,
619 "budget_exceeded" => Self::BudgetExceeded,
620 "budget_soft_exceeded" => Self::BudgetSoftExceeded,
621 "execution_not_suspended" => Self::ExecutionNotSuspended,
622 "already_suspended" => Self::AlreadySuspended,
623 "waitpoint_closed" => Self::WaitpointClosed,
624 "waitpoint_not_found" => Self::WaitpointNotFound,
625 "target_not_signalable" => Self::TargetNotSignalable,
626 "waitpoint_pending_use_buffer_script" => Self::WaitpointPendingUseBufferScript,
627 "duplicate_signal" => Self::DuplicateSignal,
628 "payload_too_large" => Self::PayloadTooLarge,
629 "signal_limit_exceeded" => Self::SignalLimitExceeded,
630 "invalid_waitpoint_key" => Self::InvalidWaitpointKey,
631 "invalid_lease_for_suspend" => Self::InvalidLeaseForSuspend,
632 "resume_condition_not_met" => Self::ResumeConditionNotMet,
633 "waitpoint_not_pending" => Self::WaitpointNotPending,
634 "pending_waitpoint_expired" => Self::PendingWaitpointExpired,
635 "invalid_waitpoint_for_execution" => Self::InvalidWaitpointForExecution,
636 "waitpoint_already_exists" => Self::WaitpointAlreadyExists,
637 "waitpoint_not_open" => Self::WaitpointNotOpen,
638 "execution_not_terminal" => Self::ExecutionNotTerminal,
639 "max_replays_exhausted" => Self::MaxReplaysExhausted,
640 "stream_closed" => Self::StreamClosed,
641 "stale_owner_cannot_append" => Self::StaleOwnerCannotAppend,
642 "retention_limit_exceeded" => Self::RetentionLimitExceeded,
643 "execution_not_eligible" => Self::ExecutionNotEligible,
644 "execution_not_in_eligible_set" => Self::ExecutionNotInEligibleSet,
645 "grant_already_exists" => Self::GrantAlreadyExists,
646 "execution_not_reclaimable" => Self::ExecutionNotReclaimable,
647 "invalid_dependency" => Self::InvalidDependency,
648 "stale_graph_revision" => Self::StaleGraphRevision,
649 "execution_already_in_flow" => Self::ExecutionAlreadyInFlow,
650 "cycle_detected" => Self::CycleDetected,
651 "flow_not_found" => Self::FlowNotFound,
652 "execution_not_in_flow" => Self::ExecutionNotInFlow,
653 "dependency_already_exists" => Self::DependencyAlreadyExists,
654 "self_referencing_edge" => Self::SelfReferencingEdge,
655 "flow_already_terminal" => Self::FlowAlreadyTerminal,
656 "deps_not_satisfied" => Self::DepsNotSatisfied,
657 "not_blocked_by_deps" => Self::NotBlockedByDeps,
658 "not_runnable" => Self::NotRunnable,
659 "terminal" => Self::Terminal,
660 "invalid_blocking_reason" => Self::InvalidBlockingReason,
661 "ok_already_applied" => Self::OkAlreadyApplied,
662 "attempt_not_found" => Self::AttemptNotFound,
663 "attempt_not_in_created_state" => Self::AttemptNotInCreatedState,
664 "attempt_not_started" => Self::AttemptNotStarted,
665 "attempt_already_terminal" => Self::AttemptAlreadyTerminal,
666 "execution_not_found" => Self::ExecutionNotFound,
667 "execution_not_eligible_for_attempt" => Self::ExecutionNotEligibleForAttempt,
668 "replay_not_allowed" => Self::ReplayNotAllowed,
669 "max_retries_exhausted" => Self::MaxRetriesExhausted,
670 "stream_not_found" => Self::StreamNotFound,
671 "stream_already_closed" => Self::StreamAlreadyClosed,
672 "invalid_frame_type" => Self::InvalidFrameType,
673 "invalid_offset" => Self::InvalidOffset,
674 "unauthorized" => Self::Unauthorized,
675 "budget_not_found" => Self::BudgetNotFound,
676 "invalid_budget_scope" => Self::InvalidBudgetScope,
677 "budget_attach_conflict" => Self::BudgetAttachConflict,
678 "budget_override_not_allowed" => Self::BudgetOverrideNotAllowed,
679 "quota_policy_not_found" => Self::QuotaPolicyNotFound,
680 "rate_limit_exceeded" => Self::RateLimitExceeded,
681 "concurrency_limit_exceeded" => Self::ConcurrencyLimitExceeded,
682 "quota_attach_conflict" => Self::QuotaAttachConflict,
683 "invalid_quota_spec" => Self::InvalidQuotaSpec,
684 "invalid_input" => Self::InvalidInput(String::new()),
685 "capability_mismatch" => Self::CapabilityMismatch(String::new()),
686 "invalid_capabilities" => Self::InvalidCapabilities(String::new()),
687 "invalid_policy_json" => Self::InvalidPolicyJson(String::new()),
688 "waitpoint_not_token_bound" => Self::WaitpointNotTokenBound,
689 "invalid_kid" => Self::InvalidKid,
690 "invalid_secret_hex" => Self::InvalidSecretHex,
691 "invalid_grace_ms" => Self::InvalidGraceMs,
692 "rotation_conflict" => Self::RotationConflict(String::new()),
693 "invalid_tag_key" => Self::InvalidTagKey(String::new()),
694 "fence_required" => Self::FenceRequired,
695 "partial_fence_triple" => Self::PartialFenceTriple,
696 _ => return None,
697 })
698 }
699
700 pub fn from_code_with_detail(code: &str, detail: &str) -> Option<Self> {
708 Self::from_code_with_details(code, std::slice::from_ref(&detail))
709 }
710
711 pub fn from_code_with_details(code: &str, details: &[&str]) -> Option<Self> {
717 let base = Self::from_code(code)?;
718 let d = |i: usize| details.get(i).copied().unwrap_or("").to_owned();
719 Some(match base {
720 Self::CapabilityMismatch(_) => Self::CapabilityMismatch(d(0)),
721 Self::InvalidCapabilities(_) => Self::InvalidCapabilities(d(0)),
722 Self::InvalidPolicyJson(_) => Self::InvalidPolicyJson(d(0)),
723 Self::InvalidInput(_) => Self::InvalidInput(d(0)),
724 Self::RotationConflict(_) => Self::RotationConflict(d(0)),
725 Self::InvalidTagKey(_) => Self::InvalidTagKey(d(0)),
726 Self::ExecutionNotActive { .. } => Self::ExecutionNotActive {
727 terminal_outcome: d(0),
728 lease_epoch: d(1),
729 lifecycle_phase: d(2),
730 attempt_id: d(3),
731 },
732 other => other,
733 })
734 }
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 #[test]
742 fn error_classification_terminal() {
743 assert_eq!(ScriptError::StaleLease.class(), ErrorClass::Terminal);
744 assert_eq!(ScriptError::LeaseExpired.class(), ErrorClass::Terminal);
745 assert_eq!(ScriptError::ExecutionNotFound.class(), ErrorClass::Terminal);
746 }
747
748 #[test]
749 fn error_classification_retryable() {
750 assert_eq!(
751 ScriptError::UseClaimResumedExecution.class(),
752 ErrorClass::Retryable
753 );
754 assert_eq!(
755 ScriptError::NoEligibleExecution.class(),
756 ErrorClass::Retryable
757 );
758 assert_eq!(
759 ScriptError::WaitpointNotFound.class(),
760 ErrorClass::Retryable
761 );
762 assert_eq!(
763 ScriptError::RateLimitExceeded.class(),
764 ErrorClass::Retryable
765 );
766 }
767
768 #[test]
769 fn error_classification_cooperative() {
770 assert_eq!(ScriptError::BudgetExceeded.class(), ErrorClass::Cooperative);
771 }
772
773 #[test]
774 fn error_classification_valkey_transient_is_retryable() {
775 use ferriskey::ErrorKind;
776 let transient = ScriptError::Valkey(ferriskey::Error::from((
777 ErrorKind::IoError,
778 "connection dropped",
779 )));
780 assert_eq!(transient.class(), ErrorClass::Retryable);
781 }
782
783 #[test]
784 fn error_classification_valkey_permanent_is_terminal() {
785 use ferriskey::ErrorKind;
786 let permanent = ScriptError::Valkey(ferriskey::Error::from((
787 ErrorKind::AuthenticationFailed,
788 "bad creds",
789 )));
790 assert_eq!(permanent.class(), ErrorClass::Terminal);
791
792 let fatal_recv = ScriptError::Valkey(ferriskey::Error::from((
795 ErrorKind::FatalReceiveError,
796 "response lost",
797 )));
798 assert_eq!(fatal_recv.class(), ErrorClass::Terminal);
799 }
800
801 #[test]
802 fn error_classification_informational() {
803 assert_eq!(
804 ScriptError::ExecutionNotSuspended.class(),
805 ErrorClass::Informational
806 );
807 assert_eq!(
808 ScriptError::DuplicateSignal.class(),
809 ErrorClass::Informational
810 );
811 assert_eq!(
812 ScriptError::OkAlreadyApplied.class(),
813 ErrorClass::Informational
814 );
815 }
816
817 #[test]
818 fn error_classification_bug() {
819 assert_eq!(ScriptError::ActiveAttemptExists.class(), ErrorClass::Bug);
820 assert_eq!(
821 ScriptError::AttemptNotInCreatedState.class(),
822 ErrorClass::Bug
823 );
824 }
825
826 #[test]
827 fn error_classification_expected() {
828 assert_eq!(ScriptError::StreamNotFound.class(), ErrorClass::Expected);
829 }
830
831 #[test]
832 fn error_classification_budget_soft_exceeded() {
833 assert_eq!(
835 ScriptError::BudgetSoftExceeded.class(),
836 ErrorClass::Informational
837 );
838 }
839
840 #[test]
841 fn error_classification_soft_error() {
842 assert_eq!(ScriptError::InvalidFrameType.class(), ErrorClass::SoftError);
843 }
844
845 #[test]
846 fn from_code_roundtrip() {
847 let codes = [
848 "stale_lease", "lease_expired", "lease_revoked",
849 "execution_not_active", "no_active_lease", "active_attempt_exists",
850 "use_claim_resumed_execution", "not_a_resumed_execution",
851 "execution_not_leaseable", "lease_conflict",
852 "invalid_claim_grant", "claim_grant_expired",
853 "budget_exceeded", "budget_soft_exceeded",
854 "execution_not_suspended", "already_suspended",
855 "waitpoint_closed", "waitpoint_not_found",
856 "target_not_signalable", "waitpoint_pending_use_buffer_script",
857 "invalid_lease_for_suspend", "resume_condition_not_met",
858 "signal_limit_exceeded",
859 "execution_not_terminal", "max_replays_exhausted",
860 "stream_closed", "stale_owner_cannot_append", "retention_limit_exceeded",
861 "execution_not_eligible", "execution_not_in_eligible_set",
862 "grant_already_exists", "execution_not_reclaimable",
863 "invalid_dependency", "stale_graph_revision",
864 "execution_already_in_flow", "cycle_detected",
865 "execution_not_found", "max_retries_exhausted",
866 "flow_not_found", "execution_not_in_flow",
867 "dependency_already_exists", "self_referencing_edge",
868 "flow_already_terminal",
869 "deps_not_satisfied", "not_blocked_by_deps",
870 "not_runnable", "terminal", "invalid_blocking_reason",
871 "waitpoint_not_pending", "pending_waitpoint_expired",
872 "invalid_waitpoint_for_execution", "waitpoint_already_exists",
873 "waitpoint_not_open",
874 ];
875 for code in codes {
876 let err = ScriptError::from_code(code);
877 assert!(err.is_some(), "failed to parse code: {code}");
878 }
879 }
880
881 #[test]
882 fn from_code_unknown_returns_none() {
883 assert!(ScriptError::from_code("nonexistent_error").is_none());
884 }
885
886 #[test]
887 fn fence_required_classifies_terminal() {
888 assert_eq!(ScriptError::FenceRequired.class(), ErrorClass::Terminal);
889 assert_eq!(
890 ScriptError::PartialFenceTriple.class(),
891 ErrorClass::Terminal
892 );
893 }
894
895 #[test]
896 fn fence_required_from_code_roundtrips() {
897 assert!(matches!(
898 ScriptError::from_code("fence_required"),
899 Some(ScriptError::FenceRequired)
900 ));
901 assert!(matches!(
902 ScriptError::from_code("partial_fence_triple"),
903 Some(ScriptError::PartialFenceTriple)
904 ));
905 }
906
907 #[test]
913 fn parse_structured_fields_render_and_match() {
914 let with_exec = ScriptError::Parse {
915 fcall: "ff_claim_execution".into(),
916 execution_id: Some("018f-abc".into()),
917 message: "expected Array".into(),
918 };
919 assert_eq!(
920 with_exec.to_string(),
921 "parse error: ff_claim_execution[exec=018f-abc]: expected Array"
922 );
923 assert!(matches!(
924 &with_exec,
925 ScriptError::Parse { execution_id: Some(e), .. } if e == "018f-abc"
926 ));
927
928 let no_exec = ScriptError::Parse {
929 fcall: "stream_tail_decode".into(),
930 execution_id: None,
931 message: "unexpected array length 3".into(),
932 };
933 assert_eq!(
934 no_exec.to_string(),
935 "parse error: stream_tail_decode: unexpected array length 3"
936 );
937 assert!(matches!(
938 &no_exec,
939 ScriptError::Parse { execution_id: None, fcall, .. } if !fcall.is_empty()
940 ));
941 }
942}