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,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module Boundary
7
+ def run_persisted!(...)
8
+ ensure_no_split_step_boundary!
9
+ super
10
+ end
11
+
12
+ def advance_persisted!(...)
13
+ ensure_no_split_step_boundary!
14
+ super
15
+ end
16
+
17
+ def clear_persisted!(...)
18
+ ensure_no_split_step_boundary!
19
+ super
20
+ end
21
+
22
+ def initialize_copy(source)
23
+ super
24
+ copied_split_step_phase = @split_step_phase
25
+ detach_copied_runtime_state!(source)
26
+ return unless copied_split_step_phase
27
+
28
+ @split_step_phase = :copied_boundary
29
+ reset_copied_execution_state!
30
+ reset_copied_dispatch_state!
31
+ end
32
+ private :initialize_copy
33
+
34
+ private
35
+
36
+ def reset_copied_execution_state!
37
+ @split_step_token = nil
38
+ @split_step_prepared_descriptor = nil
39
+ @split_step_transaction_identity = nil
40
+ @split_step_execution_thread = nil
41
+ @split_step_active_execution_authorization = nil
42
+ @split_step_execution_result = nil
43
+ @split_step_advance_permit = false
44
+ clear_split_step_execution_verification!
45
+ clear_split_step_execution_authorization!
46
+ end
47
+
48
+ def reset_copied_dispatch_state!
49
+ @split_step_dispatch_token = nil
50
+ @split_step_dispatch_descriptor = nil
51
+ @split_step_attempted_dispatch_descriptor = nil
52
+ @split_step_dispatch_thread = nil
53
+ @split_step_pre_dispatch_payload = nil
54
+ @split_step_attempted_dispatch_payload = nil
55
+ @split_step_preparation_thread = nil
56
+ @split_step_persist_permit = false
57
+ end
58
+
59
+ def detach_copied_runtime_state!(source)
60
+ source_messages = source.instance_variable_get(:@session_messages)
61
+ @split_step_mutex = Mutex.new
62
+ @persisted_keys_mutex = Mutex.new
63
+ @tool_results_mutex = Mutex.new
64
+ @usage_mutex = Mutex.new
65
+ detach_split_step_execution_state!
66
+ @session_messages = snapshot_value(source_messages || [])
67
+ end
68
+
69
+ def ensure_split_step_execution_allowed!
70
+ @split_step_mutex.synchronize do
71
+ return unless @split_step_phase
72
+
73
+ if split_step_advance_permitted?
74
+ @split_step_advance_permit = false
75
+ return
76
+ end
77
+ end
78
+
79
+ raise WorkflowError, "use execute_prepared_step! for the active split-step boundary"
80
+ end
81
+
82
+ def claim_split_step_advance!
83
+ @split_step_mutex.synchronize do
84
+ unless @split_step_phase
85
+ @split_step_phase = :ordinary_execution
86
+ return :ordinary
87
+ end
88
+ if split_step_advance_permitted?
89
+ @split_step_advance_permit = false
90
+ return :split_step
91
+ end
92
+
93
+ raise WorkflowError, "use execute_prepared_step! for the active split-step boundary"
94
+ end
95
+ end
96
+
97
+ def release_split_step_advance!(claim)
98
+ return unless claim == :ordinary
99
+
100
+ @split_step_mutex.synchronize do
101
+ @split_step_phase = nil if @split_step_phase == :ordinary_execution
102
+ end
103
+ end
104
+
105
+ def ensure_no_split_step_boundary!
106
+ return unless @split_step_phase
107
+
108
+ raise WorkflowError, "a split-step persistence boundary is already active"
109
+ end
110
+
111
+ def guard_split_step_subclass_execution!
112
+ @split_step_mutex.synchronize do
113
+ return unless @split_step_phase
114
+ return if split_step_advance_permitted?
115
+ end
116
+
117
+ raise WorkflowError, "use execute_prepared_step! for the active split-step boundary"
118
+ end
119
+
120
+ def split_step_advance_permitted?
121
+ @split_step_phase == :executing &&
122
+ @split_step_execution_thread.equal?(Thread.current) &&
123
+ @split_step_advance_permit
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module BoundaryReset
7
+ private
8
+
9
+ def clear_split_step_boundary!
10
+ @split_step_phase = nil
11
+ @split_step_transition_name = nil
12
+ @split_step_transition = nil
13
+ @split_step_transition_signature = nil
14
+ @split_step_origin_state = nil
15
+ @split_step_token = nil
16
+ @split_step_active_execution_authorization = nil
17
+ @split_step_execution_result = nil
18
+ clear_split_step_descriptor_state!
19
+ @split_step_persistence_key = nil
20
+ @split_step_adapter = nil
21
+ remove_instance_variable(:@split_step_persistence_ttl) if
22
+ instance_variable_defined?(:@split_step_persistence_ttl)
23
+ @split_step_preparation_payload = nil
24
+ @split_step_checkpoint_digest = nil
25
+ @split_step_checkpoint_version = nil
26
+ @split_step_preparation_thread = nil
27
+ @split_step_persist_permit = false
28
+ end
29
+
30
+ def clear_split_step_descriptor_state!
31
+ @split_step_prepared_descriptor = nil
32
+ @split_step_transaction_identity = nil
33
+ @split_step_preparation_digest = nil
34
+ @split_step_pending_descriptor = nil
35
+ @split_step_dispatch_started = false
36
+ @split_step_dispatch_token = nil
37
+ @split_step_dispatch_descriptor = nil
38
+ @split_step_attempted_dispatch_descriptor = nil
39
+ @split_step_dispatch_thread = nil
40
+ @split_step_pre_dispatch_payload = nil
41
+ @split_step_attempted_dispatch_payload = nil
42
+ clear_split_step_execution_verification!
43
+ clear_split_step_execution_authorization!
44
+ remove_instance_variable(:@split_step_definition_digest) if
45
+ instance_variable_defined?(:@split_step_definition_digest)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "dry-initializer"
5
+ require "json"
6
+
7
+ module Smith
8
+ class Workflow
9
+ module SplitStepPersistence
10
+ class CanonicalPayloadDigest
11
+ extend Dry::Initializer
12
+
13
+ MAX_BYTES = 4 * 1024 * 1024
14
+ MAX_NODES = 100_000
15
+
16
+ param :payload
17
+
18
+ def self.call(payload) = new(payload).call
19
+
20
+ def call
21
+ if payload.bytesize > MAX_BYTES
22
+ raise WorkflowError, "split-step preparation payload exceeds maximum bytes #{MAX_BYTES}"
23
+ end
24
+
25
+ @nodes = 0
26
+ canonical = canonicalize(JSON.parse(payload))
27
+ Digest::SHA256.hexdigest(JSON.generate(canonical))
28
+ end
29
+
30
+ private
31
+
32
+ def canonicalize(value)
33
+ visit!
34
+ case value
35
+ when Hash then canonical_hash(value)
36
+ when Array then value.map { |item| canonicalize(item) }
37
+ when Float then value == value.to_i ? value.to_i : value
38
+ else value
39
+ end
40
+ end
41
+
42
+ def canonical_hash(value)
43
+ value.keys.sort.to_h { |key| [key, canonicalize(value.fetch(key))] }
44
+ end
45
+
46
+ def visit!
47
+ @nodes += 1
48
+ return if @nodes <= MAX_NODES
49
+
50
+ raise WorkflowError, "split-step preparation payload exceeds maximum size #{MAX_NODES}"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module Checkpoint
7
+ def complete_persisted_step!
8
+ completion_claimed = false
9
+ previous_phase = claim_split_step_completion!
10
+ completion_claimed = true
11
+ completed = false
12
+ payload = @split_step_adapter.fetch(@split_step_persistence_key)
13
+ unless persisted_split_step_checkpoint?(payload)
14
+ recover_rolled_back_split_step_checkpoint!(payload)
15
+ raise WorkflowError, "the persisted split-step checkpoint is not committed"
16
+ end
17
+
18
+ @split_step_mutex.synchronize do
19
+ @persistence_version = @split_step_checkpoint_version
20
+ clear_step_in_progress!
21
+ clear_split_step_boundary!
22
+ end
23
+ completed = true
24
+ self
25
+ ensure
26
+ restore_incomplete_checkpoint!(previous_phase) if completion_claimed && !completed
27
+ end
28
+
29
+ def persist!(key = nil, adapter: Smith.persistence_adapter)
30
+ checkpoint_claimed = false
31
+ resolved_key, store = resolve_split_step_target(key, adapter)
32
+ checkpoint_claimed = claim_split_step_checkpoint!
33
+ result = super(resolved_key, adapter: store) do |payload, next_version|
34
+ capture_split_step_payload!(payload, next_version, checkpoint_claimed:)
35
+ end
36
+ @split_step_mutex.synchronize { @split_step_phase = :checkpointed } if checkpoint_claimed
37
+ result
38
+ rescue StandardError
39
+ mark_split_step_checkpoint_unknown! if checkpoint_claimed
40
+ raise
41
+ end
42
+
43
+ private
44
+
45
+ def resolve_split_step_target(key, adapter)
46
+ resolved_key = if @split_step_persistence_key
47
+ candidate = candidate_split_step_persistence_key(key)
48
+ if candidate.equal?(@split_step_persistence_key)
49
+ candidate
50
+ else
51
+ normalize_split_step_persistence_key(candidate)
52
+ end
53
+ else
54
+ resolve_persistence_key!(key)
55
+ end
56
+ store = persistence_adapter!(adapter)
57
+ validate_split_step_target!(resolved_key, store)
58
+ resolved_key = @split_step_persistence_key if @split_step_persistence_key
59
+ @persistence_key = resolved_key
60
+ [resolved_key, store]
61
+ end
62
+
63
+ def claim_split_step_checkpoint!
64
+ @split_step_mutex.synchronize do
65
+ if split_step_preparation_persist_permitted?
66
+ @split_step_persist_permit = false
67
+ return false
68
+ end
69
+ return false unless @split_step_phase
70
+
71
+ if %i[executed checkpoint_retryable].include?(@split_step_phase)
72
+ @split_step_phase = :checkpointing
73
+ return true
74
+ end
75
+
76
+ raise WorkflowError, "the active split-step boundary cannot be checkpointed"
77
+ end
78
+ end
79
+
80
+ def split_step_preparation_persist_permitted?
81
+ @split_step_phase == :preparing &&
82
+ @split_step_preparation_thread.equal?(Thread.current) &&
83
+ @split_step_persist_permit
84
+ end
85
+
86
+ def validate_split_step_target!(key, adapter)
87
+ return unless @split_step_persistence_key
88
+ return if key == @split_step_persistence_key && adapter.equal?(@split_step_adapter)
89
+
90
+ raise WorkflowError, "the split-step persistence target cannot change"
91
+ end
92
+
93
+ def capture_split_step_payload!(payload, next_version, checkpoint_claimed:)
94
+ return capture_split_step_preparation_payload!(payload, next_version) if @split_step_phase == :preparing
95
+
96
+ validate_split_step_marker!(payload, expected: true) if checkpoint_claimed
97
+ return payload unless checkpoint_claimed
98
+
99
+ checkpoint_payload = split_step_checkpoint_payload(payload)
100
+ checkpoint_digest = Digest::SHA256.hexdigest(checkpoint_payload)
101
+ validate_split_step_retry_payload!(checkpoint_digest)
102
+ @split_step_checkpoint_digest = checkpoint_digest
103
+ @split_step_checkpoint_version = next_version
104
+ checkpoint_payload
105
+ end
106
+
107
+ def validate_split_step_retry_payload!(checkpoint_digest)
108
+ return unless @split_step_checkpoint_digest
109
+ return if @split_step_checkpoint_digest == checkpoint_digest
110
+
111
+ @split_step_mutex.synchronize do
112
+ @split_step_phase = :checkpoint_retryable if @split_step_phase == :checkpointing
113
+ end
114
+ raise WorkflowError, "the reconciled split-step checkpoint payload changed"
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module CheckpointState
7
+ private
8
+
9
+ def claim_split_step_completion!
10
+ @split_step_mutex.synchronize do
11
+ unless %i[checkpoint_unknown checkpointed].include?(@split_step_phase)
12
+ raise WorkflowError, "no persisted split-step checkpoint is awaiting completion"
13
+ end
14
+ if Smith::PersistenceAdapters.supports?(@split_step_adapter, :transaction_open?) &&
15
+ @split_step_adapter.transaction_open?
16
+ raise WorkflowError, "the split-step checkpoint transaction is still open"
17
+ end
18
+
19
+ previous_phase = @split_step_phase
20
+ @split_step_phase = :confirming_checkpoint
21
+ previous_phase
22
+ end
23
+ end
24
+
25
+ def restore_incomplete_checkpoint!(previous_phase)
26
+ @split_step_mutex.synchronize do
27
+ @split_step_phase = previous_phase if @split_step_phase == :confirming_checkpoint
28
+ end
29
+ end
30
+
31
+ def recover_rolled_back_split_step_checkpoint!(payload)
32
+ return unless persisted_split_step_payload?(payload, @split_step_preparation_payload)
33
+
34
+ preparation_version = JSON.parse(payload).fetch("persistence_version")
35
+ @split_step_mutex.synchronize do
36
+ return unless @split_step_phase == :confirming_checkpoint
37
+
38
+ @persistence_version = preparation_version
39
+ @split_step_phase = :checkpoint_retryable
40
+ end
41
+ end
42
+
43
+ def mark_split_step_checkpoint_unknown!
44
+ @split_step_mutex.synchronize do
45
+ @split_step_phase = :checkpoint_unknown if @split_step_phase == :checkpointing
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module DefinitionBoundary
7
+ private
8
+
9
+ def restart_safe_split_step? = !effective_definition_digest.nil?
10
+
11
+ def ensure_split_step_definition_current!
12
+ return if effective_definition_digest == self.class.definition_digest
13
+
14
+ raise WorkflowError, "the prepared workflow definition has changed"
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module DispatchBoundary
7
+ private
8
+
9
+ def dispatch_store!(...)
10
+ @split_step_dispatch_started = true if @split_step_phase == :preparing
11
+ super
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module DispatchClaim
7
+ def claim_prepared_step_dispatch!
8
+ ensure_restart_safe_dispatch_claim!
9
+ ensure_split_step_definition_current!
10
+ transaction_identity = TransactionIdentity.capture(@split_step_adapter)
11
+ claim_dispatch_intent!
12
+ payload, descriptor = build_split_step_dispatch_claim
13
+ dispatch_started = true
14
+ replace_exact_dispatch!(payload)
15
+ accept_split_step_dispatch!(payload, descriptor, transaction_identity)
16
+ descriptor
17
+ rescue PersistencePayloadConflict
18
+ reject_split_step_dispatch!
19
+ raise
20
+ rescue StandardError
21
+ if dispatch_started
22
+ mark_split_step_dispatch_unknown!(payload, descriptor)
23
+ else
24
+ restore_unclaimed_dispatch!
25
+ end
26
+ raise
27
+ end
28
+
29
+ private
30
+
31
+ def ensure_restart_safe_dispatch_claim!
32
+ return if restart_safe_split_step?
33
+
34
+ raise WorkflowError, "restart-safe dispatch claiming requires a definition_digest"
35
+ end
36
+
37
+ def split_step_dispatch_payload(token)
38
+ document = JSON.parse(@split_step_preparation_payload)
39
+ document["split_step_phase"] = "dispatching"
40
+ document["split_step_dispatch_token"] = token
41
+ JSON.generate(document)
42
+ end
43
+
44
+ def build_split_step_dispatch_claim
45
+ token = SecureRandom.uuid.freeze
46
+ payload = split_step_dispatch_payload(token)
47
+ [payload, build_split_step_dispatch(token, payload)]
48
+ end
49
+
50
+ def replace_exact_dispatch!(payload)
51
+ @split_step_adapter.replace_exact(
52
+ @split_step_persistence_key,
53
+ payload,
54
+ expected_payload: @split_step_preparation_payload,
55
+ ttl: nil
56
+ )
57
+ end
58
+
59
+ def build_split_step_dispatch(token, payload)
60
+ PreparedStepDispatch.new(
61
+ prepared_step: @split_step_prepared_descriptor,
62
+ token:,
63
+ dispatch_digest: CanonicalPayloadDigest.call(payload)
64
+ )
65
+ end
66
+
67
+ def accept_split_step_dispatch!(payload, descriptor, transaction_identity)
68
+ @split_step_mutex.synchronize do
69
+ unless active_split_step_dispatch_claim?
70
+ raise WorkflowError, "the prepared dispatch claim is no longer active"
71
+ end
72
+
73
+ @split_step_pre_dispatch_payload = @split_step_preparation_payload
74
+ @split_step_preparation_payload = payload.freeze
75
+ @split_step_dispatch_token = descriptor.token
76
+ @split_step_dispatch_descriptor = descriptor
77
+ @split_step_dispatch_thread = nil
78
+ @split_step_phase = transaction_identity ? :dispatch_claimed_uncommitted : :dispatch_claimed
79
+ end
80
+ end
81
+
82
+ def reject_split_step_dispatch!
83
+ @split_step_mutex.synchronize do
84
+ next unless active_split_step_dispatch_claim?
85
+
86
+ @split_step_dispatch_thread = nil
87
+ @split_step_phase = :dispatch_rejected
88
+ end
89
+ end
90
+
91
+ def restore_unclaimed_dispatch!
92
+ @split_step_mutex.synchronize do
93
+ next unless active_split_step_dispatch_claim?
94
+
95
+ @split_step_dispatch_thread = nil
96
+ @split_step_phase = :prepared
97
+ end
98
+ end
99
+
100
+ def mark_split_step_dispatch_unknown!(payload, descriptor)
101
+ @split_step_mutex.synchronize do
102
+ next unless active_split_step_dispatch_claim?
103
+
104
+ @split_step_attempted_dispatch_payload = payload&.freeze
105
+ @split_step_dispatch_token = descriptor&.token
106
+ @split_step_attempted_dispatch_descriptor = descriptor
107
+ @split_step_dispatch_thread = nil
108
+ @split_step_phase = :dispatch_unknown
109
+ end
110
+ end
111
+
112
+ def active_split_step_dispatch_claim?
113
+ @split_step_phase == :claiming_dispatch && @split_step_dispatch_thread.equal?(Thread.current)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module DispatchConfirmation
7
+ def confirm_prepared_step_dispatch!
8
+ confirmation_claimed = false
9
+ rolled_back = false
10
+ claim_dispatch_confirmation!
11
+ confirmation_claimed = true
12
+ payload = @split_step_adapter.fetch(@split_step_persistence_key)
13
+ return confirm_committed_dispatch! if persisted_split_step_payload?(payload, @split_step_preparation_payload)
14
+
15
+ rolled_back = recover_rolled_back_dispatch!(payload)
16
+ raise WorkflowError, "the persisted split-step dispatch claim is not committed"
17
+ rescue StandardError
18
+ mark_unconfirmed_dispatch_unknown! if confirmation_claimed && !rolled_back
19
+ raise
20
+ end
21
+
22
+ private
23
+
24
+ def claim_dispatch_confirmation!
25
+ @split_step_mutex.synchronize do
26
+ unless @split_step_phase == :dispatch_claimed_uncommitted
27
+ raise WorkflowError, "no uncommitted split-step dispatch claim is awaiting confirmation"
28
+ end
29
+ if @split_step_adapter.transaction_open?
30
+ raise WorkflowError, "the split-step dispatch transaction is still open"
31
+ end
32
+
33
+ @split_step_phase = :confirming_dispatch
34
+ end
35
+ end
36
+
37
+ def confirm_committed_dispatch!
38
+ @split_step_mutex.synchronize do
39
+ @split_step_phase = :dispatch_claimed
40
+ clear_split_step_dispatch_transaction!
41
+ end
42
+ self
43
+ end
44
+
45
+ def recover_rolled_back_dispatch!(payload)
46
+ return false unless persisted_split_step_payload?(payload, @split_step_pre_dispatch_payload)
47
+
48
+ @split_step_mutex.synchronize do
49
+ return false unless @split_step_phase == :confirming_dispatch
50
+
51
+ @split_step_preparation_payload = @split_step_pre_dispatch_payload
52
+ @split_step_phase = :prepared
53
+ clear_split_step_dispatch_transaction!
54
+ true
55
+ end
56
+ end
57
+
58
+ def mark_unconfirmed_dispatch_unknown!
59
+ @split_step_mutex.synchronize do
60
+ @split_step_phase = :dispatch_unknown if @split_step_phase == :confirming_dispatch
61
+ end
62
+ end
63
+
64
+ def clear_split_step_dispatch_transaction!
65
+ @split_step_pre_dispatch_payload = nil
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module DispatchVerification
7
+ private
8
+
9
+ def claim_dispatch_intent!
10
+ @split_step_mutex.synchronize do
11
+ raise WorkflowError, "no persisted step is prepared" unless @split_step_phase == :prepared
12
+
13
+ ensure_prepared_split_step_transition_matches!
14
+ @split_step_phase = :claiming_dispatch
15
+ @split_step_dispatch_thread = Thread.current
16
+ end
17
+ end
18
+
19
+ def verify_split_step_dispatch_available!(verification_token)
20
+ payload = @split_step_adapter.fetch(@split_step_persistence_key)
21
+ return if persisted_split_step_payload?(payload, @split_step_preparation_payload)
22
+
23
+ @split_step_mutex.synchronize do
24
+ if active_split_step_execution_verification?(verification_token)
25
+ @split_step_phase = :dispatch_unknown
26
+ @split_step_execution_previous_phase = nil
27
+ clear_split_step_execution_verification!
28
+ end
29
+ end
30
+ raise PersistencePayloadConflict.new(key: @split_step_persistence_key)
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end