smith-agents 0.4.5 → 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 (140) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +201 -0
  3. data/README.md +108 -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 +24 -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 +6 -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/definition_identity/class_methods.rb +48 -0
  46. data/lib/smith/workflow/definition_identity.rb +25 -0
  47. data/lib/smith/workflow/deterministic_step.rb +2 -2
  48. data/lib/smith/workflow/dsl.rb +73 -5
  49. data/lib/smith/workflow/durability.rb +1 -1
  50. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  51. data/lib/smith/workflow/execution.rb +16 -18
  52. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  53. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  54. data/lib/smith/workflow/fanout_execution.rb +1 -1
  55. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  56. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  57. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  58. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  59. data/lib/smith/workflow/graph/metrics.rb +1 -3
  60. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  61. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  62. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  63. data/lib/smith/workflow/graph/reachability.rb +19 -31
  64. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  65. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  66. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  67. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  68. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  69. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  70. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  71. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  72. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  73. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  74. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  75. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  76. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  77. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  78. data/lib/smith/workflow/graph/validator.rb +1 -1
  79. data/lib/smith/workflow/graph.rb +62 -4
  80. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  81. data/lib/smith/workflow/identifier.rb +24 -0
  82. data/lib/smith/workflow/message_admission.rb +40 -0
  83. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  84. data/lib/smith/workflow/message_batch.rb +60 -0
  85. data/lib/smith/workflow/message_value_normalizer.rb +133 -0
  86. data/lib/smith/workflow/nested_execution.rb +10 -0
  87. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  88. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  89. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  90. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  91. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  92. data/lib/smith/workflow/parallel.rb +19 -22
  93. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  94. data/lib/smith/workflow/parallel_execution.rb +7 -3
  95. data/lib/smith/workflow/persistence.rb +37 -8
  96. data/lib/smith/workflow/prepared_step.rb +122 -0
  97. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  98. data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
  99. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  100. data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
  101. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  102. data/lib/smith/workflow/retry_execution.rb +20 -11
  103. data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
  104. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  105. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  106. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -5
  107. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  108. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  109. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  110. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  111. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  112. data/lib/smith/workflow/split_step_persistence/execution.rb +49 -47
  113. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
  114. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
  115. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
  116. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  117. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
  118. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  119. data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
  120. data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
  121. data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
  122. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
  123. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  124. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
  125. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  126. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  127. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  128. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  129. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  130. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +50 -0
  131. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  132. data/lib/smith/workflow/split_step_persistence.rb +36 -0
  133. data/lib/smith/workflow/step_completion.rb +31 -0
  134. data/lib/smith/workflow/string_snapshot.rb +25 -0
  135. data/lib/smith/workflow/transition.rb +122 -133
  136. data/lib/smith/workflow/transition_actionability.rb +22 -0
  137. data/lib/smith/workflow.rb +44 -18
  138. data/lib/smith.rb +41 -2
  139. data/sig/smith.rbs +1 -0
  140. metadata +110 -19
@@ -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
@@ -11,7 +11,9 @@ module Smith
11
11
 
12
12
  def prepend(*features)
13
13
  result = super
14
- Module.instance_method(:prepend).bind_call(self, SubclassBoundary.guard)
14
+ guard = SubclassBoundary.guard
15
+ guard.freeze
16
+ Module.instance_method(:prepend).bind_call(self, guard)
15
17
  result
16
18
  end
17
19
  end
@@ -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