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 Execution
7
+ def execute_prepared_step!
8
+ authorization = ExecutionAuthorization
9
+ .instance_method(:authorize_prepared_step_execution!)
10
+ .bind_call(self)
11
+ result = Execution.instance_method(:execute_authorized_prepared_step!).bind_call(self, authorization)
12
+ result.step_snapshot
13
+ end
14
+
15
+ def execute_authorized_prepared_step!(authorization)
16
+ authorization = validate_split_step_execution_authorization!(authorization)
17
+ step = nil
18
+ result = nil
19
+ execution_thread = Thread.current
20
+ execution_active = false
21
+ begin
22
+ activate_split_step_execution!(authorization, execution_thread)
23
+ execution_active = true
24
+ step = execute_claimed_split_step_transition!
25
+ result = consume_split_step_execution_result!(execution_thread)
26
+ ensure
27
+ finish_split_step_execution!(step, authorization, execution_thread) if execution_active
28
+ end
29
+ result
30
+ end
31
+
32
+ def prepared_persisted_step?
33
+ @split_step_mutex.synchronize do
34
+ expected = restart_safe_split_step? ? :dispatch_claimed : :prepared
35
+ [expected, :execution_authorized].include?(@split_step_phase)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def activate_split_step_execution!(authorization, execution_thread)
42
+ @split_step_mutex.synchronize do
43
+ unless active_split_step_execution_authorization?(authorization)
44
+ raise WorkflowError, "the prepared-step execution authorization is no longer active"
45
+ end
46
+
47
+ ensure_split_step_definition_current!
48
+ ensure_prepared_split_step_transition_matches!
49
+ PreparedStepExecutionAuthorization.instance_method(:activate_execution!)
50
+ .bind_call(authorization, execution_thread)
51
+
52
+ @split_step_active_execution_authorization = authorization
53
+ @split_step_execution_result = nil
54
+ @split_step_phase = :executing
55
+ clear_split_step_execution_authorization!
56
+ @split_step_execution_thread = execution_thread
57
+ @split_step_advance_permit = true
58
+ end
59
+ end
60
+
61
+ def restore_unverified_execution!(verification_token)
62
+ @split_step_mutex.synchronize do
63
+ next unless active_split_step_execution_verification?(verification_token)
64
+
65
+ @split_step_phase = @split_step_execution_previous_phase
66
+ @split_step_execution_previous_phase = nil
67
+ clear_split_step_execution_verification!
68
+ end
69
+ end
70
+
71
+ def execute_claimed_split_step_transition!
72
+ step = Workflow.instance_method(:advance!).bind_call(self)
73
+ return step if accepted_split_step_result?(step)
74
+
75
+ raise WorkflowError, "prepared execution did not return the claimed transition"
76
+ end
77
+
78
+ def finish_split_step_execution!(step, authorization, execution_thread)
79
+ @split_step_mutex.synchronize do
80
+ next unless @split_step_phase == :executing &&
81
+ @split_step_execution_thread.equal?(execution_thread)
82
+
83
+ @split_step_execution_thread = nil
84
+ @split_step_advance_permit = false
85
+ @split_step_execution_previous_phase = nil
86
+ @split_step_active_execution_authorization = nil
87
+ @split_step_execution_result = nil
88
+ @split_step_phase = step ? :executed : :attempted
89
+ end
90
+ ensure
91
+ PreparedStepExecutionAuthorization.instance_method(:close_execution!)
92
+ .bind_call(authorization, execution_thread)
93
+ end
94
+
95
+ def resolve_split_step_advance_transition
96
+ @split_step_mutex.synchronize do
97
+ unless @split_step_phase == :executing && @split_step_execution_thread.equal?(Thread.current)
98
+ return NO_SPLIT_TRANSITION
99
+ end
100
+
101
+ name = @next_transition_name
102
+ @next_transition_name = nil if name
103
+ raise UnresolvedTransitionError.new(name, self.class, @state) if name && @split_step_transition.nil?
104
+
105
+ @split_step_transition
106
+ end
107
+ end
108
+
109
+ def accepted_split_step_result?(step)
110
+ return false unless step.is_a?(Hash)
111
+ return false unless step[:transition] == @split_step_transition_name
112
+ return false unless step[:from] == @split_step_origin_state
113
+
114
+ step[:error] ? accepted_split_step_failure? : step.key?(:to) && @state == step[:to]
115
+ end
116
+
117
+ def accepted_split_step_failure?
118
+ failure_name = @split_step_transition&.failure_transition || :fail
119
+ failure_transition = self.class.find_transition(failure_name)
120
+ return false unless failure_transition
121
+
122
+ @state == failure_transition.to ||
123
+ (@state == @split_step_origin_state && @next_transition_name == failure_name)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module ExecutionAuthorization
7
+ def authorize_prepared_step_execution!
8
+ verification_token = claim_split_step_execution_verification!
9
+ authorized = false
10
+ verify_claimed_split_step_execution!(verification_token)
11
+ authorization = build_split_step_execution_authorization
12
+ activate_split_step_execution_authorization!(authorization, verification_token)
13
+ authorized = true
14
+ authorization
15
+ ensure
16
+ restore_unverified_execution!(verification_token) if verification_token && !authorized
17
+ end
18
+
19
+ def release_prepared_step_execution!(authorization)
20
+ authorization = validate_split_step_execution_authorization!(authorization)
21
+ @split_step_mutex.synchronize do
22
+ unless active_split_step_execution_authorization?(authorization)
23
+ raise WorkflowError, "the prepared-step execution authorization is no longer active"
24
+ end
25
+
26
+ @split_step_phase = @split_step_execution_previous_phase
27
+ clear_split_step_execution_authorization!
28
+ end
29
+ PreparedStepExecutionAuthorization
30
+ .instance_method(:close_execution!)
31
+ .bind_call(authorization)
32
+ self
33
+ end
34
+
35
+ private
36
+
37
+ def build_split_step_execution_authorization
38
+ PreparedStepExecutionAuthorization.new(
39
+ prepared_step: @split_step_prepared_descriptor,
40
+ dispatch_claim: @split_step_dispatch_descriptor,
41
+ execution_bindings: ExecutionBindingSnapshot.capture(
42
+ @split_step_transition,
43
+ workflow_class: self.class
44
+ )
45
+ )
46
+ end
47
+
48
+ def activate_split_step_execution_authorization!(authorization, verification_token)
49
+ @split_step_mutex.synchronize do
50
+ unless active_split_step_execution_verification?(verification_token)
51
+ raise WorkflowError, "the prepared execution verification is no longer active"
52
+ end
53
+
54
+ @split_step_execution_authorization = authorization
55
+ clear_split_step_execution_verification!
56
+ @split_step_phase = :execution_authorized
57
+ end
58
+ end
59
+
60
+ def validate_split_step_execution_authorization!(authorization)
61
+ return authorization if authorization.is_a?(PreparedStepExecutionAuthorization)
62
+
63
+ raise ArgumentError,
64
+ "authorization must be a Smith::Workflow::PreparedStepExecutionAuthorization"
65
+ end
66
+
67
+ def active_split_step_execution_authorization?(authorization)
68
+ @split_step_phase == :execution_authorized &&
69
+ @split_step_execution_authorization.equal?(authorization) &&
70
+ authorization.issued_in_current_process?
71
+ end
72
+
73
+ def active_split_step_execution_verification?(verification_token)
74
+ @split_step_phase == :verifying_execution &&
75
+ @split_step_execution_verification_token.equal?(verification_token)
76
+ end
77
+
78
+ def clear_split_step_execution_verification!
79
+ @split_step_execution_verification_token = nil
80
+ end
81
+
82
+ def clear_split_step_execution_authorization!
83
+ @split_step_execution_authorization = nil
84
+ @split_step_execution_previous_phase = nil
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../agent"
4
+ require_relative "../../agent/registry"
5
+
6
+ module Smith
7
+ class Workflow
8
+ module SplitStepPersistence
9
+ class ExecutionBindingCollector
10
+ MAX_BINDINGS = 10_000
11
+
12
+ def initialize
13
+ @requests = {}
14
+ @bindings = {}
15
+ end
16
+
17
+ def capture(transition, workflow_class:)
18
+ capture_binding(transition.agent_name, workflow_class:, transition:, role: :agent)
19
+ capture_fanout(transition, workflow_class:)
20
+ capture_optimization(transition, workflow_class:)
21
+ capture_orchestration(transition, workflow_class:)
22
+ end
23
+
24
+ def fetch!(name, workflow_class:, transition_name:, role:)
25
+ @bindings.fetch(name.to_s) do
26
+ raise WorkflowError,
27
+ "execution authorization does not contain #{role} :#{name} " \
28
+ "for workflow #{workflow_class}, transition :#{transition_name}"
29
+ end
30
+ end
31
+
32
+ def resolve!
33
+ @bindings = Agent::Registry.capture_bindings!(@requests.values)
34
+
35
+ @requests.freeze
36
+ self
37
+ end
38
+
39
+ def each(&)
40
+ @bindings.each(&)
41
+ end
42
+
43
+ def freeze
44
+ @requests.freeze
45
+ @bindings.freeze
46
+ super
47
+ end
48
+
49
+ private
50
+
51
+ def capture_fanout(transition, workflow_class:)
52
+ transition.fanout_config&.fetch(:branches, nil)&.each_value do |agent|
53
+ capture_binding(agent, workflow_class:, transition:, role: :fanout_agent)
54
+ end
55
+ end
56
+
57
+ def capture_optimization(transition, workflow_class:)
58
+ return unless (config = transition.optimization_config)
59
+
60
+ capture_binding(config.fetch(:generator), workflow_class:, transition:, role: :generator)
61
+ capture_binding(config.fetch(:evaluator), workflow_class:, transition:, role: :evaluator)
62
+ end
63
+
64
+ def capture_orchestration(transition, workflow_class:)
65
+ return unless (config = transition.orchestrator_config)
66
+
67
+ capture_binding(config.fetch(:orchestrator), workflow_class:, transition:, role: :orchestrator)
68
+ capture_binding(config.fetch(:worker), workflow_class:, transition:, role: :worker)
69
+ end
70
+
71
+ def capture_binding(name, workflow_class:, transition:, role:)
72
+ return unless name
73
+
74
+ key = name.to_s
75
+ return if @requests.key?(key)
76
+ if @requests.length >= MAX_BINDINGS
77
+ raise WorkflowError, "execution authorization exceeds maximum agent bindings #{MAX_BINDINGS}"
78
+ end
79
+
80
+ @requests[key] = {
81
+ name:,
82
+ workflow_class:,
83
+ transition_name: transition.name,
84
+ role:
85
+ }.freeze
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "execution_binding_collector"
4
+ require_relative "execution_workflow_snapshot"
5
+
6
+ module Smith
7
+ class Workflow
8
+ module SplitStepPersistence
9
+ class ExecutionBindingSnapshot
10
+ MAX_NESTED_WORKFLOWS = 128
11
+ MAX_TRANSITIONS = 10_000
12
+
13
+ def self.capture(transition, workflow_class:)
14
+ new(transition, workflow_class:).capture
15
+ end
16
+
17
+ def initialize(transition, workflow_class:)
18
+ @transition = transition
19
+ @workflow_class = workflow_class
20
+ @bindings = ExecutionBindingCollector.new
21
+ @visited_workflows = {}.compare_by_identity
22
+ @visited_workflows[workflow_class] = true
23
+ @workflow_snapshots = {}.compare_by_identity
24
+ @workflow_queue = []
25
+ @workflow_queue_index = 0
26
+ @transition_count = 0
27
+ end
28
+
29
+ def capture
30
+ capture_transition(@transition, workflow_class: @workflow_class)
31
+ drain_workflow_queue
32
+ @bindings.resolve!
33
+ @bindings.freeze
34
+ @workflow_snapshots.freeze
35
+ freeze
36
+ end
37
+
38
+ def fetch!(name, workflow_class:, transition_name:, role:)
39
+ @bindings.fetch!(name, workflow_class:, transition_name:, role:)
40
+ end
41
+
42
+ def each_agent_binding(&)
43
+ @bindings.each(&)
44
+ end
45
+
46
+ def verify_workflow!(workflow_class)
47
+ snapshot = @workflow_snapshots.fetch(workflow_class) do
48
+ raise WorkflowError, "execution authorization does not contain nested workflow #{workflow_class}"
49
+ end
50
+ snapshot.verify!
51
+ end
52
+
53
+ private
54
+
55
+ def capture_transition(transition, workflow_class:)
56
+ return unless transition
57
+
58
+ visit_transition!
59
+ @bindings.capture(transition, workflow_class:)
60
+ enqueue_nested_workflow(transition.workflow_class)
61
+ end
62
+
63
+ def enqueue_nested_workflow(workflow_class)
64
+ return unless workflow_class
65
+ return if @visited_workflows.key?(workflow_class)
66
+
67
+ @visited_workflows[workflow_class] = true
68
+ if @visited_workflows.length > MAX_NESTED_WORKFLOWS
69
+ raise WorkflowError,
70
+ "execution authorization exceeds maximum nested workflows #{MAX_NESTED_WORKFLOWS}"
71
+ end
72
+
73
+ @workflow_queue << workflow_class
74
+ end
75
+
76
+ def drain_workflow_queue
77
+ while @workflow_queue_index < @workflow_queue.length
78
+ workflow_class = @workflow_queue.fetch(@workflow_queue_index)
79
+ @workflow_queue_index += 1
80
+ @workflow_snapshots[workflow_class] = ExecutionWorkflowSnapshot.capture(workflow_class) do |transition|
81
+ capture_transition(transition, workflow_class:)
82
+ end
83
+ end
84
+ end
85
+
86
+ def visit_transition!
87
+ @transition_count += 1
88
+ return if @transition_count <= MAX_TRANSITIONS
89
+
90
+ raise WorkflowError, "execution authorization exceeds maximum transitions #{MAX_TRANSITIONS}"
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module ExecutionResultCapture
7
+ private
8
+
9
+ def capture_split_step_execution_result!(step)
10
+ result = prepare_split_step_execution_result(step)
11
+ commit_split_step_execution_result!(result)
12
+ end
13
+
14
+ def prepare_split_step_execution_result(step)
15
+ return unless active_split_step_execution_thread?
16
+
17
+ PreparedStepExecutionResult.from_step(step)
18
+ end
19
+
20
+ def commit_split_step_execution_result!(result)
21
+ return unless result
22
+
23
+ @split_step_mutex.synchronize do
24
+ return unless active_split_step_execution_thread?
25
+ raise WorkflowError, "prepared-step execution produced more than one result" if @split_step_execution_result
26
+
27
+ @split_step_execution_result = result
28
+ end
29
+ end
30
+
31
+ def consume_split_step_execution_result!(execution_thread)
32
+ @split_step_mutex.synchronize do
33
+ unless @split_step_phase == :executing && @split_step_execution_thread.equal?(execution_thread)
34
+ raise WorkflowError, "prepared-step execution is no longer active"
35
+ end
36
+
37
+ @split_step_execution_result ||
38
+ raise(WorkflowError, "prepared-step execution did not produce a result")
39
+ end
40
+ end
41
+
42
+ def active_split_step_execution_thread?
43
+ @split_step_phase == :executing && @split_step_execution_thread.equal?(Thread.current)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module ExecutionVerification
7
+ private
8
+
9
+ def claim_split_step_execution_verification!
10
+ @split_step_mutex.synchronize do
11
+ ensure_split_step_definition_current!
12
+ expected = restart_safe_split_step? ? :dispatch_claimed : :prepared
13
+ raise WorkflowError, "no persisted step is prepared for execution" unless @split_step_phase == expected
14
+
15
+ ensure_prepared_split_step_transition_matches!
16
+
17
+ token = Object.new.freeze
18
+ @split_step_execution_previous_phase = expected
19
+ @split_step_execution_verification_token = token
20
+ @split_step_phase = :verifying_execution
21
+ token
22
+ end
23
+ end
24
+
25
+ def verify_claimed_split_step_execution!(verification_token)
26
+ if restart_safe_split_step?
27
+ verify_split_step_dispatch_available!(verification_token)
28
+ else
29
+ verify_split_step_preparation_available!
30
+ end
31
+ end
32
+
33
+ def prepared_split_step_transition_matches?
34
+ transition = pending_split_step_transition
35
+ transition_name = @next_transition_name || transition&.name
36
+ transition_name == @split_step_transition_name &&
37
+ transition.equal?(@split_step_transition) &&
38
+ split_step_transition_signature(transition) == @split_step_transition_signature
39
+ end
40
+
41
+ def ensure_prepared_split_step_transition_matches!
42
+ return if prepared_split_step_transition_matches?
43
+
44
+ raise WorkflowError, "the prepared transition no longer matches the workflow"
45
+ end
46
+
47
+ def verify_split_step_preparation_available!
48
+ payload = @split_step_adapter.fetch(@split_step_persistence_key)
49
+ durable = persisted_split_step_payload?(payload, @split_step_preparation_payload)
50
+ live = persisted_split_step_payload?(current_split_step_preparation_payload, @split_step_preparation_payload)
51
+ stable = split_step_transition_signature(@split_step_transition) == @split_step_transition_signature
52
+ return if durable && live && stable
53
+
54
+ raise WorkflowError, "the persisted split-step preparation is no longer available"
55
+ end
56
+
57
+ def current_split_step_preparation_payload
58
+ split_step_preparation_payload(
59
+ JSON.generate(to_state.merge(persistence_version: @persistence_version))
60
+ )
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-initializer"
4
+
5
+ require_relative "../../errors"
6
+ require_relative "../graph"
7
+ require_relative "transition_contract"
8
+
9
+ module Smith
10
+ class Workflow
11
+ module SplitStepPersistence
12
+ class ExecutionWorkflowSnapshot
13
+ extend Dry::Initializer
14
+
15
+ param :workflow_class
16
+
17
+ def self.capture(workflow_class, &)
18
+ new(workflow_class).capture(&)
19
+ end
20
+
21
+ def capture
22
+ @initial_state_signature = initial_state_signature
23
+ @transitions = reachable_definition_transitions.map do |transition|
24
+ yield transition if block_given?
25
+ [transition.name, transition, TransitionContract.capture(transition)].freeze
26
+ end.freeze
27
+ freeze
28
+ end
29
+
30
+ def verify!
31
+ unless initial_state_signature == @initial_state_signature
32
+ raise WorkflowError, "authorized nested workflow definition changed before execution"
33
+ end
34
+
35
+ index = 0
36
+ reachable_definition_transitions.each do |transition|
37
+ expected = @transitions[index]
38
+ verify_transition!(transition, expected)
39
+ index += 1
40
+ end
41
+ return true if index == @transitions.length
42
+
43
+ raise WorkflowError, "authorized nested workflow definition changed before execution"
44
+ end
45
+
46
+ private
47
+
48
+ def reachable_definition_transitions
49
+ workflow_class.graph.reachable_transitions.map do |transition|
50
+ workflow_class.transition_at(transition.definition_index)
51
+ end
52
+ rescue IndexError
53
+ raise WorkflowError, "authorized nested workflow definition changed before execution"
54
+ end
55
+
56
+ def initial_state_signature
57
+ TransitionContractSignature.new(
58
+ value: workflow_class.initial_state,
59
+ max_depth: TransitionContract::MAX_DEPTH,
60
+ max_nodes: TransitionContract::MAX_NODES,
61
+ max_bytes: TransitionContract::MAX_BYTES
62
+ ).call
63
+ end
64
+
65
+ def verify_transition!(transition, expected)
66
+ matches = expected &&
67
+ transition.name == expected.fetch(0) &&
68
+ transition.equal?(expected.fetch(1)) &&
69
+ TransitionContract.signature(transition) == expected.fetch(2)
70
+ return if matches
71
+
72
+ raise WorkflowError, "authorized nested workflow definition changed before execution"
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module Inheritance
7
+ def inherited(subclass)
8
+ super
9
+ subclass.prepend(SubclassBoundary)
10
+ end
11
+
12
+ def prepend(*features)
13
+ result = super
14
+ guard = SubclassBoundary.guard
15
+ guard.freeze
16
+ Module.instance_method(:prepend).bind_call(self, guard)
17
+ result
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Workflow
5
+ module SplitStepPersistence
6
+ module Payloads
7
+ private
8
+
9
+ def persisted_split_step_payload?(payload, expected)
10
+ return false unless payload.is_a?(String) && expected.is_a?(String)
11
+
12
+ CanonicalPayloadDigest.call(payload) == CanonicalPayloadDigest.call(expected)
13
+ rescue WorkflowError, JSON::ParserError, TypeError
14
+ false
15
+ end
16
+
17
+ def persisted_split_step_checkpoint?(payload)
18
+ return false unless payload && @split_step_checkpoint_digest
19
+
20
+ @split_step_checkpoint_digest == Digest::SHA256.hexdigest(payload)
21
+ end
22
+
23
+ def validate_split_step_marker!(payload, expected:)
24
+ marker = JSON.parse(payload).fetch("step_in_progress")
25
+ return if marker == expected
26
+
27
+ raise WorkflowError, "split-step persistence serialized an invalid step_in_progress marker"
28
+ rescue JSON::ParserError, KeyError, TypeError
29
+ raise WorkflowError, "split-step persistence requires an explicit step_in_progress marker"
30
+ end
31
+
32
+ def split_step_checkpoint_payload(payload)
33
+ document = JSON.parse(payload)
34
+ document["step_in_progress"] = false
35
+ JSON.generate(document)
36
+ end
37
+
38
+ def split_step_preparation_payload(payload)
39
+ document = JSON.parse(payload)
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
45
+ JSON.generate(document)
46
+ end
47
+
48
+ def split_step_transition_signature(transition)
49
+ transition && TransitionContract.signature(transition)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end