smith-agents 0.4.2 → 0.4.4
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 +81 -3
- data/README.md +45 -2
- data/UPSTREAM_PROPOSAL.md +19 -0
- data/docs/PATTERNS.md +41 -1
- data/lib/smith/agent/lifecycle.rb +30 -10
- data/lib/smith/agent/registry/introspection.rb +27 -0
- data/lib/smith/agent/registry.rb +22 -17
- data/lib/smith/agent/registry_binding.rb +43 -0
- data/lib/smith/agent.rb +3 -4
- data/lib/smith/doctor/checks/models_registry.rb +25 -14
- data/lib/smith/doctor/checks/persistence_capabilities.rb +37 -29
- data/lib/smith/version.rb +1 -1
- data/lib/smith/workflow/agent_result.rb +26 -0
- data/lib/smith/workflow/branch_env.rb +24 -0
- data/lib/smith/workflow/budget_integration.rb +2 -0
- data/lib/smith/workflow/deterministic_execution.rb +1 -1
- data/lib/smith/workflow/deterministic_step.rb +32 -4
- data/lib/smith/workflow/durability.rb +38 -12
- data/lib/smith/workflow/evaluator_optimizer.rb +5 -8
- data/lib/smith/workflow/event_integration.rb +3 -3
- data/lib/smith/workflow/execution.rb +2 -0
- data/lib/smith/workflow/fanout_execution.rb +2 -0
- data/lib/smith/workflow/graph/contract_helpers.rb +65 -0
- data/lib/smith/workflow/graph/fanout_contract.rb +140 -0
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +98 -0
- data/lib/smith/workflow/graph/optimization_contract.rb +96 -0
- data/lib/smith/workflow/graph/orchestration_contract.rb +83 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostic_builder.rb +124 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +114 -0
- data/lib/smith/workflow/graph/runtime_readiness.rb +63 -0
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +87 -0
- data/lib/smith/workflow/graph/runtime_readiness_report.rb +65 -0
- data/lib/smith/workflow/graph/targets.rb +5 -0
- data/lib/smith/workflow/graph/transition_diagnostics.rb +7 -0
- data/lib/smith/workflow/graph/transition_snapshot.rb +61 -20
- data/lib/smith/workflow/graph.rb +16 -1
- data/lib/smith/workflow/graph_dsl.rb +4 -0
- data/lib/smith/workflow/guardrail_integration.rb +20 -3
- data/lib/smith/workflow/nested_execution.rb +5 -4
- data/lib/smith/workflow/optimization_state.rb +13 -0
- data/lib/smith/workflow/orchestration_state.rb +13 -0
- data/lib/smith/workflow/orchestrator_worker.rb +3 -15
- data/lib/smith/workflow/parallel/cancellation_signal.rb +21 -0
- data/lib/smith/workflow/parallel.rb +6 -15
- data/lib/smith/workflow/parallel_execution.rb +2 -0
- data/lib/smith/workflow/persistence.rb +37 -6
- data/lib/smith/workflow/router.rb +15 -4
- data/lib/smith/workflow/run_result.rb +54 -0
- data/lib/smith/workflow/transition.rb +90 -4
- data/lib/smith/workflow/usage_entry.rb +30 -0
- data/lib/smith/workflow/worker_execution.rb +13 -0
- data/lib/smith/workflow.rb +40 -136
- data/lib/smith.rb +9 -0
- metadata +21 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../../persistence_adapters"
|
|
4
|
+
|
|
3
5
|
module Smith
|
|
4
6
|
module Doctor
|
|
5
7
|
module Checks
|
|
@@ -12,41 +14,16 @@ module Smith
|
|
|
12
14
|
module PersistenceCapabilities
|
|
13
15
|
module_function
|
|
14
16
|
|
|
15
|
-
OPTIONAL_CAPABILITIES =
|
|
17
|
+
OPTIONAL_CAPABILITIES = Smith::PersistenceAdapters::OPTIONAL_METHODS
|
|
16
18
|
|
|
17
19
|
def run(report)
|
|
18
20
|
adapter = resolve_adapter
|
|
19
|
-
if adapter.nil?
|
|
20
|
-
report.add(
|
|
21
|
-
name: "persistence.capabilities",
|
|
22
|
-
status: :warn,
|
|
23
|
-
message: "No persistence adapter configured",
|
|
24
|
-
detail: "Smith.config.persistence_adapter is nil and Smith.config.test_mode is false. " \
|
|
25
|
-
"Hosts using durable workflows must set persistence_adapter."
|
|
26
|
-
)
|
|
27
|
-
return
|
|
28
|
-
end
|
|
21
|
+
return report_missing_adapter(report) if adapter.nil?
|
|
29
22
|
|
|
30
23
|
missing = OPTIONAL_CAPABILITIES.reject { |cap| Smith::PersistenceAdapters.supports?(adapter, cap) }
|
|
24
|
+
return report_supported_capabilities(report, adapter) if missing.empty?
|
|
31
25
|
|
|
32
|
-
|
|
33
|
-
report.add(
|
|
34
|
-
name: "persistence.capabilities",
|
|
35
|
-
status: :pass,
|
|
36
|
-
message: "#{adapter.class.name} supports all optional persistence capabilities",
|
|
37
|
-
detail: "Supported: #{OPTIONAL_CAPABILITIES.join(', ')}"
|
|
38
|
-
)
|
|
39
|
-
else
|
|
40
|
-
report.add(
|
|
41
|
-
name: "persistence.capabilities",
|
|
42
|
-
status: :warn,
|
|
43
|
-
message: "#{adapter.class.name} missing optional capabilities: #{missing.join(', ')}",
|
|
44
|
-
detail: "Workflows using these capabilities fall back to non-versioned writes " \
|
|
45
|
-
"with a one-time warning per adapter class. Switch to RedisStore, " \
|
|
46
|
-
"ActiveRecordStore (with lock_version column), or the Memory adapter " \
|
|
47
|
-
"for full coverage."
|
|
48
|
-
)
|
|
49
|
-
end
|
|
26
|
+
report_missing_capabilities(report, adapter, missing)
|
|
50
27
|
end
|
|
51
28
|
|
|
52
29
|
def resolve_adapter
|
|
@@ -54,6 +31,37 @@ module Smith
|
|
|
54
31
|
rescue StandardError
|
|
55
32
|
nil
|
|
56
33
|
end
|
|
34
|
+
|
|
35
|
+
def report_missing_adapter(report)
|
|
36
|
+
report.add(
|
|
37
|
+
name: "persistence.capabilities",
|
|
38
|
+
status: :warn,
|
|
39
|
+
message: "No persistence adapter configured",
|
|
40
|
+
detail: "Smith.config.persistence_adapter is nil and Smith.config.test_mode is false. " \
|
|
41
|
+
"Hosts using durable workflows must set persistence_adapter."
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def report_supported_capabilities(report, adapter)
|
|
46
|
+
report.add(
|
|
47
|
+
name: "persistence.capabilities",
|
|
48
|
+
status: :pass,
|
|
49
|
+
message: "#{adapter.class.name} supports all optional persistence capabilities",
|
|
50
|
+
detail: "Supported: #{OPTIONAL_CAPABILITIES.join(", ")}"
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def report_missing_capabilities(report, adapter, missing)
|
|
55
|
+
report.add(
|
|
56
|
+
name: "persistence.capabilities",
|
|
57
|
+
status: :warn,
|
|
58
|
+
message: "#{adapter.class.name} missing optional capabilities: #{missing.join(", ")}",
|
|
59
|
+
detail: "Smith will fall back where possible: non-versioned writes when store_versioned " \
|
|
60
|
+
"is missing, and payload updated_at parsing when heartbeat methods are missing. " \
|
|
61
|
+
"Use RedisStore or Memory for full versioning and heartbeat coverage; " \
|
|
62
|
+
"ActiveRecordStore currently covers optimistic locking when lock_version is present."
|
|
63
|
+
)
|
|
64
|
+
end
|
|
57
65
|
end
|
|
58
66
|
end
|
|
59
67
|
end
|
data/lib/smith/version.rb
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# rubocop:disable Style/RedundantStructKeywordInit
|
|
6
|
+
AgentResult = Struct.new(
|
|
7
|
+
:content, :input_tokens, :output_tokens, :cost, :model_used,
|
|
8
|
+
keyword_init: true
|
|
9
|
+
) do
|
|
10
|
+
def self.from_response(response, content, model_used: nil)
|
|
11
|
+
new(
|
|
12
|
+
content: content,
|
|
13
|
+
input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
|
|
14
|
+
output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : nil,
|
|
15
|
+
cost: nil,
|
|
16
|
+
model_used: model_used
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def usage_known?
|
|
21
|
+
!input_tokens.nil? && !output_tokens.nil?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
# rubocop:enable Style/RedundantStructKeywordInit
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
# rubocop:disable Style/RedundantStructKeywordInit
|
|
6
|
+
BranchEnv = Struct.new(
|
|
7
|
+
:prepared_input, :guardrail_sources, :scoped_store, :branch_estimates, :deadline,
|
|
8
|
+
keyword_init: true
|
|
9
|
+
) do
|
|
10
|
+
def setup_thread
|
|
11
|
+
Smith::Tool.current_guardrails = guardrail_sources
|
|
12
|
+
Smith::Tool.current_deadline = deadline
|
|
13
|
+
Smith.scoped_artifacts = scoped_store
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def teardown_thread
|
|
17
|
+
Smith::Tool.current_guardrails = nil
|
|
18
|
+
Smith::Tool.current_deadline = nil
|
|
19
|
+
Smith.scoped_artifacts = nil
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
# rubocop:enable Style/RedundantStructKeywordInit
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -4,14 +4,17 @@ module Smith
|
|
|
4
4
|
class Workflow
|
|
5
5
|
class DeterministicStep
|
|
6
6
|
attr_reader :context, :tool_results, :session_messages, :current_state, :transition_name,
|
|
7
|
-
:context_writes, :routed_to, :outcome
|
|
7
|
+
:context_writes, :routed_to, :outcome, :allowed_routes
|
|
8
8
|
|
|
9
|
-
def initialize(context:, session_messages:, tool_results:, state:,
|
|
9
|
+
def initialize(context:, session_messages:, tool_results:, state:, **options)
|
|
10
|
+
validate_options!(options)
|
|
11
|
+
transition = options[:transition]
|
|
10
12
|
@context = context
|
|
11
13
|
@session_messages = session_messages
|
|
12
14
|
@tool_results = tool_results
|
|
13
15
|
@current_state = state
|
|
14
|
-
@transition_name = transition_name
|
|
16
|
+
@transition_name = transition ? transition.name : options.fetch(:transition_name)
|
|
17
|
+
@allowed_routes = snapshot_allowed_routes(transition ? transition.deterministic_routes : options[:allowed_routes])
|
|
15
18
|
@context_writes = {}
|
|
16
19
|
@routed_to = nil
|
|
17
20
|
@outcome = nil
|
|
@@ -39,7 +42,7 @@ module Smith
|
|
|
39
42
|
def route_to(transition_name)
|
|
40
43
|
raise WorkflowError, "route_to already called with :#{@routed_to}" if @routed_to
|
|
41
44
|
|
|
42
|
-
@routed_to = transition_name
|
|
45
|
+
@routed_to = route_target_for(transition_name)
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
def write_outcome(kind:, payload:)
|
|
@@ -52,6 +55,31 @@ module Smith
|
|
|
52
55
|
def fail!(message, retryable: nil, kind: nil, details: nil)
|
|
53
56
|
raise DeterministicStepFailure.new(message, retryable: retryable, kind: kind, details: details)
|
|
54
57
|
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def validate_options!(options)
|
|
62
|
+
unknown = options.keys - %i[transition transition_name allowed_routes]
|
|
63
|
+
raise ArgumentError, "unknown keywords: #{unknown.join(", ")}" if unknown.any?
|
|
64
|
+
return if options[:transition] || options.key?(:transition_name)
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, "missing keyword: :transition"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def snapshot_allowed_routes(routes)
|
|
70
|
+
return nil if routes.nil?
|
|
71
|
+
|
|
72
|
+
routes.map { |route| route.is_a?(String) ? route.dup.freeze : route }.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def route_target_for(transition_name)
|
|
76
|
+
return transition_name if allowed_routes.nil?
|
|
77
|
+
|
|
78
|
+
allowed_route = allowed_routes.find { |route| route == transition_name }
|
|
79
|
+
return allowed_route if allowed_route
|
|
80
|
+
|
|
81
|
+
raise WorkflowError, "route_to #{transition_name.inspect} is not declared in deterministic routes"
|
|
82
|
+
end
|
|
55
83
|
end
|
|
56
84
|
end
|
|
57
85
|
end
|
|
@@ -32,11 +32,9 @@ module Smith
|
|
|
32
32
|
# `store/fetch/delete`, and this peek piggybacks on `fetch`.
|
|
33
33
|
# Custom adapters work without changes.
|
|
34
34
|
#
|
|
35
|
-
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
# in-flight workflow is being resumed — either way, no NEW
|
|
39
|
-
# credit authorization is needed).
|
|
35
|
+
# Hosts can use this to distinguish a resumed workflow from a
|
|
36
|
+
# brand-new workflow when coordinating external accounting or
|
|
37
|
+
# admission-control checks.
|
|
40
38
|
def persisted_state_exists?(key: nil, context: {}, adapter: Smith.persistence_adapter)
|
|
41
39
|
resolved_key = resolved_persistence_key(key:, context:)
|
|
42
40
|
!fetch_persisted_payload(resolved_key, adapter:).nil?
|
|
@@ -51,11 +49,9 @@ module Smith
|
|
|
51
49
|
# the top of `run_persisted!` BEFORE the first `advance!`. A
|
|
52
50
|
# worker that dies between that initial `persist!` and the
|
|
53
51
|
# first model call leaves a Redis key with no billable work.
|
|
54
|
-
# If
|
|
55
|
-
# alone, a
|
|
56
|
-
# state
|
|
57
|
-
# skipped because state exists, but the state has nothing to
|
|
58
|
-
# bill — it's just the workflow's starting state).
|
|
52
|
+
# If external accounting or admission-control checks key on
|
|
53
|
+
# `persisted_state_exists?` alone, a retry on that abandoned
|
|
54
|
+
# init state can be mistaken for a billable/resumable workflow.
|
|
59
55
|
#
|
|
60
56
|
# `restorable_billing_state?` returns true only when there's
|
|
61
57
|
# actual `usage_entries` to bill on idempotent replay. Terminal
|
|
@@ -167,11 +163,22 @@ module Smith
|
|
|
167
163
|
return false unless state_name && class_name
|
|
168
164
|
|
|
169
165
|
klass = Object.const_get(class_name)
|
|
170
|
-
klass
|
|
166
|
+
state = state_name_for_payload(klass, state_name)
|
|
167
|
+
klass.transitions_from(state).empty? && next_transition.nil?
|
|
171
168
|
rescue JSON::ParserError, NameError, NoMethodError
|
|
172
169
|
false
|
|
173
170
|
end
|
|
174
171
|
|
|
172
|
+
def state_name_for_payload(klass, state_name)
|
|
173
|
+
states = klass.instance_variable_get(:@states) || []
|
|
174
|
+
return state_name if states.include?(state_name)
|
|
175
|
+
|
|
176
|
+
symbolized = state_name.to_sym if state_name.respond_to?(:to_sym)
|
|
177
|
+
return symbolized if states.include?(symbolized)
|
|
178
|
+
|
|
179
|
+
state_name
|
|
180
|
+
end
|
|
181
|
+
|
|
175
182
|
def persistence_adapter!(adapter)
|
|
176
183
|
return adapter if adapter
|
|
177
184
|
|
|
@@ -241,12 +248,19 @@ module Smith
|
|
|
241
248
|
persist!(resolved_key, adapter:)
|
|
242
249
|
|
|
243
250
|
until terminal?
|
|
251
|
+
ensure_transition_budget!
|
|
252
|
+
|
|
244
253
|
if strict_idempotency?
|
|
245
254
|
mark_step_in_progress!
|
|
246
255
|
persist!(resolved_key, adapter:)
|
|
247
256
|
end
|
|
248
257
|
|
|
249
|
-
|
|
258
|
+
begin
|
|
259
|
+
step = advance!
|
|
260
|
+
rescue StandardError
|
|
261
|
+
clear_pre_step_marker!(resolved_key, adapter:)
|
|
262
|
+
raise
|
|
263
|
+
end
|
|
250
264
|
steps << step if step
|
|
251
265
|
|
|
252
266
|
clear_step_in_progress!
|
|
@@ -261,6 +275,7 @@ module Smith
|
|
|
261
275
|
return if terminal?
|
|
262
276
|
|
|
263
277
|
resolved_key = resolve_persistence_key!(key)
|
|
278
|
+
ensure_transition_budget!
|
|
264
279
|
mark_step_in_progress! if strict_idempotency?
|
|
265
280
|
persist!(resolved_key, adapter:)
|
|
266
281
|
step = advance!
|
|
@@ -268,6 +283,9 @@ module Smith
|
|
|
268
283
|
persist!(resolved_key, adapter:) if step
|
|
269
284
|
invoke_on_step_callback(step, on_step) if step
|
|
270
285
|
step
|
|
286
|
+
rescue StandardError
|
|
287
|
+
clear_pre_step_marker!(resolved_key, adapter:) if defined?(resolved_key) && resolved_key
|
|
288
|
+
raise
|
|
271
289
|
end
|
|
272
290
|
|
|
273
291
|
def persist!(key = nil, adapter: Smith.persistence_adapter)
|
|
@@ -306,6 +324,14 @@ module Smith
|
|
|
306
324
|
self.class.idempotency_mode == :strict
|
|
307
325
|
end
|
|
308
326
|
|
|
327
|
+
def clear_pre_step_marker!(key, adapter:)
|
|
328
|
+
return unless strict_idempotency?
|
|
329
|
+
return if step_work_started?
|
|
330
|
+
|
|
331
|
+
clear_step_in_progress!
|
|
332
|
+
persist!(key, adapter:)
|
|
333
|
+
end
|
|
334
|
+
|
|
309
335
|
# Forwards the persist payload to the adapter, splatting `ttl:`
|
|
310
336
|
# only when a TTL is resolved. The empty-Hash splat is a no-op so
|
|
311
337
|
# external duck-typed adapters that don't accept a `ttl:` kwarg
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
require_relative "agent_result"
|
|
6
|
+
require_relative "optimization_state"
|
|
7
|
+
|
|
3
8
|
module Smith
|
|
4
9
|
class Workflow
|
|
5
10
|
module EvaluatorOptimizer
|
|
6
|
-
OptimizationState = Struct.new(
|
|
7
|
-
:config, :prepared_input, :candidate, :feedback, :last_score, :generator_class, :evaluator_class
|
|
8
|
-
) do
|
|
9
|
-
def initialize(config, prepared_input)
|
|
10
|
-
super(config, prepared_input, nil, nil, nil, nil, nil)
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
|
|
14
11
|
private
|
|
15
12
|
|
|
16
13
|
def execute_optimization_step(transition, prepared_input: nil)
|
|
@@ -13,9 +13,9 @@ module Smith
|
|
|
13
13
|
|
|
14
14
|
Smith::Events.emit(
|
|
15
15
|
Events::StepCompleted.new(
|
|
16
|
-
transition: transition.name,
|
|
17
|
-
from: transition.from,
|
|
18
|
-
to: transition.to
|
|
16
|
+
transition: transition.name.to_sym,
|
|
17
|
+
from: transition.from&.to_sym,
|
|
18
|
+
to: transition.to.to_sym
|
|
19
19
|
)
|
|
20
20
|
)
|
|
21
21
|
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
class Graph
|
|
6
|
+
module ContractHelpers
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def label_for(value)
|
|
10
|
+
label = if value.respond_to?(:name) && value.name && !value.name.empty?
|
|
11
|
+
value.name
|
|
12
|
+
elsif value.respond_to?(:inspect)
|
|
13
|
+
value.inspect
|
|
14
|
+
else
|
|
15
|
+
value.to_s
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
immutable_value(label)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def idempotency_mode
|
|
22
|
+
return :unknown unless workflow_class.respond_to?(:idempotency_mode)
|
|
23
|
+
|
|
24
|
+
workflow_class.idempotency_mode
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def in_flight_resume
|
|
28
|
+
return :blocked_by_step_in_progress if idempotency_mode == :strict
|
|
29
|
+
return :unknown unless %i[strict lax].include?(idempotency_mode)
|
|
30
|
+
|
|
31
|
+
:reruns_transition
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def immutable_value(value)
|
|
35
|
+
return value if immediate_value?(value)
|
|
36
|
+
|
|
37
|
+
duplicate = value.dup
|
|
38
|
+
return value if duplicate.equal?(value)
|
|
39
|
+
|
|
40
|
+
duplicate.freeze
|
|
41
|
+
rescue TypeError
|
|
42
|
+
value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def immediate_value?(value)
|
|
46
|
+
value.nil? || value.is_a?(Symbol) || value.is_a?(Numeric) ||
|
|
47
|
+
value == true || value == false
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def deep_freeze(value)
|
|
51
|
+
case value
|
|
52
|
+
when Hash
|
|
53
|
+
value.each_value { |nested| deep_freeze(nested) }
|
|
54
|
+
value.freeze
|
|
55
|
+
when Array
|
|
56
|
+
value.each { |nested| deep_freeze(nested) }
|
|
57
|
+
value.freeze
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
class Graph
|
|
6
|
+
class FanoutContract
|
|
7
|
+
attr_reader :transition, :workflow_class
|
|
8
|
+
|
|
9
|
+
def self.from_transition(transition, workflow_class: nil)
|
|
10
|
+
branches = transition.fanout_config&.fetch(:branches, nil)
|
|
11
|
+
return unless branches
|
|
12
|
+
|
|
13
|
+
new(transition, workflow_class: workflow_class).to_h
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(transition, workflow_class: nil)
|
|
17
|
+
@transition = transition
|
|
18
|
+
@workflow_class = workflow_class
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
deep_freeze(
|
|
23
|
+
branch_count: branches.length,
|
|
24
|
+
join_state: immutable_value(transition.to),
|
|
25
|
+
output_shape: :named_branch_results,
|
|
26
|
+
branch_order: :declaration_order,
|
|
27
|
+
join: join_contract,
|
|
28
|
+
output_contract: output_contract,
|
|
29
|
+
resume_contract: resume_contract,
|
|
30
|
+
branches: branch_summaries,
|
|
31
|
+
branch_contracts: branch_contracts
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def branches
|
|
38
|
+
transition.fanout_config.fetch(:branches)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def join_contract
|
|
42
|
+
{
|
|
43
|
+
state: immutable_value(transition.to),
|
|
44
|
+
transition: immutable_value(transition.name)
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def output_contract
|
|
49
|
+
{
|
|
50
|
+
collection: :array,
|
|
51
|
+
item_shape: :named_branch_result,
|
|
52
|
+
ordering: :branch_declaration_order,
|
|
53
|
+
branch_key_field: :branch,
|
|
54
|
+
agent_field: :agent,
|
|
55
|
+
output_field: :output,
|
|
56
|
+
failure: :discard_all_branch_results_on_failure
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resume_contract
|
|
61
|
+
{
|
|
62
|
+
granularity: :transition,
|
|
63
|
+
branch_checkpointing: false,
|
|
64
|
+
idempotency_mode: idempotency_mode,
|
|
65
|
+
in_flight_resume: in_flight_resume
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def branch_summaries
|
|
70
|
+
branches.map do |branch, agent|
|
|
71
|
+
{ branch: immutable_value(branch), agent: immutable_value(agent) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def branch_contracts
|
|
76
|
+
branches.map do |branch, agent|
|
|
77
|
+
branch_value = immutable_value(branch)
|
|
78
|
+
agent_value = immutable_value(agent)
|
|
79
|
+
|
|
80
|
+
{
|
|
81
|
+
branch: branch_value,
|
|
82
|
+
agent: agent_value,
|
|
83
|
+
result_branch_value: branch_value,
|
|
84
|
+
result_shape: {
|
|
85
|
+
branch: branch_value,
|
|
86
|
+
agent: agent_value,
|
|
87
|
+
output: :agent_output
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def immutable_value(value)
|
|
94
|
+
return value if immediate_value?(value)
|
|
95
|
+
|
|
96
|
+
duplicate = value.dup
|
|
97
|
+
return value if duplicate.equal?(value)
|
|
98
|
+
|
|
99
|
+
duplicate.freeze
|
|
100
|
+
rescue TypeError
|
|
101
|
+
# Some host-owned topology values intentionally refuse duplication.
|
|
102
|
+
# Leave them untouched; the graph contract containers are still
|
|
103
|
+
# frozen, and inspection must never mutate workflow-owned values.
|
|
104
|
+
value
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def immediate_value?(value)
|
|
108
|
+
value.nil? || value.is_a?(Symbol) || value.is_a?(Numeric) ||
|
|
109
|
+
value == true || value == false
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def idempotency_mode
|
|
113
|
+
return :unknown unless workflow_class&.respond_to?(:idempotency_mode)
|
|
114
|
+
|
|
115
|
+
workflow_class.idempotency_mode
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def in_flight_resume
|
|
119
|
+
return :blocked_by_step_in_progress if idempotency_mode == :strict
|
|
120
|
+
return :unknown unless %i[strict lax].include?(idempotency_mode)
|
|
121
|
+
|
|
122
|
+
:reruns_transition
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def deep_freeze(value)
|
|
126
|
+
case value
|
|
127
|
+
when Hash
|
|
128
|
+
value.each_value { |nested| deep_freeze(nested) }
|
|
129
|
+
value.freeze
|
|
130
|
+
when Array
|
|
131
|
+
value.each { |nested| deep_freeze(nested) }
|
|
132
|
+
value.freeze
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
value
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|