smith-agents 0.4.5 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +264 -0
- data/README.md +191 -4
- data/docs/CONFIGURATION.md +4 -1
- data/docs/PATTERNS.md +7 -0
- data/docs/PERSISTENCE.md +347 -25
- 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 +37 -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 -4
- data/lib/smith/error.rb +5 -0
- data/lib/smith/errors.rb +10 -0
- data/lib/smith/exponential_backoff.rb +128 -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_store.rb +7 -4
- data/lib/smith/persistence_adapters/cache_store.rb +1 -0
- data/lib/smith/persistence_adapters/memory.rb +16 -1
- 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 +28 -11
- data/lib/smith/persistence_adapters/retry.rb +22 -10
- data/lib/smith/persistence_adapters.rb +21 -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/composite/branch.rb +93 -0
- data/lib/smith/workflow/composite/branch_budget_contract.rb +41 -0
- data/lib/smith/workflow/composite/branch_contract.rb +102 -0
- data/lib/smith/workflow/composite/branch_execution.rb +124 -0
- data/lib/smith/workflow/composite/branch_failure.rb +91 -0
- data/lib/smith/workflow/composite/branch_outcome.rb +108 -0
- data/lib/smith/workflow/composite/budget_allocator.rb +78 -0
- data/lib/smith/workflow/composite/budget_math.rb +51 -0
- data/lib/smith/workflow/composite/contract.rb +100 -0
- data/lib/smith/workflow/composite/effects.rb +140 -0
- data/lib/smith/workflow/composite/effects_application.rb +36 -0
- data/lib/smith/workflow/composite/effects_baseline.rb +36 -0
- data/lib/smith/workflow/composite/effects_preflight.rb +102 -0
- data/lib/smith/workflow/composite/encoded_value_budget.rb +36 -0
- data/lib/smith/workflow/composite/enums.rb +27 -0
- data/lib/smith/workflow/composite/error.rb +54 -0
- data/lib/smith/workflow/composite/error_evidence.rb +61 -0
- data/lib/smith/workflow/composite/execution_contract.rb +73 -0
- data/lib/smith/workflow/composite/fanout_branch_contract.rb +61 -0
- data/lib/smith/workflow/composite/input.rb +53 -0
- data/lib/smith/workflow/composite/outcome_accumulator.rb +131 -0
- data/lib/smith/workflow/composite/outcome_set.rb +23 -0
- data/lib/smith/workflow/composite/payload.rb +128 -0
- data/lib/smith/workflow/composite/payload_digest.rb +28 -0
- data/lib/smith/workflow/composite/plan.rb +130 -0
- data/lib/smith/workflow/composite/plan_integrity.rb +52 -0
- data/lib/smith/workflow/composite/planner.rb +53 -0
- data/lib/smith/workflow/composite/preparation.rb +27 -0
- data/lib/smith/workflow/composite/reducer.rb +133 -0
- data/lib/smith/workflow/composite/reduction.rb +31 -0
- data/lib/smith/workflow/composite/value_budget.rb +90 -0
- data/lib/smith/workflow/composite_branch_execution_authorization.rb +52 -0
- data/lib/smith/workflow/deadline_enforcement.rb +22 -5
- 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 +1 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
- data/lib/smith/workflow/execution.rb +28 -30
- 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 +20 -19
- 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 +134 -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 +46 -14
- data/lib/smith/workflow/persistence.rb +37 -8
- data/lib/smith/workflow/prepared_branch_execution.rb +31 -0
- 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 +101 -0
- data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
- data/lib/smith/workflow/prepared_step_execution_scope.rb +111 -0
- data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
- data/lib/smith/workflow/process_local.rb +33 -0
- data/lib/smith/workflow/retry_execution.rb +20 -11
- data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
- 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 +3 -5
- data/lib/smith/workflow/split_step_persistence/composite_branch_authorization.rb +99 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_effects.rb +41 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_execution.rb +95 -0
- data/lib/smith/workflow/split_step_persistence/composite_branch_outcome.rb +36 -0
- data/lib/smith/workflow/split_step_persistence/composite_execution.rb +39 -0
- data/lib/smith/workflow/split_step_persistence/composite_preparation.rb +57 -0
- data/lib/smith/workflow/split_step_persistence/composite_reduction_execution.rb +122 -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 +20 -64
- data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +109 -0
- data/lib/smith/workflow/split_step_persistence/execution_authorization_issuance.rb +57 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +95 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +109 -0
- data/lib/smith/workflow/split_step_persistence/execution_lifecycle.rb +96 -0
- data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/execution_verification.rb +77 -0
- data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
- data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
- data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
- data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
- data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
- data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
- data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
- 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/subclass_boundary.rb +66 -0
- data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
- data/lib/smith/workflow/split_step_persistence.rb +50 -0
- data/lib/smith/workflow/step_completion.rb +31 -0
- data/lib/smith/workflow/step_context.rb +46 -0
- data/lib/smith/workflow/string_snapshot.rb +25 -0
- data/lib/smith/workflow/thread_context_snapshot.rb +103 -0
- data/lib/smith/workflow/transition.rb +137 -133
- data/lib/smith/workflow/transition_actionability.rb +22 -0
- data/lib/smith/workflow.rb +64 -18
- data/lib/smith.rb +44 -2
- data/sig/smith.rbs +1 -0
- metadata +155 -19
data/lib/smith/version.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Smith
|
|
|
4
4
|
class Workflow
|
|
5
5
|
# rubocop:disable Style/RedundantStructKeywordInit
|
|
6
6
|
BranchEnv = Struct.new(
|
|
7
|
-
:prepared_input, :guardrail_sources, :scoped_store, :branch_estimates, :deadline,
|
|
7
|
+
:prepared_input, :guardrail_sources, :scoped_store, :branch_estimates, :deadline, :agent_class,
|
|
8
8
|
keyword_init: true
|
|
9
9
|
) do
|
|
10
10
|
def setup_thread
|
|
@@ -15,8 +15,7 @@ module Smith
|
|
|
15
15
|
def reserve_branch_budget(ledger, branch_estimates:)
|
|
16
16
|
return nil unless ledger && branch_estimates
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
branch_estimates
|
|
18
|
+
reserve_estimates!(ledger, branch_estimates)
|
|
20
19
|
end
|
|
21
20
|
|
|
22
21
|
def compute_branch_estimates(ledger, branch_count:, agent_budget: nil)
|
|
@@ -29,13 +28,16 @@ module Smith
|
|
|
29
28
|
end
|
|
30
29
|
end
|
|
31
30
|
|
|
32
|
-
def reconcile_branch_budget(ledger,
|
|
33
|
-
return unless ledger &&
|
|
31
|
+
def reconcile_branch_budget(ledger, reservation, agent_result: nil)
|
|
32
|
+
return unless ledger && reservation
|
|
34
33
|
|
|
35
34
|
actuals = extract_actuals(agent_results_for_settlement(agent_result))
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
settlement = reservation.amounts.to_h do |dimension, _amount|
|
|
36
|
+
[dimension, actual_for_dimension(dimension, actuals[:tokens], actuals[:cost])]
|
|
38
37
|
end
|
|
38
|
+
return ledger.reconcile_many!(reservation, actual: settlement) if settlement.length > 1
|
|
39
|
+
|
|
40
|
+
ledger.reconcile!(reservation, settlement.values.first)
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def extract_actuals(agent_results)
|
|
@@ -66,10 +68,12 @@ module Smith
|
|
|
66
68
|
0
|
|
67
69
|
end
|
|
68
70
|
|
|
69
|
-
def release_branch_budget(ledger,
|
|
70
|
-
return unless ledger &&
|
|
71
|
+
def release_branch_budget(ledger, reservation)
|
|
72
|
+
return unless ledger && reservation
|
|
73
|
+
|
|
74
|
+
return ledger.release_many!(reservation) if reservation.amounts.length > 1
|
|
71
75
|
|
|
72
|
-
|
|
76
|
+
ledger.release!(reservation)
|
|
73
77
|
end
|
|
74
78
|
|
|
75
79
|
def settle_budget_on_failure(ledger, estimates, agent_result)
|
|
@@ -91,8 +95,14 @@ module Smith
|
|
|
91
95
|
est[dim] = cap ? [remaining, cap].min : remaining
|
|
92
96
|
end
|
|
93
97
|
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
reserve_estimates!(ledger, estimates)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def reserve_estimates!(ledger, estimates)
|
|
102
|
+
return ledger.reserve_many!(estimates) if estimates.length > 1
|
|
103
|
+
|
|
104
|
+
dimension, amount = estimates.first
|
|
105
|
+
ledger.reserve!(dimension, amount)
|
|
96
106
|
end
|
|
97
107
|
|
|
98
108
|
def finalize_branch(transition, index, result, ledger, reserved)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../types"
|
|
4
|
+
require_relative "../message_value_normalizer"
|
|
5
|
+
require_relative "../prepared_step"
|
|
6
|
+
require_relative "payload"
|
|
7
|
+
require_relative "payload_digest"
|
|
8
|
+
|
|
9
|
+
module Smith
|
|
10
|
+
class Workflow
|
|
11
|
+
module Composite
|
|
12
|
+
class Branch < Payload
|
|
13
|
+
MAX_BUDGET_DIMENSIONS = 128
|
|
14
|
+
HASH_LENGTH = Hash.instance_method(:length)
|
|
15
|
+
HASH_EACH_PAIR = Hash.instance_method(:each_pair)
|
|
16
|
+
private_constant :HASH_LENGTH, :HASH_EACH_PAIR
|
|
17
|
+
OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
|
|
18
|
+
private_constant :OwnedString
|
|
19
|
+
|
|
20
|
+
attribute :ordinal, Types::Integer.constrained(gteq: 0, lteq: PreparedStep::MAX_COUNTER_VALUE)
|
|
21
|
+
attribute :key, OwnedString.constrained(min_size: 1, max_size: 256)
|
|
22
|
+
attribute :agent, OwnedString.constrained(min_size: 1, max_size: 256)
|
|
23
|
+
attribute :binding_identity, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
|
|
24
|
+
attribute :budget, Types::Hash
|
|
25
|
+
attribute :digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
|
|
26
|
+
|
|
27
|
+
def self.build(ordinal:, key:, agent:, binding_identity:, budget:)
|
|
28
|
+
attributes = {
|
|
29
|
+
ordinal:,
|
|
30
|
+
key: key.to_s,
|
|
31
|
+
agent: agent.to_s,
|
|
32
|
+
binding_identity:,
|
|
33
|
+
budget: normalize_budget(budget)
|
|
34
|
+
}
|
|
35
|
+
new(attributes.merge(digest: PayloadDigest.call(attributes)))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(attributes)
|
|
39
|
+
owned = self.class.normalize_attributes(attributes)
|
|
40
|
+
owned[:budget] = self.class.send(:normalize_budget, owned[:budget])
|
|
41
|
+
super(owned)
|
|
42
|
+
expected = PayloadDigest.call(to_h.except(:digest))
|
|
43
|
+
raise ArgumentError, "composite branch digest does not match" unless digest == expected
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def normalize_budget(value)
|
|
50
|
+
normalized = normalized_budget(value)
|
|
51
|
+
validate_budget_dimensions!(normalized)
|
|
52
|
+
copy_budget(normalized)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def normalized_budget(value)
|
|
56
|
+
normalized = MessageValueNormalizer.new(value, label: "composite branch budget").call
|
|
57
|
+
raise ArgumentError, "composite branch budget must be a Hash" unless normalized.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
normalized
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def validate_budget_dimensions!(budget)
|
|
63
|
+
return if HASH_LENGTH.bind_call(budget) <= MAX_BUDGET_DIMENSIONS
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "composite branch budget contains too many dimensions"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def copy_budget(normalized)
|
|
69
|
+
budget = {}
|
|
70
|
+
HASH_EACH_PAIR.bind_call(normalized) do |name, amount|
|
|
71
|
+
validate_budget_dimension!(name)
|
|
72
|
+
validate_budget_amount!(amount)
|
|
73
|
+
budget[name.freeze] = amount
|
|
74
|
+
end
|
|
75
|
+
budget.freeze
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def validate_budget_dimension!(name)
|
|
79
|
+
return if name.length.between?(1, 256)
|
|
80
|
+
|
|
81
|
+
raise ArgumentError, "composite branch budget dimensions must be bounded non-empty strings"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def validate_budget_amount!(amount)
|
|
85
|
+
return if amount.is_a?(Numeric) && amount.finite? && amount >= 0
|
|
86
|
+
|
|
87
|
+
raise ArgumentError, "composite branch budget values must be finite non-negative numbers"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "budget_allocator"
|
|
4
|
+
require_relative "plan"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
class Workflow
|
|
8
|
+
module Composite
|
|
9
|
+
module BranchBudgetContract
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def validate_composite_branch_count!(count)
|
|
13
|
+
Plan.validate_branch_count!(count)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def composite_budget_allocator(branch_count)
|
|
17
|
+
return unless @ledger
|
|
18
|
+
|
|
19
|
+
BudgetAllocator.new(ledger: @ledger, branch_count:)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def composite_branch_budget(allocator, ordinal, agent_budget)
|
|
23
|
+
return {} unless allocator
|
|
24
|
+
|
|
25
|
+
caps = @ledger.limits.each_key.with_object({}) do |dimension, result|
|
|
26
|
+
cap = agent_cap_for_dimension(dimension, agent_budget)
|
|
27
|
+
result[dimension] = cap unless cap.nil?
|
|
28
|
+
end
|
|
29
|
+
allocator.call(ordinal:, caps:)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def composite_binding_identity!(agent_class, agent)
|
|
33
|
+
identity = agent_class&.execution_identity
|
|
34
|
+
return identity if identity
|
|
35
|
+
|
|
36
|
+
raise WorkflowError, "durable composite agent :#{agent} requires an execution_identity"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../parallel"
|
|
4
|
+
require_relative "branch"
|
|
5
|
+
require_relative "branch_budget_contract"
|
|
6
|
+
require_relative "fanout_branch_contract"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
class Workflow
|
|
10
|
+
module Composite
|
|
11
|
+
module BranchContract
|
|
12
|
+
include BranchBudgetContract
|
|
13
|
+
include FanoutBranchContract
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def composite_branch_specs(authorization, transition, branch_count: nil)
|
|
18
|
+
return parallel_branch_specs(authorization, transition, branch_count:) if transition.parallel?
|
|
19
|
+
|
|
20
|
+
fanout_branch_specs(authorization, transition)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def parallel_branch_specs(authorization, transition, branch_count: nil)
|
|
24
|
+
validate_planned_parallel_count!(transition, branch_count) if branch_count
|
|
25
|
+
count = branch_count || Parallel.resolve_branch_count(transition, @context)
|
|
26
|
+
validate_composite_branch_count!(count)
|
|
27
|
+
agent = transition.agent_name
|
|
28
|
+
agent_class = captured_agent(authorization, transition, agent, :agent)
|
|
29
|
+
allocator = composite_budget_allocator(count)
|
|
30
|
+
Array.new(count) do |ordinal|
|
|
31
|
+
parallel_branch_spec(agent, agent_class, allocator, ordinal).merge(key: ordinal.to_s)
|
|
32
|
+
end.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def parallel_branch_spec(agent, agent_class, allocator, ordinal)
|
|
36
|
+
{
|
|
37
|
+
agent: agent.to_s,
|
|
38
|
+
binding_identity: composite_binding_identity!(agent_class, agent),
|
|
39
|
+
budget: composite_branch_budget(allocator, ordinal, agent_class&.budget)
|
|
40
|
+
}.freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def validate_planned_parallel_count!(transition, branch_count)
|
|
44
|
+
Parallel.validate_branch_count!(branch_count)
|
|
45
|
+
configured = transition.agent_opts[:count]
|
|
46
|
+
return if configured.respond_to?(:call)
|
|
47
|
+
return if branch_count == (configured || 1)
|
|
48
|
+
|
|
49
|
+
raise WorkflowError, "composite plan branch count does not match the prepared transition"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def captured_agent(authorization, transition, agent, role)
|
|
53
|
+
authorization.fetch_agent!(
|
|
54
|
+
agent,
|
|
55
|
+
workflow_class: self.class,
|
|
56
|
+
transition_name: transition.name,
|
|
57
|
+
role:
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def validate_composite_branches!(authorization, plan, transition)
|
|
62
|
+
specs = composite_branch_specs(authorization, transition, branch_count: plan.branches.length)
|
|
63
|
+
specs.each_with_index do |spec, ordinal|
|
|
64
|
+
expected = comparable_branch(Branch.build(ordinal:, **spec))
|
|
65
|
+
actual = comparable_branch(plan.branches.fetch(ordinal))
|
|
66
|
+
next if actual == expected
|
|
67
|
+
|
|
68
|
+
raise WorkflowError, "composite plan branches do not match the prepared transition"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def validate_composite_selected_branch!(authorization, execution, transition)
|
|
73
|
+
branch = execution.branch
|
|
74
|
+
spec = selected_branch_spec(authorization, execution, transition)
|
|
75
|
+
candidate = Branch.build(ordinal: branch.ordinal, **spec)
|
|
76
|
+
raise WorkflowError, "composite branch does not match the prepared transition" unless
|
|
77
|
+
comparable_branch(candidate) == comparable_branch(branch)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def selected_branch_spec(authorization, execution, transition)
|
|
81
|
+
return selected_parallel_branch_spec(authorization, execution, transition) if transition.parallel?
|
|
82
|
+
|
|
83
|
+
selected_fanout_branch_spec(authorization, execution, transition)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def selected_parallel_branch_spec(authorization, execution, transition)
|
|
87
|
+
branch = execution.branch
|
|
88
|
+
count = execution.branch_count
|
|
89
|
+
validate_planned_parallel_count!(transition, count)
|
|
90
|
+
agent = transition.agent_name
|
|
91
|
+
agent_class = captured_agent(authorization, transition, agent, :agent)
|
|
92
|
+
allocator = composite_budget_allocator(count)
|
|
93
|
+
parallel_branch_spec(agent, agent_class, allocator, branch.ordinal).merge(key: branch.ordinal.to_s)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def comparable_branch(branch)
|
|
97
|
+
[branch.ordinal, branch.key, branch.agent, branch.binding_identity, branch.budget]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../types"
|
|
4
|
+
require_relative "../prepared_step"
|
|
5
|
+
require_relative "../prepared_step_dispatch"
|
|
6
|
+
require_relative "branch"
|
|
7
|
+
require_relative "enums"
|
|
8
|
+
require_relative "payload"
|
|
9
|
+
require_relative "payload_digest"
|
|
10
|
+
require_relative "plan"
|
|
11
|
+
|
|
12
|
+
module Smith
|
|
13
|
+
class Workflow
|
|
14
|
+
module Composite
|
|
15
|
+
class BranchExecution < Payload
|
|
16
|
+
VERSION = 1
|
|
17
|
+
ENUM_ATTRIBUTES = %i[kind resume_policy failure_policy reduction_policy retry_policy].freeze
|
|
18
|
+
private_constant :ENUM_ATTRIBUTES
|
|
19
|
+
OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
|
|
20
|
+
private_constant :OwnedString
|
|
21
|
+
|
|
22
|
+
attribute :version, Types::Integer.enum(VERSION)
|
|
23
|
+
attribute :execution_semantics_version, OwnedString.constrained(min_size: 1, max_size: 32)
|
|
24
|
+
attribute :dispatch, Types.Instance(PreparedStepDispatch)
|
|
25
|
+
attribute :plan_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
|
|
26
|
+
attribute :kind, Types::Symbol.enum(:parallel, :fanout)
|
|
27
|
+
attribute :transition, OwnedString.constrained(min_size: 1, max_size: 256)
|
|
28
|
+
attribute :from, OwnedString.constrained(min_size: 1, max_size: 256)
|
|
29
|
+
attribute :execution_namespace, OwnedString.constrained(format: PreparedStep::UUID_PATTERN)
|
|
30
|
+
attribute :branch_count, Types::Integer.constrained(gteq: 1, lteq: Plan::MAX_BRANCHES)
|
|
31
|
+
attribute :input_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
|
|
32
|
+
attribute :budget_state_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
|
|
33
|
+
attribute :resume_policy, Types::Symbol.enum(Plan::RESUME_POLICY)
|
|
34
|
+
attribute :failure_policy, Types::Symbol.enum(Plan::FAILURE_POLICY)
|
|
35
|
+
attribute :reduction_policy, Types::Symbol.enum(Plan::REDUCTION_POLICY)
|
|
36
|
+
attribute :retry_policy, Types::Symbol.enum(Plan::RETRY_POLICY)
|
|
37
|
+
attribute :branch, Types.Instance(Branch)
|
|
38
|
+
attribute :digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
def build(plan:, branch:)
|
|
42
|
+
validate_plan_branch!(plan, branch)
|
|
43
|
+
values = execution_attributes(plan, branch)
|
|
44
|
+
new(values.merge(digest: PayloadDigest.call(serializable(values))))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def normalize_attributes(attributes)
|
|
48
|
+
normalized = super
|
|
49
|
+
normalize_dispatch!(normalized)
|
|
50
|
+
normalize_branch!(normalized)
|
|
51
|
+
ENUM_ATTRIBUTES.each { |key| normalized[key] = Enums.normalize(key, normalized[key]) }
|
|
52
|
+
normalized
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def serializable(values)
|
|
56
|
+
values.merge(dispatch: values.fetch(:dispatch).to_h, branch: values.fetch(:branch).to_h)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def execution_attributes(plan, branch)
|
|
62
|
+
{
|
|
63
|
+
version: VERSION,
|
|
64
|
+
execution_semantics_version: plan.execution_semantics_version,
|
|
65
|
+
dispatch: plan.dispatch,
|
|
66
|
+
plan_digest: plan.plan_digest,
|
|
67
|
+
kind: plan.kind,
|
|
68
|
+
transition: plan.transition,
|
|
69
|
+
from: plan.from,
|
|
70
|
+
execution_namespace: plan.execution_namespace,
|
|
71
|
+
branch_count: plan.branches.length,
|
|
72
|
+
input_digest: plan.input_digest,
|
|
73
|
+
budget_state_digest: plan.budget_state_digest,
|
|
74
|
+
resume_policy: plan.resume_policy,
|
|
75
|
+
failure_policy: plan.failure_policy,
|
|
76
|
+
reduction_policy: plan.reduction_policy,
|
|
77
|
+
retry_policy: plan.retry_policy,
|
|
78
|
+
branch:
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_plan_branch!(plan, branch)
|
|
83
|
+
raise ArgumentError, "plan must be a Smith composite plan" unless plan.is_a?(Plan)
|
|
84
|
+
raise ArgumentError, "branch must be a Smith composite branch" unless branch.is_a?(Branch)
|
|
85
|
+
|
|
86
|
+
expected = plan.branches.fetch(branch.ordinal) do
|
|
87
|
+
raise ArgumentError, "composite branch ordinal is invalid"
|
|
88
|
+
end
|
|
89
|
+
raise ArgumentError, "composite branch does not belong to plan" unless expected.to_h == branch.to_h
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_dispatch!(attributes)
|
|
93
|
+
value = attributes[:dispatch]
|
|
94
|
+
attributes[:dispatch] = PreparedStepDispatch.deserialize(value) unless value.is_a?(PreparedStepDispatch)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def normalize_branch!(attributes)
|
|
98
|
+
value = attributes[:branch]
|
|
99
|
+
attributes[:branch] = Branch.deserialize(value) unless value.is_a?(Branch)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def initialize(attributes)
|
|
104
|
+
owned = self.class.normalize_attributes(attributes)
|
|
105
|
+
super(owned)
|
|
106
|
+
validate_contract!
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def validate_contract!
|
|
112
|
+
Plan.validate_branch_count!(branch_count)
|
|
113
|
+
raise ArgumentError, "composite branch ordinal exceeds branch count" if branch.ordinal >= branch_count
|
|
114
|
+
unless execution_semantics_version == Smith::EXECUTION_SEMANTICS_VERSION
|
|
115
|
+
raise ArgumentError, "composite branch execution semantics do not match"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
expected = PayloadDigest.call(self.class.serializable(to_h.except(:digest)))
|
|
119
|
+
raise ArgumentError, "composite branch execution digest does not match" unless digest == expected
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../errors"
|
|
4
|
+
require_relative "error"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
class Workflow
|
|
8
|
+
module Composite
|
|
9
|
+
class BranchFailure < WorkflowError
|
|
10
|
+
DETAIL_KEYS = {
|
|
11
|
+
"branch_key" => :branch_key,
|
|
12
|
+
"error_class" => :error_class,
|
|
13
|
+
"error_family" => :error_family,
|
|
14
|
+
"retryable" => :retryable,
|
|
15
|
+
"kind" => :kind
|
|
16
|
+
}.freeze
|
|
17
|
+
DETAIL_NAMES = DETAIL_KEYS.values.freeze
|
|
18
|
+
private_constant :DETAIL_KEYS, :DETAIL_NAMES
|
|
19
|
+
|
|
20
|
+
attr_reader :branch_key, :error_class, :error_family, :retryable, :kind, :details
|
|
21
|
+
|
|
22
|
+
def self.from_details(details)
|
|
23
|
+
values = normalize_details(details)
|
|
24
|
+
error = Error.new(
|
|
25
|
+
class_name: values.fetch(:error_class),
|
|
26
|
+
family: values.fetch(:error_family),
|
|
27
|
+
retryable: values.fetch(:retryable),
|
|
28
|
+
kind: values.fetch(:kind)
|
|
29
|
+
)
|
|
30
|
+
new(branch_key: values.fetch(:branch_key), error:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.normalize_details(details)
|
|
34
|
+
raise ArgumentError, "composite branch failure details must be a Hash" unless details.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
normalized = {}
|
|
37
|
+
Hash.instance_method(:each_pair).bind_call(details) do |key, value|
|
|
38
|
+
name = normalize_detail_key(key)
|
|
39
|
+
if normalized.key?(name)
|
|
40
|
+
raise ArgumentError, "composite branch failure details contain a duplicate attribute"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
normalized[name] = value
|
|
44
|
+
end
|
|
45
|
+
missing = DETAIL_NAMES - normalized.keys
|
|
46
|
+
raise ArgumentError, "composite branch failure details are missing required attributes" if missing.any?
|
|
47
|
+
|
|
48
|
+
normalized
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.normalize_detail_key(key)
|
|
52
|
+
name = key.is_a?(Symbol) ? key : DETAIL_KEYS.fetch(key, key)
|
|
53
|
+
return name if DETAIL_NAMES.include?(name)
|
|
54
|
+
|
|
55
|
+
raise ArgumentError, "composite branch failure details contain an unknown attribute"
|
|
56
|
+
end
|
|
57
|
+
private_class_method :normalize_details
|
|
58
|
+
private_class_method :normalize_detail_key
|
|
59
|
+
|
|
60
|
+
def initialize(branch_key:, error:)
|
|
61
|
+
validate_arguments!(branch_key, error)
|
|
62
|
+
@branch_key = branch_key.dup.freeze
|
|
63
|
+
@error_class = error.class_name.dup.freeze
|
|
64
|
+
@error_family = error.family.dup.freeze
|
|
65
|
+
@retryable = error.retryable
|
|
66
|
+
@kind = error.kind&.dup&.freeze
|
|
67
|
+
@details = failure_details
|
|
68
|
+
super("composite branch #{branch_key.inspect} failed")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def validate_arguments!(branch_key, error)
|
|
74
|
+
valid_key = branch_key.is_a?(String) && branch_key.length.between?(1, 256)
|
|
75
|
+
raise ArgumentError, "composite branch failure key must be a bounded non-empty String" unless valid_key
|
|
76
|
+
raise ArgumentError, "composite branch failure requires typed error evidence" unless error.is_a?(Error)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def failure_details
|
|
80
|
+
{
|
|
81
|
+
branch_key: @branch_key,
|
|
82
|
+
error_class: @error_class,
|
|
83
|
+
error_family: @error_family,
|
|
84
|
+
retryable: @retryable,
|
|
85
|
+
kind: @kind
|
|
86
|
+
}.freeze
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../types"
|
|
4
|
+
require_relative "../message_value_normalizer"
|
|
5
|
+
require_relative "../prepared_step"
|
|
6
|
+
require_relative "effects"
|
|
7
|
+
require_relative "enums"
|
|
8
|
+
require_relative "error"
|
|
9
|
+
require_relative "payload"
|
|
10
|
+
require_relative "payload_digest"
|
|
11
|
+
|
|
12
|
+
module Smith
|
|
13
|
+
class Workflow
|
|
14
|
+
module Composite
|
|
15
|
+
class BranchOutcome < Payload
|
|
16
|
+
OwnedString = Types::String.constructor { |value| value.is_a?(String) ? value.dup.freeze : value }
|
|
17
|
+
private_constant :OwnedString
|
|
18
|
+
|
|
19
|
+
attribute :plan_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
|
|
20
|
+
attribute :branch_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
|
|
21
|
+
attribute :ordinal, Types::Integer.constrained(gteq: 0, lteq: PreparedStep::MAX_COUNTER_VALUE)
|
|
22
|
+
attribute :branch_key, OwnedString.constrained(min_size: 1, max_size: 256)
|
|
23
|
+
attribute :agent, OwnedString.constrained(min_size: 1, max_size: 256)
|
|
24
|
+
attribute :status, Types::Symbol.enum(:succeeded, :failed)
|
|
25
|
+
attribute? :output, Types::Any.optional
|
|
26
|
+
attribute? :error, Types.Instance(Error).optional
|
|
27
|
+
attribute :effects, Types.Instance(Effects)
|
|
28
|
+
attribute :digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def succeeded(plan_digest:, branch:, output:, effects:)
|
|
32
|
+
build(plan_digest:, branch:, effects:, status: :succeeded, output:, error: nil)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def failed(plan_digest:, branch:, error:, effects:)
|
|
36
|
+
build(plan_digest:, branch:, effects:, status: :failed, output: nil, error:)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def normalize_attributes(attributes)
|
|
40
|
+
normalized = super
|
|
41
|
+
normalize_status!(normalized)
|
|
42
|
+
normalize_error!(normalized)
|
|
43
|
+
normalize_effects!(normalized)
|
|
44
|
+
normalized
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def build(plan_digest:, branch:, effects:, **result)
|
|
50
|
+
values = {
|
|
51
|
+
plan_digest:,
|
|
52
|
+
branch_digest: branch.digest,
|
|
53
|
+
ordinal: branch.ordinal,
|
|
54
|
+
branch_key: branch.key,
|
|
55
|
+
agent: branch.agent,
|
|
56
|
+
status: result.fetch(:status),
|
|
57
|
+
output: normalize_output(result.fetch(:output)),
|
|
58
|
+
error: result.fetch(:error),
|
|
59
|
+
effects:
|
|
60
|
+
}
|
|
61
|
+
new(values.merge(digest: PayloadDigest.call(serializable(values))))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def normalize_status!(attributes)
|
|
65
|
+
attributes[:status] = Enums.normalize(:status, attributes[:status])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def normalize_error!(attributes)
|
|
69
|
+
error = attributes[:error]
|
|
70
|
+
attributes[:error] = Error.deserialize(error) if error && !error.is_a?(Error)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def normalize_effects!(attributes)
|
|
74
|
+
effects = attributes[:effects]
|
|
75
|
+
attributes[:effects] = Effects.deserialize(effects) unless effects.is_a?(Effects)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def normalize_output(value)
|
|
79
|
+
MessageValueNormalizer.new(value, label: "composite branch output").call
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def serializable(values)
|
|
83
|
+
values.merge(error: values[:error]&.to_h, effects: values.fetch(:effects).to_h)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def initialize(attributes)
|
|
88
|
+
owned = self.class.normalize_attributes(attributes)
|
|
89
|
+
owned[:output] = self.class.send(:normalize_output, owned[:output])
|
|
90
|
+
super(owned)
|
|
91
|
+
validate_shape!
|
|
92
|
+
expected = PayloadDigest.call(self.class.send(:serializable, to_h.except(:digest)))
|
|
93
|
+
raise ArgumentError, "composite branch outcome digest does not match" unless digest == expected
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def succeeded? = status == :succeeded
|
|
97
|
+
def failed? = status == :failed
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def validate_shape!
|
|
102
|
+
valid = succeeded? ? error.nil? : error && output.nil?
|
|
103
|
+
raise ArgumentError, "composite branch outcome fields do not match status" unless valid
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|