smith-agents 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -0
  3. data/README.md +116 -6
  4. data/docs/CONFIGURATION.md +4 -1
  5. data/docs/PATTERNS.md +7 -0
  6. data/docs/PERSISTENCE.md +502 -1
  7. data/docs/workflow_claim.md +4 -1
  8. data/lib/smith/agent/lifecycle.rb +19 -10
  9. data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
  10. data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
  11. data/lib/smith/agent/registry.rb +8 -0
  12. data/lib/smith/agent.rb +24 -2
  13. data/lib/smith/budget/amount_contract.rb +67 -0
  14. data/lib/smith/budget/amount_representation.rb +43 -0
  15. data/lib/smith/budget/decimal_context.rb +18 -0
  16. data/lib/smith/budget/ledger.rb +110 -18
  17. data/lib/smith/budget/ledger_publication.rb +60 -0
  18. data/lib/smith/budget/ledger_state.rb +21 -0
  19. data/lib/smith/budget/ledger_state_transition.rb +72 -0
  20. data/lib/smith/budget/public_state_validator.rb +25 -0
  21. data/lib/smith/budget/reservation.rb +20 -0
  22. data/lib/smith/budget/reservation_contract.rb +34 -0
  23. data/lib/smith/context/observation_masking.rb +3 -1
  24. data/lib/smith/context/state_injection.rb +1 -1
  25. data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -3
  26. data/lib/smith/error.rb +5 -0
  27. data/lib/smith/errors.rb +6 -0
  28. data/lib/smith/exponential_backoff.rb +128 -0
  29. data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
  30. data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
  31. data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
  32. data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
  33. data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
  34. data/lib/smith/persistence_adapters/active_record_store.rb +95 -52
  35. data/lib/smith/persistence_adapters/cache_store.rb +4 -1
  36. data/lib/smith/persistence_adapters/memory.rb +45 -14
  37. data/lib/smith/persistence_adapters/payload_version.rb +23 -0
  38. data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
  39. data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
  40. data/lib/smith/persistence_adapters/redis_store.rb +38 -43
  41. data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
  42. data/lib/smith/persistence_adapters/retry.rb +22 -10
  43. data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
  44. data/lib/smith/persistence_adapters.rb +26 -18
  45. data/lib/smith/persistence_payload_conflict.rb +12 -0
  46. data/lib/smith/tool/budget_enforcement.rb +2 -2
  47. data/lib/smith/types.rb +1 -0
  48. data/lib/smith/version.rb +2 -1
  49. data/lib/smith/workflow/branch_env.rb +1 -1
  50. data/lib/smith/workflow/budget_integration.rb +21 -11
  51. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  52. data/lib/smith/workflow/definition_identity.rb +25 -0
  53. data/lib/smith/workflow/deterministic_step.rb +2 -2
  54. data/lib/smith/workflow/dsl.rb +73 -5
  55. data/lib/smith/workflow/durability.rb +2 -1
  56. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  57. data/lib/smith/workflow/execution.rb +16 -18
  58. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  59. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  60. data/lib/smith/workflow/fanout_execution.rb +1 -1
  61. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  62. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  63. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  64. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  65. data/lib/smith/workflow/graph/metrics.rb +1 -3
  66. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  67. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  68. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  69. data/lib/smith/workflow/graph/reachability.rb +19 -31
  70. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  71. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  72. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  73. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  74. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  75. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  76. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  77. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  78. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  79. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  80. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  81. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  82. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  83. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  84. data/lib/smith/workflow/graph/validator.rb +1 -1
  85. data/lib/smith/workflow/graph.rb +62 -4
  86. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  87. data/lib/smith/workflow/identifier.rb +24 -0
  88. data/lib/smith/workflow/message_admission.rb +40 -0
  89. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  90. data/lib/smith/workflow/message_batch.rb +60 -0
  91. data/lib/smith/workflow/message_value_normalizer.rb +133 -0
  92. data/lib/smith/workflow/nested_execution.rb +10 -0
  93. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  94. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  95. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  96. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  97. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  98. data/lib/smith/workflow/parallel.rb +19 -22
  99. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  100. data/lib/smith/workflow/parallel_execution.rb +7 -3
  101. data/lib/smith/workflow/persistence.rb +49 -10
  102. data/lib/smith/workflow/prepared_step.rb +122 -0
  103. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  104. data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
  105. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  106. data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
  107. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  108. data/lib/smith/workflow/retry_execution.rb +20 -11
  109. data/lib/smith/workflow/split_step_persistence/boundary.rb +128 -0
  110. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  111. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  112. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +119 -0
  113. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
  114. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  115. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  116. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  117. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  118. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  119. data/lib/smith/workflow/split_step_persistence/execution.rb +128 -0
  120. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
  121. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
  122. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
  123. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  124. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
  125. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  126. data/lib/smith/workflow/split_step_persistence/inheritance.rb +22 -0
  127. data/lib/smith/workflow/split_step_persistence/payloads.rb +54 -0
  128. data/lib/smith/workflow/split_step_persistence/preparation.rb +125 -0
  129. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +117 -0
  130. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  131. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +54 -0
  132. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  133. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  134. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  135. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  136. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  137. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
  138. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +102 -0
  139. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  140. data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
  141. data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
  142. data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
  143. data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
  144. data/lib/smith/workflow/split_step_persistence.rb +79 -0
  145. data/lib/smith/workflow/step_completion.rb +31 -0
  146. data/lib/smith/workflow/string_snapshot.rb +25 -0
  147. data/lib/smith/workflow/transition.rb +122 -133
  148. data/lib/smith/workflow/transition_actionability.rb +22 -0
  149. data/lib/smith/workflow.rb +57 -19
  150. data/lib/smith.rb +42 -2
  151. data/sig/smith.rbs +1 -0
  152. metadata +131 -19
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module Preparation
7
+ DESCRIPTOR_PHASES = %i[
8
+ prepared_uncommitted prepared verifying_execution execution_authorized executing executed attempted
9
+ dispatch_claimed_uncommitted dispatch_claimed dispatch_rejected dispatch_unknown
10
+ confirming_dispatch checkpointing checkpoint_retryable checkpoint_unknown
11
+ checkpointed confirming_checkpoint
12
+ ].freeze
13
+ private_constant :DESCRIPTOR_PHASES
14
+
15
+ def prepare_persisted_step!(key = nil, adapter: Smith.persistence_adapter)
16
+ ensure_strict_split_step_persistence!
17
+ intent_claimed = claim_split_step_preparation_intent!
18
+ return unless intent_claimed
19
+
20
+ preparation_finalized = false
21
+ prepare_claimed_persisted_step!(key, adapter)
22
+ preparation_finalized = true
23
+ persist_claimed_split_step_preparation!(@split_step_persistence_key, @split_step_adapter)
24
+ @split_step_transition_name
25
+ ensure
26
+ release_split_step_preparation_intent! if intent_claimed && !preparation_finalized
27
+ end
28
+
29
+ def confirm_prepared_step!
30
+ confirmation_claimed = false
31
+ claim_split_step_confirmation!
32
+ confirmation_claimed = true
33
+ confirmed = false
34
+ payload = @split_step_adapter.fetch(@split_step_persistence_key)
35
+ raise WorkflowError, "the persisted split-step preparation is not committed" unless
36
+ persisted_split_step_payload?(payload, @split_step_preparation_payload)
37
+
38
+ @split_step_mutex.synchronize do
39
+ @split_step_phase = :prepared
40
+ @split_step_transaction_thread = nil
41
+ @split_step_transaction_identity = nil
42
+ end
43
+ confirmed = true
44
+ self
45
+ ensure
46
+ restore_unconfirmed_preparation! if confirmation_claimed && !confirmed
47
+ end
48
+
49
+ def prepared_persisted_step
50
+ phase, descriptor, adapter, transaction_identity = @split_step_mutex.synchronize do
51
+ [
52
+ @split_step_phase,
53
+ @split_step_prepared_descriptor,
54
+ @split_step_adapter,
55
+ @split_step_transaction_identity
56
+ ]
57
+ end
58
+ return unless DESCRIPTOR_PHASES.include?(phase) && descriptor
59
+ return descriptor unless phase == :prepared_uncommitted
60
+
61
+ descriptor if TransactionIdentity.matches?(adapter, transaction_identity)
62
+ end
63
+
64
+ private
65
+
66
+ def ensure_strict_split_step_persistence!
67
+ return if strict_idempotency?
68
+
69
+ raise WorkflowError, "split-step persistence requires idempotency_mode :strict"
70
+ end
71
+
72
+ def prepare_claimed_persisted_step!(key, adapter)
73
+ transition = pending_split_step_transition
74
+ transition_name = @next_transition_name || transition&.name
75
+ raise WorkflowError, "workflow has no pending transition" unless transition_name
76
+
77
+ ensure_transition_budget!
78
+ resolved_key = normalize_split_step_persistence_key(candidate_split_step_persistence_key(key))
79
+ store = persistence_adapter!(adapter)
80
+ persistence_ttl = validate_split_step_adapter!(store)
81
+ finalize_split_step_preparation!(transition, transition_name, resolved_key, store, persistence_ttl)
82
+ end
83
+
84
+ def pending_split_step_transition
85
+ transition = if @next_transition_name
86
+ self.class.find_transition(@next_transition_name)
87
+ else
88
+ self.class.first_transition_from(@state)
89
+ end
90
+ validate_transition_origin!(transition) if transition
91
+ transition
92
+ end
93
+
94
+ def candidate_split_step_persistence_key(key)
95
+ return key unless key.nil? || blank_key?(key)
96
+ return @persistence_key unless blank_key?(@persistence_key)
97
+
98
+ self.class.send(:resolve_persistence_key!, key:, context: @context)
99
+ end
100
+
101
+ def normalize_split_step_persistence_key(key) = key.to_s.dup.freeze
102
+
103
+ def validate_split_step_adapter!(adapter)
104
+ unless Smith::PersistenceAdapters.supports?(adapter, :store_versioned)
105
+ raise WorkflowError, "split-step persistence requires an adapter with store_versioned"
106
+ end
107
+
108
+ validate_restart_safe_adapter!(adapter)
109
+ unless split_step_adapter_accepts_explicit_ttl?(adapter)
110
+ raise WorkflowError, "split-step persistence requires store_versioned to accept ttl:"
111
+ end
112
+ return unless effective_persistence_ttl
113
+
114
+ raise WorkflowError, "split-step persistence requires non-expiring workflow persistence"
115
+ end
116
+
117
+ def split_step_adapter_accepts_explicit_ttl?(adapter)
118
+ adapter.method(:store_versioned).parameters.any? do |kind, name|
119
+ kind == :keyrest || (%i[key keyreq].include?(kind) && name == :ttl)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module PreparationClaim
7
+ private
8
+
9
+ def claim_split_step_preparation_intent!
10
+ @split_step_mutex.synchronize do
11
+ ensure_no_split_step_boundary!
12
+ return false if terminal?
13
+
14
+ @split_step_phase = :claiming_preparation
15
+ @split_step_preparation_thread = Thread.current
16
+ @split_step_definition_digest = self.class.send(:seal_definition_identity!)
17
+ true
18
+ end
19
+ end
20
+
21
+ def release_split_step_preparation_intent!
22
+ @split_step_mutex.synchronize do
23
+ next unless @split_step_phase == :claiming_preparation
24
+ next unless @split_step_preparation_thread.equal?(Thread.current)
25
+
26
+ @split_step_phase = nil
27
+ @split_step_preparation_thread = nil
28
+ remove_instance_variable(:@split_step_definition_digest) if
29
+ instance_variable_defined?(:@split_step_definition_digest)
30
+ end
31
+ end
32
+
33
+ def finalize_split_step_preparation!(transition, transition_name, key, adapter, persistence_ttl)
34
+ transaction_identity = TransactionIdentity.capture(adapter)
35
+ transition_signature = TransitionContract.capture(transition)
36
+ @split_step_mutex.synchronize do
37
+ unless active_split_step_preparation_claim?
38
+ raise WorkflowError, "the split-step preparation claim is no longer active"
39
+ end
40
+
41
+ @split_step_phase = :preparing
42
+ @split_step_transition_name = transition_name
43
+ @split_step_transition = transition
44
+ @split_step_transition_signature = transition_signature
45
+ @split_step_origin_state = @state
46
+ @split_step_token = SecureRandom.uuid.freeze
47
+ detach_split_step_execution_state!
48
+ target_writer = PreparationClaim.instance_method(:assign_split_step_persistence_target!)
49
+ target_writer.bind_call(self, key, adapter, persistence_ttl, transaction_identity)
50
+ end
51
+ end
52
+
53
+ def assign_split_step_persistence_target!(key, adapter, persistence_ttl, transaction_identity)
54
+ @split_step_persistence_key = key
55
+ @split_step_adapter = adapter
56
+ @split_step_persistence_ttl = persistence_ttl
57
+ @split_step_transaction_identity = transaction_identity
58
+ @split_step_dispatch_started = false
59
+ @persistence_key = key
60
+ @split_step_persist_permit = true
61
+ end
62
+
63
+ def active_split_step_preparation_claim?
64
+ @split_step_phase == :claiming_preparation &&
65
+ @split_step_preparation_thread.equal?(Thread.current)
66
+ end
67
+
68
+ def mark_split_step_prepared!(_adapter)
69
+ @split_step_mutex.synchronize do
70
+ phase = @split_step_transaction_identity ? :prepared_uncommitted : :prepared
71
+ unless @split_step_pending_descriptor
72
+ raise WorkflowError, "split-step preparation descriptor was not captured"
73
+ end
74
+
75
+ @split_step_prepared_descriptor = @split_step_pending_descriptor
76
+ @split_step_pending_descriptor = nil
77
+ @split_step_phase = phase
78
+ @split_step_preparation_thread = nil
79
+ @split_step_persist_permit = false
80
+ end
81
+ end
82
+
83
+ def build_split_step_prepared_descriptor(persistence_version)
84
+ PreparedStep.new(
85
+ token: @split_step_token,
86
+ transition: @split_step_transition_name.to_s,
87
+ from: @split_step_origin_state.to_s,
88
+ persistence_key: @split_step_persistence_key,
89
+ persistence_version: persistence_version,
90
+ step_number: @step_count + 1,
91
+ preparation_digest: @split_step_preparation_digest,
92
+ definition_digest: effective_definition_digest
93
+ )
94
+ end
95
+
96
+ def claim_split_step_confirmation!
97
+ @split_step_mutex.synchronize do
98
+ unless @split_step_phase == :prepared_uncommitted
99
+ raise WorkflowError, "no uncommitted split-step preparation is awaiting confirmation"
100
+ end
101
+ if @split_step_adapter.transaction_open?
102
+ raise WorkflowError, "the split-step preparation transaction is still open"
103
+ end
104
+
105
+ @split_step_phase = :confirming_preparation
106
+ end
107
+ end
108
+
109
+ def restore_unconfirmed_preparation!
110
+ @split_step_mutex.synchronize do
111
+ @split_step_phase = :prepared_uncommitted if @split_step_phase == :confirming_preparation
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module PreparationPayload
7
+ private
8
+
9
+ def capture_split_step_preparation_payload!(payload, next_version)
10
+ validate_split_step_marker!(payload, expected: true)
11
+ prepared_payload = split_step_preparation_payload(payload)
12
+ @split_step_preparation_payload = prepared_payload
13
+ @split_step_preparation_digest = CanonicalPayloadDigest.call(prepared_payload)
14
+ descriptor_builder = PreparationClaim.instance_method(:build_split_step_prepared_descriptor)
15
+ @split_step_pending_descriptor = descriptor_builder.bind_call(self, next_version)
16
+ prepared_payload
17
+ rescue StandardError
18
+ resetter = PreparationRecovery.instance_method(:reset_failed_split_step_preparation!)
19
+ resetter.bind_call(self)
20
+ raise
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module PreparationRecovery
7
+ private
8
+
9
+ def persist_claimed_split_step_preparation!(resolved_key, store)
10
+ persist!(resolved_key, adapter: store)
11
+ mark_split_step_prepared!(store)
12
+ rescue PersistenceVersionConflict
13
+ handle_split_step_preparation_conflict!
14
+ raise
15
+ rescue StandardError
16
+ if @split_step_dispatch_started
17
+ mark_split_step_preparation_unknown!
18
+ else
19
+ reset_failed_split_step_preparation!
20
+ end
21
+ raise
22
+ end
23
+
24
+ def reset_failed_split_step_preparation!
25
+ return unless @split_step_phase == :preparing
26
+
27
+ clear_step_in_progress!
28
+ clear_split_step_boundary!
29
+ end
30
+
31
+ def handle_split_step_preparation_conflict!
32
+ payload = @split_step_adapter.fetch(@split_step_persistence_key)
33
+ if persisted_split_step_payload?(payload, @split_step_preparation_payload)
34
+ mark_split_step_preparation_unknown!
35
+ else
36
+ reset_failed_split_step_preparation!
37
+ end
38
+ rescue StandardError
39
+ mark_split_step_preparation_unknown!
40
+ end
41
+
42
+ def mark_split_step_preparation_unknown!
43
+ @split_step_mutex.synchronize do
44
+ if @split_step_phase == :preparing
45
+ @split_step_phase = :preparation_unknown
46
+ @split_step_prepared_descriptor = nil
47
+ @split_step_transaction_identity = nil
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module Recovery
7
+ private
8
+
9
+ def recover_prepared_step(recovery, adapter)
10
+ decision = validate_prepared_step_recovery!(recovery)
11
+ descriptor = decision.prepared_step
12
+ store = persistence_adapter!(adapter)
13
+ ensure_strict_split_step_persistence!
14
+ validate_split_step_adapter!(store)
15
+ ensure_recovery_outside_transaction!(store)
16
+ payload = fetch_recovery_payload!(store, descriptor.persistence_key)
17
+ document = validate_recovery_payload!(payload, decision, store)
18
+ restore_state(document, allow_step_in_progress: true)
19
+ attach_recovered_split_step!(descriptor, payload, store, dispatch_claim: decision.dispatch_claim)
20
+ self
21
+ end
22
+
23
+ def validate_prepared_step_recovery!(recovery)
24
+ return recovery if recovery.is_a?(PreparedStepRecovery)
25
+
26
+ raise ArgumentError, "recovery must be a Smith::Workflow::PreparedStepRecovery"
27
+ end
28
+
29
+ def ensure_recovery_outside_transaction!(adapter)
30
+ return unless Smith::PersistenceAdapters.supports?(adapter, :transaction_open?)
31
+ return unless adapter.transaction_open?
32
+
33
+ raise WorkflowError, "prepared-step recovery requires committed host authority outside a transaction"
34
+ end
35
+
36
+ def fetch_recovery_payload!(adapter, key)
37
+ payload = adapter.fetch(key)
38
+ return payload if payload.is_a?(String)
39
+
40
+ raise WorkflowError, "the persisted split-step preparation is not available"
41
+ end
42
+
43
+ def validate_recovery_payload!(payload, decision, adapter)
44
+ if payload.bytesize > CanonicalPayloadDigest::MAX_BYTES
45
+ raise WorkflowError,
46
+ "persisted split-step preparation exceeds maximum bytes #{CanonicalPayloadDigest::MAX_BYTES}"
47
+ end
48
+
49
+ document = JSON.parse(payload)
50
+ raise WorkflowError, "the persisted split-step preparation must be an object" unless document.is_a?(Hash)
51
+
52
+ validate_recovery_definition!(document, decision.prepared_step)
53
+ validate_recovery_identity!(document, decision, adapter)
54
+ validate_recovery_digest!(payload, decision)
55
+ document
56
+ rescue JSON::ParserError, TypeError
57
+ raise WorkflowError, "the persisted split-step preparation is invalid"
58
+ end
59
+
60
+ def validate_recovery_definition!(document, descriptor)
61
+ current_digest = self.class.definition_digest
62
+ return if recovery_digests_match?(document, descriptor, current_digest) &&
63
+ recovery_class_matches?(document) && recovery_schema_matches?(document)
64
+
65
+ raise WorkflowError, "the prepared step does not match the current workflow definition"
66
+ end
67
+
68
+ def recovery_digests_match?(document, descriptor, current_digest)
69
+ current_digest &&
70
+ descriptor.definition_digest == current_digest &&
71
+ document["definition_digest"] == current_digest
72
+ end
73
+
74
+ def recovery_class_matches?(document)
75
+ !self.class.name.to_s.empty? && document["class"] == self.class.name
76
+ end
77
+
78
+ def recovery_schema_matches?(document)
79
+ document["schema_version"] == self.class.persistence_schema_version
80
+ end
81
+
82
+ def validate_recovery_identity!(document, decision, adapter)
83
+ descriptor = decision.prepared_step
84
+ return if recovery_marker_matches?(document, decision, adapter) &&
85
+ recovery_position_matches?(document, descriptor)
86
+
87
+ raise WorkflowError, "the persisted split-step preparation does not match the recovery descriptor"
88
+ end
89
+
90
+ def recovery_marker_matches?(document, decision, adapter)
91
+ descriptor = decision.prepared_step
92
+ document["step_in_progress"] == true &&
93
+ recovery_phase_matches?(document, decision.dispatch_claim) &&
94
+ document["split_step_persistence_identity"] == adapter.persistence_identity &&
95
+ document["split_step_token"] == descriptor.token &&
96
+ document["persistence_key"] == descriptor.persistence_key &&
97
+ document["persistence_version"] == descriptor.persistence_version
98
+ end
99
+
100
+ def recovery_phase_matches?(document, dispatch_claim)
101
+ if dispatch_claim
102
+ document["split_step_phase"] == "dispatching" &&
103
+ document["split_step_dispatch_token"] == dispatch_claim.token
104
+ else
105
+ document["split_step_phase"] == "prepared" && !document.key?("split_step_dispatch_token")
106
+ end
107
+ end
108
+
109
+ def recovery_position_matches?(document, descriptor)
110
+ step_count = document["step_count"]
111
+ step_count.is_a?(Integer) &&
112
+ step_count + 1 == descriptor.step_number &&
113
+ document["state"].to_s == descriptor.from
114
+ end
115
+
116
+ def validate_recovery_digest!(payload, decision)
117
+ expected = decision.dispatch_claim&.dispatch_digest || decision.prepared_step.preparation_digest
118
+ return if CanonicalPayloadDigest.call(payload) == expected
119
+
120
+ raise WorkflowError, "the persisted split-step recovery payload digest does not match"
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module RecoveryBoundary
7
+ private
8
+
9
+ def attach_recovered_split_step!(descriptor, payload, adapter, dispatch_claim:)
10
+ transition = pending_split_step_transition
11
+ transition_name = @next_transition_name || transition&.name
12
+ unless transition && transition_name.to_s == descriptor.transition && @state.to_s == descriptor.from
13
+ raise WorkflowError, "the recovered transition does not match the current workflow position"
14
+ end
15
+
16
+ detach_split_step_execution_state!
17
+ install_recovered_split_step!(descriptor, payload, adapter, transition, dispatch_claim)
18
+ end
19
+
20
+ def install_recovered_split_step!(descriptor, payload, adapter, transition, dispatch_claim)
21
+ signature = TransitionContract.capture(transition)
22
+ @split_step_mutex.synchronize do
23
+ install_recovered_transition!(transition, signature)
24
+ install_recovered_persistence!(descriptor, payload, adapter)
25
+ install_recovered_dispatch!(dispatch_claim)
26
+ end
27
+ end
28
+
29
+ def install_recovered_transition!(transition, signature)
30
+ @split_step_transition_name = transition.name
31
+ @split_step_transition = transition
32
+ @split_step_transition_signature = signature
33
+ @split_step_origin_state = @state
34
+ end
35
+
36
+ def install_recovered_persistence!(descriptor, payload, adapter)
37
+ @split_step_token = descriptor.token
38
+ @split_step_prepared_descriptor = descriptor
39
+ @split_step_persistence_key = descriptor.persistence_key
40
+ @split_step_adapter = adapter
41
+ @split_step_persistence_ttl = nil
42
+ @split_step_preparation_payload = payload.dup.freeze
43
+ @split_step_preparation_digest = descriptor.preparation_digest
44
+ @split_step_definition_digest = descriptor.definition_digest
45
+ end
46
+
47
+ def install_recovered_dispatch!(dispatch_claim)
48
+ @split_step_dispatch_started = false
49
+ @split_step_dispatch_token = dispatch_claim&.token
50
+ @split_step_dispatch_descriptor = dispatch_claim
51
+ @split_step_phase = dispatch_claim ? :dispatch_claimed : :prepared
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module RecoveryClassMethods
7
+ def recover_prepared_step(recovery, adapter: Smith.persistence_adapter)
8
+ seal_definition_identity!
9
+ allocate.send(:recover_prepared_step, recovery, adapter)
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ module SplitStepPersistence
8
+ class ReplaceExactSignature
9
+ extend Dry::Initializer
10
+
11
+ CONTRACT_KEYWORDS = %i[expected_payload ttl].freeze
12
+
13
+ param :callable
14
+
15
+ def valid? = accepts_two_values? && accepts_keywords?
16
+
17
+ private
18
+
19
+ def parameters = callable.parameters
20
+
21
+ def accepts_two_values?
22
+ required = parameters.count { |kind, _| kind == :req }
23
+ positional = parameters.count { |kind, _| %i[req opt].include?(kind) }
24
+ rest = parameters.any? { |kind, _| kind == :rest }
25
+ required <= 2 && (positional >= 2 || rest)
26
+ end
27
+
28
+ def accepts_keywords?
29
+ required_keywords_supported? && contract_keywords_accepted?
30
+ end
31
+
32
+ def required_keywords_supported?
33
+ required = parameters.filter_map { |kind, name| name if kind == :keyreq }
34
+ (required - CONTRACT_KEYWORDS).empty?
35
+ end
36
+
37
+ def contract_keywords_accepted?
38
+ return true if parameters.any? { |kind, _| kind == :keyrest }
39
+
40
+ keywords = parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) }
41
+ CONTRACT_KEYWORDS.all? { |name| keywords.include?(name) }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "replace_exact_signature"
4
+
5
+ module Smith
6
+ class Workflow
7
+ module SplitStepPersistence
8
+ module RestartSafeAdapter
9
+ private
10
+
11
+ def validate_restart_safe_adapter!(adapter)
12
+ return unless restart_safe_split_step?
13
+
14
+ ensure_replace_exact_capability!(adapter)
15
+ ensure_replace_exact_signature!(adapter)
16
+ ensure_persistence_identity!(adapter)
17
+ end
18
+
19
+ def ensure_replace_exact_capability!(adapter)
20
+ return if Smith::PersistenceAdapters.supports?(adapter, :replace_exact)
21
+
22
+ raise WorkflowError, "restart-safe split-step persistence requires replace_exact"
23
+ end
24
+
25
+ def ensure_replace_exact_signature!(adapter)
26
+ return if ReplaceExactSignature.new(adapter.method(:replace_exact)).valid?
27
+
28
+ raise WorkflowError,
29
+ "restart-safe split-step persistence requires replace_exact(key, payload, expected_payload:, ttl:)"
30
+ end
31
+
32
+ def ensure_persistence_identity!(adapter)
33
+ identity = adapter.persistence_identity if
34
+ Smith::PersistenceAdapters.supports?(adapter, :persistence_identity)
35
+ return if identity.is_a?(String) && !identity.empty? && identity.bytesize <= 256
36
+
37
+ raise WorkflowError, "restart-safe split-step persistence requires a bounded persistence_identity"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end