smith-agents 0.4.5 → 0.6.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 (186) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +264 -0
  3. data/README.md +191 -4
  4. data/docs/CONFIGURATION.md +4 -1
  5. data/docs/PATTERNS.md +7 -0
  6. data/docs/PERSISTENCE.md +347 -25
  7. data/lib/smith/agent/lifecycle.rb +19 -10
  8. data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
  9. data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
  10. data/lib/smith/agent/registry.rb +8 -0
  11. data/lib/smith/agent.rb +37 -2
  12. data/lib/smith/budget/amount_contract.rb +67 -0
  13. data/lib/smith/budget/amount_representation.rb +43 -0
  14. data/lib/smith/budget/decimal_context.rb +18 -0
  15. data/lib/smith/budget/ledger.rb +110 -18
  16. data/lib/smith/budget/ledger_publication.rb +60 -0
  17. data/lib/smith/budget/ledger_state.rb +21 -0
  18. data/lib/smith/budget/ledger_state_transition.rb +72 -0
  19. data/lib/smith/budget/public_state_validator.rb +25 -0
  20. data/lib/smith/budget/reservation.rb +20 -0
  21. data/lib/smith/budget/reservation_contract.rb +34 -0
  22. data/lib/smith/context/observation_masking.rb +3 -1
  23. data/lib/smith/context/state_injection.rb +1 -1
  24. data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -4
  25. data/lib/smith/error.rb +5 -0
  26. data/lib/smith/errors.rb +10 -0
  27. data/lib/smith/exponential_backoff.rb +128 -0
  28. data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
  29. data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
  30. data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
  31. data/lib/smith/persistence_adapters/active_record_store.rb +7 -4
  32. data/lib/smith/persistence_adapters/cache_store.rb +1 -0
  33. data/lib/smith/persistence_adapters/memory.rb +16 -1
  34. data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
  35. data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
  36. data/lib/smith/persistence_adapters/redis_store.rb +28 -11
  37. data/lib/smith/persistence_adapters/retry.rb +22 -10
  38. data/lib/smith/persistence_adapters.rb +21 -18
  39. data/lib/smith/persistence_payload_conflict.rb +12 -0
  40. data/lib/smith/tool/budget_enforcement.rb +2 -2
  41. data/lib/smith/types.rb +1 -0
  42. data/lib/smith/version.rb +2 -1
  43. data/lib/smith/workflow/branch_env.rb +1 -1
  44. data/lib/smith/workflow/budget_integration.rb +21 -11
  45. data/lib/smith/workflow/composite/branch.rb +93 -0
  46. data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
  47. data/lib/smith/workflow/composite/branch_contract.rb +102 -0
  48. data/lib/smith/workflow/composite/branch_execution.rb +124 -0
  49. data/lib/smith/workflow/composite/branch_failure.rb +91 -0
  50. data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
  51. data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
  52. data/lib/smith/workflow/composite/budget_math.rb +51 -0
  53. data/lib/smith/workflow/composite/contract.rb +100 -0
  54. data/lib/smith/workflow/composite/effects.rb +140 -0
  55. data/lib/smith/workflow/composite/effects_application.rb +36 -0
  56. data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
  57. data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
  58. data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
  59. data/lib/smith/workflow/composite/enums.rb +27 -0
  60. data/lib/smith/workflow/composite/error.rb +54 -0
  61. data/lib/smith/workflow/composite/error_evidence.rb +61 -0
  62. data/lib/smith/workflow/composite/execution_contract.rb +73 -0
  63. data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
  64. data/lib/smith/workflow/composite/input.rb +53 -0
  65. data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
  66. data/lib/smith/workflow/composite/outcome_set.rb +23 -0
  67. data/lib/smith/workflow/composite/payload.rb +128 -0
  68. data/lib/smith/workflow/composite/payload_digest.rb +28 -0
  69. data/lib/smith/workflow/composite/plan.rb +130 -0
  70. data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
  71. data/lib/smith/workflow/composite/planner.rb +53 -0
  72. data/lib/smith/workflow/composite/preparation.rb +27 -0
  73. data/lib/smith/workflow/composite/reducer.rb +133 -0
  74. data/lib/smith/workflow/composite/reduction.rb +31 -0
  75. data/lib/smith/workflow/composite/value_budget.rb +90 -0
  76. data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
  77. data/lib/smith/workflow/deadline_enforcement.rb +22 -5
  78. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  79. data/lib/smith/workflow/definition_identity.rb +25 -0
  80. data/lib/smith/workflow/deterministic_step.rb +2 -2
  81. data/lib/smith/workflow/dsl.rb +73 -5
  82. data/lib/smith/workflow/durability.rb +1 -1
  83. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  84. data/lib/smith/workflow/execution.rb +28 -30
  85. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  86. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  87. data/lib/smith/workflow/fanout_execution.rb +20 -19
  88. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  89. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  90. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  91. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  92. data/lib/smith/workflow/graph/metrics.rb +1 -3
  93. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  94. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  95. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  96. data/lib/smith/workflow/graph/reachability.rb +19 -31
  97. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  98. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  99. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  100. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  101. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  102. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  103. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  104. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  105. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  106. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  107. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  108. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  109. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  110. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  111. data/lib/smith/workflow/graph/validator.rb +1 -1
  112. data/lib/smith/workflow/graph.rb +62 -4
  113. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  114. data/lib/smith/workflow/identifier.rb +24 -0
  115. data/lib/smith/workflow/message_admission.rb +40 -0
  116. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  117. data/lib/smith/workflow/message_batch.rb +60 -0
  118. data/lib/smith/workflow/message_value_normalizer.rb +134 -0
  119. data/lib/smith/workflow/nested_execution.rb +10 -0
  120. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  121. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  122. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  123. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  124. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  125. data/lib/smith/workflow/parallel.rb +19 -22
  126. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  127. data/lib/smith/workflow/parallel_execution.rb +46 -14
  128. data/lib/smith/workflow/persistence.rb +37 -8
  129. data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
  130. data/lib/smith/workflow/prepared_step.rb +122 -0
  131. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  132. data/lib/smith/workflow/prepared_step_execution_authorization.rb +101 -0
  133. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  134. data/lib/smith/workflow/prepared_step_execution_scope.rb +111 -0
  135. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  136. data/lib/smith/workflow/process_local.rb +33 -0
  137. data/lib/smith/workflow/retry_execution.rb +20 -11
  138. data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
  139. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  140. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  141. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -5
  142. data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
  143. data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
  144. data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
  145. data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
  146. data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
  147. data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
  148. data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -0
  149. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  150. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  151. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  152. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  153. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  154. data/lib/smith/workflow/split_step_persistence/execution.rb +20 -64
  155. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +109 -0
  156. data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
  157. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +95 -0
  158. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +109 -0
  159. data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
  160. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  161. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +77 -0
  162. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  163. data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
  164. data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
  165. data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
  166. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
  167. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  168. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
  169. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  170. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  171. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  172. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  173. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  174. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +66 -0
  175. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  176. data/lib/smith/workflow/split_step_persistence.rb +50 -0
  177. data/lib/smith/workflow/step_completion.rb +31 -0
  178. data/lib/smith/workflow/step_context.rb +46 -0
  179. data/lib/smith/workflow/string_snapshot.rb +25 -0
  180. data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
  181. data/lib/smith/workflow/transition.rb +137 -133
  182. data/lib/smith/workflow/transition_actionability.rb +22 -0
  183. data/lib/smith/workflow.rb +64 -18
  184. data/lib/smith.rb +44 -2
  185. data/sig/smith.rbs +1 -0
  186. metadata +155 -19
@@ -7,10 +7,10 @@ module Smith
7
7
  private
8
8
 
9
9
  def persisted_split_step_payload?(payload, expected)
10
- return false unless payload
10
+ return false unless payload.is_a?(String) && expected.is_a?(String)
11
11
 
12
- JSON.parse(payload) == JSON.parse(expected)
13
- rescue JSON::ParserError, TypeError
12
+ CanonicalPayloadDigest.call(payload) == CanonicalPayloadDigest.call(expected)
13
+ rescue WorkflowError, JSON::ParserError, TypeError
14
14
  false
15
15
  end
16
16
 
@@ -38,6 +38,10 @@ module Smith
38
38
  def split_step_preparation_payload(payload)
39
39
  document = JSON.parse(payload)
40
40
  document["split_step_token"] = @split_step_token
41
+ if restart_safe_split_step?
42
+ document["split_step_phase"] = "prepared"
43
+ document["split_step_persistence_identity"] = @split_step_adapter.persistence_identity
44
+ end
41
45
  JSON.generate(document)
42
46
  end
43
47
 
@@ -4,6 +4,14 @@ module Smith
4
4
  class Workflow
5
5
  module SplitStepPersistence
6
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
+
7
15
  def prepare_persisted_step!(key = nil, adapter: Smith.persistence_adapter)
8
16
  ensure_strict_split_step_persistence!
9
17
  intent_claimed = claim_split_step_preparation_intent!
@@ -27,13 +35,32 @@ module Smith
27
35
  raise WorkflowError, "the persisted split-step preparation is not committed" unless
28
36
  persisted_split_step_payload?(payload, @split_step_preparation_payload)
29
37
 
30
- @split_step_mutex.synchronize { @split_step_phase = :prepared }
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
31
43
  confirmed = true
32
44
  self
33
45
  ensure
34
46
  restore_unconfirmed_preparation! if confirmation_claimed && !confirmed
35
47
  end
36
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
+
37
64
  private
38
65
 
39
66
  def ensure_strict_split_step_persistence!
@@ -58,7 +85,7 @@ module Smith
58
85
  transition = if @next_transition_name
59
86
  self.class.find_transition(@next_transition_name)
60
87
  else
61
- self.class.transitions_from(@state).first
88
+ self.class.first_transition_from(@state)
62
89
  end
63
90
  validate_transition_origin!(transition) if transition
64
91
  transition
@@ -78,6 +105,7 @@ module Smith
78
105
  raise WorkflowError, "split-step persistence requires an adapter with store_versioned"
79
106
  end
80
107
 
108
+ validate_restart_safe_adapter!(adapter)
81
109
  unless split_step_adapter_accepts_explicit_ttl?(adapter)
82
110
  raise WorkflowError, "split-step persistence requires store_versioned to accept ttl:"
83
111
  end
@@ -13,6 +13,7 @@ module Smith
13
13
 
14
14
  @split_step_phase = :claiming_preparation
15
15
  @split_step_preparation_thread = Thread.current
16
+ @split_step_definition_digest = self.class.send(:seal_definition_identity!)
16
17
  true
17
18
  end
18
19
  end
@@ -24,10 +25,13 @@ module Smith
24
25
 
25
26
  @split_step_phase = nil
26
27
  @split_step_preparation_thread = nil
28
+ remove_instance_variable(:@split_step_definition_digest) if
29
+ instance_variable_defined?(:@split_step_definition_digest)
27
30
  end
28
31
  end
29
32
 
30
33
  def finalize_split_step_preparation!(transition, transition_name, key, adapter, persistence_ttl)
34
+ transaction_identity = TransactionIdentity.capture(adapter)
31
35
  transition_signature = TransitionContract.capture(transition)
32
36
  @split_step_mutex.synchronize do
33
37
  unless active_split_step_preparation_claim?
@@ -41,32 +45,54 @@ module Smith
41
45
  @split_step_origin_state = @state
42
46
  @split_step_token = SecureRandom.uuid.freeze
43
47
  detach_split_step_execution_state!
44
- @split_step_persistence_key = key
45
- @split_step_adapter = adapter
46
- @split_step_persistence_ttl = persistence_ttl
47
- @persistence_key = key
48
- @split_step_persist_permit = true
48
+ target_writer = PreparationClaim.instance_method(:assign_split_step_persistence_target!)
49
+ target_writer.bind_call(self, key, adapter, persistence_ttl, transaction_identity)
49
50
  end
50
51
  end
51
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
+
52
63
  def active_split_step_preparation_claim?
53
64
  @split_step_phase == :claiming_preparation &&
54
65
  @split_step_preparation_thread.equal?(Thread.current)
55
66
  end
56
67
 
57
- def mark_split_step_prepared!(adapter)
68
+ def mark_split_step_prepared!(_adapter)
58
69
  @split_step_mutex.synchronize do
59
- @split_step_phase = if Smith::PersistenceAdapters.supports?(adapter, :transaction_open?) &&
60
- adapter.transaction_open?
61
- :prepared_uncommitted
62
- else
63
- :prepared
64
- end
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
65
78
  @split_step_preparation_thread = nil
66
79
  @split_step_persist_permit = false
67
80
  end
68
81
  end
69
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
+
70
96
  def claim_split_step_confirmation!
71
97
  @split_step_mutex.synchronize do
72
98
  unless @split_step_phase == :prepared_uncommitted
@@ -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
@@ -13,7 +13,11 @@ module Smith
13
13
  handle_split_step_preparation_conflict!
14
14
  raise
15
15
  rescue StandardError
16
- mark_split_step_preparation_unknown!
16
+ if @split_step_dispatch_started
17
+ mark_split_step_preparation_unknown!
18
+ else
19
+ reset_failed_split_step_preparation!
20
+ end
17
21
  raise
18
22
  end
19
23
 
@@ -37,7 +41,11 @@ module Smith
37
41
 
38
42
  def mark_split_step_preparation_unknown!
39
43
  @split_step_mutex.synchronize do
40
- @split_step_phase = :preparation_unknown if @split_step_phase == :preparing
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
41
49
  end
42
50
  end
43
51
  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
@@ -4,6 +4,43 @@ module Smith
4
4
  class Workflow
5
5
  module SplitStepPersistence
6
6
  module SubclassBoundary
7
+ class << self
8
+ def protect_execution_path!(workflow_class, *owners)
9
+ protected_method_names(owners).each do |method_name|
10
+ protect_method!(workflow_class, method_name)
11
+ end
12
+ end
13
+
14
+ private
15
+
16
+ def protected_method_names(owners)
17
+ owners.flat_map do |owner|
18
+ owner.private_instance_methods(false) + owner.protected_instance_methods(false)
19
+ end.uniq
20
+ end
21
+
22
+ def protect_method!(workflow_class, method_name)
23
+ return if boundary_method_names.include?(method_name)
24
+
25
+ implementation = workflow_class.instance_method(method_name)
26
+ define_method(method_name) do |*arguments, **keywords, &block|
27
+ active = SubclassBoundary
28
+ .instance_method(:smith_prepared_execution_active?)
29
+ .bind_call(self)
30
+ if active
31
+ implementation.bind_call(self, *arguments, **keywords, &block)
32
+ else
33
+ super(*arguments, **keywords, &block)
34
+ end
35
+ end
36
+ private method_name
37
+ end
38
+
39
+ def boundary_method_names
40
+ instance_methods(false) + private_instance_methods(false) + protected_instance_methods(false)
41
+ end
42
+ end
43
+
7
44
  def self.guard
8
45
  Module.new.tap do |guard|
9
46
  instance_methods(false).each do |method_name|
@@ -31,8 +68,33 @@ module Smith
31
68
  super
32
69
  end
33
70
 
71
+ def authorize_prepared_step_execution!(...)
72
+ ExecutionAuthorizationIssuance.instance_method(:authorize_prepared_step_execution!).bind_call(self, ...)
73
+ end
74
+
75
+ def prepare_composite_step!(...)
76
+ CompositeExecution.instance_method(:prepare_composite_step!).bind_call(self, ...)
77
+ end
78
+
79
+ def execute_prepared_composite_branch!(...)
80
+ CompositeExecution.instance_method(:execute_prepared_composite_branch!).bind_call(self, ...)
81
+ end
82
+
83
+ def reduce_prepared_composite_step!(...)
84
+ CompositeExecution.instance_method(:reduce_prepared_composite_step!).bind_call(self, ...)
85
+ end
86
+
34
87
  private
35
88
 
89
+ def smith_prepared_execution_active?
90
+ authorization = @split_step_active_execution_authorization
91
+ return true if authorization&.active_in_current_execution? == true
92
+
93
+ authorization = @split_step_execution_authorization
94
+ @split_step_phase == :execution_authorized &&
95
+ authorization&.issued_in_current_process? == true
96
+ end
97
+
36
98
  def effective_persistence_ttl
37
99
  return super unless split_step_boundary_active?
38
100
  return super unless instance_variable_defined?(:@split_step_persistence_ttl)
@@ -46,6 +108,10 @@ module Smith
46
108
 
47
109
  { ttl: ttl }
48
110
  end
111
+
112
+ def dispatch_store!(...)
113
+ DispatchBoundary.instance_method(:dispatch_store!).bind_call(self, ...)
114
+ end
49
115
  end
50
116
  end
51
117
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ module Smith
6
+ class Workflow
7
+ module SplitStepPersistence
8
+ class TransactionIdentity
9
+ extend Dry::Initializer
10
+
11
+ param :adapter
12
+
13
+ def self.capture(adapter) = new(adapter).capture
14
+ def self.matches?(adapter, expected) = new(adapter).matches?(expected)
15
+
16
+ def capture
17
+ return unless PersistenceAdapters.supports?(adapter, :transaction_open?)
18
+ return unless adapter.transaction_open?
19
+
20
+ ensure_identity_capability!
21
+ identity = adapter.transaction_identity
22
+ if !identity || identity.to_s.empty?
23
+ raise WorkflowError, "transactional split-step persistence requires an active transaction identity"
24
+ end
25
+
26
+ identity.to_s.dup.freeze
27
+ end
28
+
29
+ def matches?(expected)
30
+ capture == expected
31
+ rescue StandardError
32
+ false
33
+ end
34
+
35
+ private
36
+
37
+ def ensure_identity_capability!
38
+ return if PersistenceAdapters.supports?(adapter, :transaction_identity)
39
+
40
+ raise WorkflowError, "transactional split-step persistence requires transaction_identity"
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end