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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +262 -0
- data/README.md +116 -6
- data/docs/CONFIGURATION.md +4 -1
- data/docs/PATTERNS.md +7 -0
- data/docs/PERSISTENCE.md +502 -1
- data/docs/workflow_claim.md +4 -1
- data/lib/smith/agent/lifecycle.rb +19 -10
- data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
- data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
- data/lib/smith/agent/registry.rb +8 -0
- data/lib/smith/agent.rb +24 -2
- data/lib/smith/budget/amount_contract.rb +67 -0
- data/lib/smith/budget/amount_representation.rb +43 -0
- data/lib/smith/budget/decimal_context.rb +18 -0
- data/lib/smith/budget/ledger.rb +110 -18
- data/lib/smith/budget/ledger_publication.rb +60 -0
- data/lib/smith/budget/ledger_state.rb +21 -0
- data/lib/smith/budget/ledger_state_transition.rb +72 -0
- data/lib/smith/budget/public_state_validator.rb +25 -0
- data/lib/smith/budget/reservation.rb +20 -0
- data/lib/smith/budget/reservation_contract.rb +34 -0
- data/lib/smith/context/observation_masking.rb +3 -1
- data/lib/smith/context/state_injection.rb +1 -1
- data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -3
- data/lib/smith/error.rb +5 -0
- data/lib/smith/errors.rb +6 -0
- data/lib/smith/exponential_backoff.rb +128 -0
- data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
- data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
- data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
- data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +95 -52
- data/lib/smith/persistence_adapters/cache_store.rb +4 -1
- data/lib/smith/persistence_adapters/memory.rb +45 -14
- data/lib/smith/persistence_adapters/payload_version.rb +23 -0
- data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
- data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
- data/lib/smith/persistence_adapters/redis_store.rb +38 -43
- data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
- data/lib/smith/persistence_adapters/retry.rb +22 -10
- data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
- data/lib/smith/persistence_adapters.rb +26 -18
- data/lib/smith/persistence_payload_conflict.rb +12 -0
- data/lib/smith/tool/budget_enforcement.rb +2 -2
- data/lib/smith/types.rb +1 -0
- data/lib/smith/version.rb +2 -1
- data/lib/smith/workflow/branch_env.rb +1 -1
- data/lib/smith/workflow/budget_integration.rb +21 -11
- data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
- data/lib/smith/workflow/definition_identity.rb +25 -0
- data/lib/smith/workflow/deterministic_step.rb +2 -2
- data/lib/smith/workflow/dsl.rb +73 -5
- data/lib/smith/workflow/durability.rb +2 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
- data/lib/smith/workflow/execution.rb +16 -18
- data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
- data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
- data/lib/smith/workflow/fanout_execution.rb +1 -1
- data/lib/smith/workflow/graph/diagnostic.rb +27 -1
- data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
- data/lib/smith/workflow/graph/execution_successors.rb +65 -0
- data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
- data/lib/smith/workflow/graph/metrics.rb +1 -3
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
- data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
- data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
- data/lib/smith/workflow/graph/reachability.rb +19 -31
- data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
- data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
- data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
- data/lib/smith/workflow/graph/transition_contract.rb +47 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
- data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
- data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
- data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
- data/lib/smith/workflow/graph/validator.rb +1 -1
- data/lib/smith/workflow/graph.rb +62 -4
- data/lib/smith/workflow/guardrail_integration.rb +23 -9
- data/lib/smith/workflow/identifier.rb +24 -0
- data/lib/smith/workflow/message_admission.rb +40 -0
- data/lib/smith/workflow/message_admission_boundary.rb +22 -0
- data/lib/smith/workflow/message_batch.rb +60 -0
- data/lib/smith/workflow/message_value_normalizer.rb +133 -0
- data/lib/smith/workflow/nested_execution.rb +10 -0
- data/lib/smith/workflow/orchestrator_worker.rb +3 -7
- data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
- data/lib/smith/workflow/parallel/execution_context.rb +113 -0
- data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
- data/lib/smith/workflow/parallel/root_execution.rb +91 -0
- data/lib/smith/workflow/parallel.rb +19 -22
- data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
- data/lib/smith/workflow/parallel_execution.rb +7 -3
- data/lib/smith/workflow/persistence.rb +49 -10
- data/lib/smith/workflow/prepared_step.rb +122 -0
- data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
- data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
- data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
- data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
- data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
- data/lib/smith/workflow/retry_execution.rb +20 -11
- data/lib/smith/workflow/split_step_persistence/boundary.rb +128 -0
- data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
- data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +119 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
- data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
- data/lib/smith/workflow/split_step_persistence/execution.rb +128 -0
- data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
- data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
- data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
- data/lib/smith/workflow/split_step_persistence/inheritance.rb +22 -0
- data/lib/smith/workflow/split_step_persistence/payloads.rb +54 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +125 -0
- data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +117 -0
- data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
- data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +54 -0
- data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
- data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
- data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
- data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
- data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
- data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +102 -0
- data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
- data/lib/smith/workflow/split_step_persistence.rb +79 -0
- data/lib/smith/workflow/step_completion.rb +31 -0
- data/lib/smith/workflow/string_snapshot.rb +25 -0
- data/lib/smith/workflow/transition.rb +122 -133
- data/lib/smith/workflow/transition_actionability.rb +22 -0
- data/lib/smith/workflow.rb +57 -19
- data/lib/smith.rb +42 -2
- data/sig/smith.rbs +1 -0
- 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
|