smith-agents 0.4.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 +7 -0
- data/CHANGELOG.md +139 -0
- data/CODE_OF_CONDUCT.md +128 -0
- data/LICENSE +21 -0
- data/README.md +226 -0
- data/Rakefile +14 -0
- data/UPSTREAM_PROPOSAL.md +141 -0
- data/docs/CONFIGURATION.md +123 -0
- data/docs/PATTERNS.md +492 -0
- data/docs/PERSISTENCE.md +169 -0
- data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
- data/docs/workflow_claim.md +58 -0
- data/exe/smith +7 -0
- data/lib/generators/smith/install/install_generator.rb +22 -0
- data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
- data/lib/smith/agent/lifecycle.rb +264 -0
- data/lib/smith/agent/registry.rb +128 -0
- data/lib/smith/agent.rb +259 -0
- data/lib/smith/artifacts/file.rb +59 -0
- data/lib/smith/artifacts/memory.rb +75 -0
- data/lib/smith/artifacts/scoped_store.rb +29 -0
- data/lib/smith/artifacts.rb +5 -0
- data/lib/smith/budget/ledger.rb +42 -0
- data/lib/smith/budget.rb +5 -0
- data/lib/smith/cli.rb +82 -0
- data/lib/smith/context/observation_masking.rb +19 -0
- data/lib/smith/context/session.rb +42 -0
- data/lib/smith/context/state_injection.rb +24 -0
- data/lib/smith/context.rb +61 -0
- data/lib/smith/doctor/check.rb +12 -0
- data/lib/smith/doctor/checks/baseline.rb +84 -0
- data/lib/smith/doctor/checks/configuration.rb +56 -0
- data/lib/smith/doctor/checks/durability.rb +103 -0
- data/lib/smith/doctor/checks/live.rb +55 -0
- data/lib/smith/doctor/checks/models_registry.rb +66 -0
- data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
- data/lib/smith/doctor/checks/persistence.rb +99 -0
- data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
- data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
- data/lib/smith/doctor/checks/rails.rb +39 -0
- data/lib/smith/doctor/checks/serialization.rb +78 -0
- data/lib/smith/doctor/installer.rb +103 -0
- data/lib/smith/doctor/printer.rb +62 -0
- data/lib/smith/doctor/report.rb +39 -0
- data/lib/smith/doctor.rb +53 -0
- data/lib/smith/errors.rb +191 -0
- data/lib/smith/event.rb +11 -0
- data/lib/smith/events/.keep +0 -0
- data/lib/smith/events/bus.rb +60 -0
- data/lib/smith/events/step_completed.rb +11 -0
- data/lib/smith/events/subscription.rb +24 -0
- data/lib/smith/events.rb +5 -0
- data/lib/smith/guardrails/runner.rb +44 -0
- data/lib/smith/guardrails/url_verifier.rb +7 -0
- data/lib/smith/guardrails.rb +35 -0
- data/lib/smith/models/inference.rb +199 -0
- data/lib/smith/models/normalizer.rb +186 -0
- data/lib/smith/models/profile.rb +39 -0
- data/lib/smith/models.rb +132 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
- data/lib/smith/persistence_adapters/cache_store.rb +79 -0
- data/lib/smith/persistence_adapters/memory.rb +105 -0
- data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
- data/lib/smith/persistence_adapters/redis_store.rb +136 -0
- data/lib/smith/persistence_adapters/retry.rb +42 -0
- data/lib/smith/persistence_adapters.rb +112 -0
- data/lib/smith/pricing.rb +65 -0
- data/lib/smith/providers/openai/responses.rb +315 -0
- data/lib/smith/providers/openai/routing.rb +67 -0
- data/lib/smith/providers/openai/tools_extensions.rb +106 -0
- data/lib/smith/railtie.rb +9 -0
- data/lib/smith/tasks/doctor.rake +38 -0
- data/lib/smith/tool/budget_enforcement.rb +33 -0
- data/lib/smith/tool/capability_builder.rb +18 -0
- data/lib/smith/tool/capture.rb +22 -0
- data/lib/smith/tool/compatibility.rb +72 -0
- data/lib/smith/tool/policy.rb +40 -0
- data/lib/smith/tool.rb +171 -0
- data/lib/smith/tools/think.rb +25 -0
- data/lib/smith/tools/url_fetcher.rb +16 -0
- data/lib/smith/tools/web_search.rb +17 -0
- data/lib/smith/tools.rb +5 -0
- data/lib/smith/trace/logger.rb +46 -0
- data/lib/smith/trace/memory.rb +53 -0
- data/lib/smith/trace/open_telemetry.rb +57 -0
- data/lib/smith/trace.rb +89 -0
- data/lib/smith/types.rb +16 -0
- data/lib/smith/version.rb +5 -0
- data/lib/smith/workflow/artifact_integration.rb +41 -0
- data/lib/smith/workflow/budget_integration.rb +105 -0
- data/lib/smith/workflow/claim.rb +118 -0
- data/lib/smith/workflow/data_volume_policy.rb +36 -0
- data/lib/smith/workflow/deadline_enforcement.rb +100 -0
- data/lib/smith/workflow/deterministic_execution.rb +53 -0
- data/lib/smith/workflow/deterministic_step.rb +57 -0
- data/lib/smith/workflow/dsl.rb +223 -0
- data/lib/smith/workflow/durability.rb +369 -0
- data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
- data/lib/smith/workflow/event_integration.rb +24 -0
- data/lib/smith/workflow/execution.rb +127 -0
- data/lib/smith/workflow/execution_frame.rb +166 -0
- data/lib/smith/workflow/guardrail_integration.rb +40 -0
- data/lib/smith/workflow/nested_execution.rb +69 -0
- data/lib/smith/workflow/orchestrator_worker.rb +145 -0
- data/lib/smith/workflow/parallel.rb +50 -0
- data/lib/smith/workflow/parallel_execution.rb +75 -0
- data/lib/smith/workflow/persistence.rb +358 -0
- data/lib/smith/workflow/pipeline.rb +117 -0
- data/lib/smith/workflow/router.rb +53 -0
- data/lib/smith/workflow/transition.rb +208 -0
- data/lib/smith/workflow.rb +555 -0
- data/lib/smith.rb +254 -0
- data/script/profile_tool_results.rb +94 -0
- data/sig/smith.rbs +4 -0
- metadata +258 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
class Workflow
|
|
5
|
+
module DSL
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.extend(ClassMethods)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
def inherited(subclass)
|
|
12
|
+
super
|
|
13
|
+
subclass.instance_variable_set(:@states, (@states || []).dup)
|
|
14
|
+
subclass.instance_variable_set(:@transitions, (@transitions || {}).dup)
|
|
15
|
+
subclass.instance_variable_set(:@initial_state_name, @initial_state_name)
|
|
16
|
+
subclass.instance_variable_set(:@budget_config, @budget_config&.dup)
|
|
17
|
+
subclass.instance_variable_set(:@max_transitions_count, @max_transitions_count)
|
|
18
|
+
subclass.instance_variable_set(:@guardrails_class, @guardrails_class)
|
|
19
|
+
subclass.instance_variable_set(:@context_manager_class, @context_manager_class)
|
|
20
|
+
subclass.instance_variable_set(:@seed_messages_builder, @seed_messages_builder)
|
|
21
|
+
subclass.instance_variable_set(:@persistence_key_builder, @persistence_key_builder)
|
|
22
|
+
subclass.instance_variable_set(:@persistence_schema_version, @persistence_schema_version)
|
|
23
|
+
subclass.instance_variable_set(:@migrations, (@migrations || {}).dup)
|
|
24
|
+
subclass.instance_variable_set(:@seed_validation_mode, @seed_validation_mode)
|
|
25
|
+
subclass.instance_variable_set(:@idempotency_mode, @idempotency_mode)
|
|
26
|
+
subclass.instance_variable_set(:@persistence_ttl, @persistence_ttl)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initial_state(name = nil)
|
|
30
|
+
return @initial_state_name if name.nil?
|
|
31
|
+
|
|
32
|
+
@initial_state_name = name
|
|
33
|
+
state(name)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def state(name)
|
|
37
|
+
@states ||= []
|
|
38
|
+
@states << name unless @states.include?(name)
|
|
39
|
+
generate_fail_transition if name == :failed
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def transition(name, from:, to:, &)
|
|
43
|
+
@transitions ||= {}
|
|
44
|
+
@transitions[name] = Transition.new(name, from: from, to: to, &)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def budget(**opts)
|
|
48
|
+
return @budget_config if opts.empty?
|
|
49
|
+
|
|
50
|
+
@budget_config = opts
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def max_transitions(count = nil)
|
|
54
|
+
return @max_transitions_count if count.nil?
|
|
55
|
+
|
|
56
|
+
@max_transitions_count = count
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def guardrails(klass = nil)
|
|
60
|
+
return @guardrails_class if klass.nil?
|
|
61
|
+
|
|
62
|
+
@guardrails_class = klass
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def context_manager(klass = nil)
|
|
66
|
+
return @context_manager_class if klass.nil?
|
|
67
|
+
|
|
68
|
+
@context_manager_class = klass
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def seed_messages(&block)
|
|
72
|
+
return @seed_messages_builder unless block_given?
|
|
73
|
+
|
|
74
|
+
@seed_messages_builder = block
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def persistence_key(&block)
|
|
78
|
+
return @persistence_key_builder unless block_given?
|
|
79
|
+
|
|
80
|
+
@persistence_key_builder = block
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Schema version stamped into every persisted payload's
|
|
84
|
+
# :schema_version key. Restore compares the stored version with
|
|
85
|
+
# this value and either passes through (equal), applies
|
|
86
|
+
# registered migrate_from blocks one step at a time (stored
|
|
87
|
+
# less than current), or raises Smith::PersistenceSchemaMismatch
|
|
88
|
+
# (stored greater than current, or unbridged gap).
|
|
89
|
+
# Pre-versioning payloads (no :schema_version key) are treated
|
|
90
|
+
# as v1 for backward compatibility.
|
|
91
|
+
def persistence_schema_version(version = nil)
|
|
92
|
+
return @persistence_schema_version || 1 if version.nil?
|
|
93
|
+
|
|
94
|
+
unless version.is_a?(Integer) && version >= 1
|
|
95
|
+
raise ArgumentError, "persistence_schema_version must be a positive Integer, got #{version.inspect}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
@persistence_schema_version = version
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Register a one-step migration from stored version N to N+1.
|
|
102
|
+
# The block receives the persisted payload Hash (top-level keys
|
|
103
|
+
# already symbolized) and must return the migrated payload.
|
|
104
|
+
# Bumping the :schema_version key is the migration's
|
|
105
|
+
# responsibility but Smith advances defensively if the block
|
|
106
|
+
# omits it, so migrations stay loop-free.
|
|
107
|
+
def migrate_from(version, &block)
|
|
108
|
+
raise ArgumentError, "migrate_from requires a block" unless block
|
|
109
|
+
|
|
110
|
+
unless version.is_a?(Integer) && version >= 1
|
|
111
|
+
raise ArgumentError, "migrate_from version must be a positive Integer, got #{version.inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@migrations ||= {}
|
|
115
|
+
@migrations[version] = block
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def migrations
|
|
119
|
+
@migrations || {}
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Controls whether restore validates that the seed_messages
|
|
123
|
+
# builder still produces the same digest as when this workflow
|
|
124
|
+
# was originally persisted.
|
|
125
|
+
#
|
|
126
|
+
# Modes:
|
|
127
|
+
# :off (default) skip validation entirely. Recommended when
|
|
128
|
+
# the seed builder is non-deterministic (timestamps,
|
|
129
|
+
# UUIDs, request-scoped data) since drift would
|
|
130
|
+
# surface on every restore.
|
|
131
|
+
# :warn log a warning via Smith.config.logger on drift; do
|
|
132
|
+
# not raise. Suitable for soft monitoring.
|
|
133
|
+
# :strict raise Smith::SeedMismatch on drift. Suitable when
|
|
134
|
+
# the seed builder is deterministic (system
|
|
135
|
+
# instructions, static templates) and divergence
|
|
136
|
+
# indicates a code change that would invalidate the
|
|
137
|
+
# persisted conversation context.
|
|
138
|
+
def seed_validation(mode = nil)
|
|
139
|
+
return @seed_validation_mode || :off if mode.nil?
|
|
140
|
+
|
|
141
|
+
unless %i[strict warn off].include?(mode)
|
|
142
|
+
raise ArgumentError, "seed_validation must be :strict, :warn, or :off, got #{mode.inspect}"
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
@seed_validation_mode = mode
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Controls whether run_persisted! / advance_persisted! stamp a
|
|
149
|
+
# step_in_progress marker before each advance and clear it
|
|
150
|
+
# afterward.
|
|
151
|
+
#
|
|
152
|
+
# Modes:
|
|
153
|
+
# :lax (default) no marker stamping; restore never raises.
|
|
154
|
+
# Safe when agent calls and tools are idempotent, so
|
|
155
|
+
# re-running a step on restore is harmless.
|
|
156
|
+
# :strict marker is persisted before each advance and cleared
|
|
157
|
+
# after. Restore raises
|
|
158
|
+
# Smith::StepInProgressOnRestore when the marker is
|
|
159
|
+
# set, indicating a previous worker crashed mid-step
|
|
160
|
+
# and re-running could double-execute non-idempotent
|
|
161
|
+
# agent calls or tools.
|
|
162
|
+
def idempotency_mode(mode = nil)
|
|
163
|
+
return @idempotency_mode || :lax if mode.nil?
|
|
164
|
+
|
|
165
|
+
unless %i[strict lax].include?(mode)
|
|
166
|
+
raise ArgumentError, "idempotency_mode must be :strict or :lax, got #{mode.inspect}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
@idempotency_mode = mode
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Per-workflow TTL override (in seconds). Takes precedence over
|
|
173
|
+
# Smith.config.persistence_ttl at persist! time. nil (default)
|
|
174
|
+
# means inherit the global config.
|
|
175
|
+
#
|
|
176
|
+
# Hosts typically set this when different workflow classes have
|
|
177
|
+
# different durability horizons: e.g., short-lived UI sessions
|
|
178
|
+
# at 1.day.to_i, long-running research workflows at 30.days.to_i.
|
|
179
|
+
#
|
|
180
|
+
# Wiring contract: when the resolved TTL is non-nil,
|
|
181
|
+
# Workflow#persist! forwards it to the adapter as a `ttl:`
|
|
182
|
+
# kwarg. Shipped adapters (Memory, RedisStore, CacheStore,
|
|
183
|
+
# ActiveRecordStore) accept this kwarg; external duck-typed
|
|
184
|
+
# adapters that implement only the bare REQUIRED_METHODS contract
|
|
185
|
+
# without a `ttl:` kwarg will only break when a host actually
|
|
186
|
+
# opts into TTL.
|
|
187
|
+
def persistence_ttl(seconds = nil)
|
|
188
|
+
return @persistence_ttl if seconds.nil?
|
|
189
|
+
|
|
190
|
+
unless seconds.is_a?(Numeric) && seconds.positive?
|
|
191
|
+
raise ArgumentError,
|
|
192
|
+
"persistence_ttl must be a positive Numeric (seconds), got #{seconds.inspect}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
@persistence_ttl = seconds
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def transitions_from(state)
|
|
199
|
+
(@transitions || {}).values.select { |t| t.from == state }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def find_transition(name)
|
|
203
|
+
(@transitions || {})[name]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def from_state(hash)
|
|
207
|
+
workflow = allocate
|
|
208
|
+
workflow.send(:restore_state, hash)
|
|
209
|
+
workflow
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
private
|
|
213
|
+
|
|
214
|
+
def generate_fail_transition
|
|
215
|
+
@transitions ||= {}
|
|
216
|
+
return if @transitions.key?(:fail)
|
|
217
|
+
|
|
218
|
+
@transitions[:fail] = Transition.new(:fail, from: nil, to: :failed)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
module Durability
|
|
8
|
+
def self.included(base)
|
|
9
|
+
base.extend(ClassMethods)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
module ClassMethods
|
|
13
|
+
def restore(key, adapter: Smith.persistence_adapter)
|
|
14
|
+
resolved_key = explicit_persistence_key!(key)
|
|
15
|
+
payload = fetch_persisted_payload(resolved_key, adapter:)
|
|
16
|
+
return nil unless payload
|
|
17
|
+
|
|
18
|
+
from_state(JSON.parse(payload)).tap do |workflow|
|
|
19
|
+
workflow.instance_variable_set(:@persistence_key, resolved_key)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def restore_or_initialize(key: nil, context: {}, adapter: Smith.persistence_adapter, **kwargs)
|
|
24
|
+
restore(resolved_persistence_key(key:, context:), adapter:) || new(context:, **kwargs)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Peek without instantiating: returns true if persisted state
|
|
28
|
+
# exists for the resolved key, false otherwise. Reuses the
|
|
29
|
+
# existing private helpers (`resolved_persistence_key`,
|
|
30
|
+
# `fetch_persisted_payload`) so it doesn't expand the adapter
|
|
31
|
+
# contract — `Smith::PersistenceAdapter` requires only
|
|
32
|
+
# `store/fetch/delete`, and this peek piggybacks on `fetch`.
|
|
33
|
+
# Custom adapters work without changes.
|
|
34
|
+
#
|
|
35
|
+
# Hadithi uses this to skip the credits guard at execution time
|
|
36
|
+
# when persisted state already exists for a workflow key (a
|
|
37
|
+
# prior attempt's billable work is durable in Redis, OR an
|
|
38
|
+
# in-flight workflow is being resumed — either way, no NEW
|
|
39
|
+
# credit authorization is needed).
|
|
40
|
+
def persisted_state_exists?(key: nil, context: {}, adapter: Smith.persistence_adapter)
|
|
41
|
+
resolved_key = resolved_persistence_key(key:, context:)
|
|
42
|
+
!fetch_persisted_payload(resolved_key, adapter:).nil?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Stricter peek: returns true only when persisted state contains
|
|
46
|
+
# billable work that needs to be preserved (at least one
|
|
47
|
+
# `usage_entries` entry).
|
|
48
|
+
#
|
|
49
|
+
# `persisted_state_exists?` answers "is there any state?" — but
|
|
50
|
+
# that includes the bare initial-state record Smith writes at
|
|
51
|
+
# the top of `run_persisted!` BEFORE the first `advance!`. A
|
|
52
|
+
# worker that dies between that initial `persist!` and the
|
|
53
|
+
# first model call leaves a Redis key with no billable work.
|
|
54
|
+
# If the credits guard's bypass keys on `persisted_state_exists?`
|
|
55
|
+
# alone, a zero-balance user's retry on that abandoned init
|
|
56
|
+
# state silently runs the first model call (the guard is
|
|
57
|
+
# skipped because state exists, but the state has nothing to
|
|
58
|
+
# bill — it's just the workflow's starting state).
|
|
59
|
+
#
|
|
60
|
+
# `restorable_billing_state?` returns true only when there's
|
|
61
|
+
# actual `usage_entries` to bill on idempotent replay. Terminal
|
|
62
|
+
# state with zero entries is also `false` because there's
|
|
63
|
+
# nothing to preserve — `run_persisted!` is a no-op on
|
|
64
|
+
# terminal anyway, so guard outcome doesn't matter for
|
|
65
|
+
# correctness in that case.
|
|
66
|
+
#
|
|
67
|
+
# This calls `restore` (full deserialize) rather than just
|
|
68
|
+
# `fetch`, so it's heavier than `persisted_state_exists?`. Use
|
|
69
|
+
# this when you specifically want the billing-aware semantics;
|
|
70
|
+
# use `persisted_state_exists?` when you only need a key-
|
|
71
|
+
# presence check.
|
|
72
|
+
def restorable_billing_state?(key: nil, context: {}, adapter: Smith.persistence_adapter)
|
|
73
|
+
resolved_key = resolved_persistence_key(key:, context:)
|
|
74
|
+
payload = fetch_persisted_payload(resolved_key, adapter:)
|
|
75
|
+
return false if payload.nil?
|
|
76
|
+
|
|
77
|
+
workflow = from_state(JSON.parse(payload))
|
|
78
|
+
entries = workflow.instance_variable_get(:@usage_entries) || []
|
|
79
|
+
entries.any?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stuck_for?(persistence_key:, threshold:, since: nil, adapter: Smith.persistence_adapter)
|
|
83
|
+
raise WorkflowError, "persistence_adapter is not configured" if adapter.nil?
|
|
84
|
+
raise ArgumentError, "persistence_key must not be blank" if persistence_key.nil? || (persistence_key.respond_to?(:strip) && persistence_key.strip.empty?)
|
|
85
|
+
raise ArgumentError, "threshold must respond to :to_i" unless threshold.respond_to?(:to_i)
|
|
86
|
+
|
|
87
|
+
if since && !since.respond_to?(:to_time)
|
|
88
|
+
raise ArgumentError, "since must respond to :to_time or be nil"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
threshold_seconds = threshold.to_i
|
|
92
|
+
now = Time.now.utc
|
|
93
|
+
|
|
94
|
+
if Smith::PersistenceAdapters.supports?(adapter, :last_heartbeat)
|
|
95
|
+
hb = adapter.last_heartbeat(persistence_key)
|
|
96
|
+
if hb
|
|
97
|
+
age = (now - hb.to_time.utc).to_f
|
|
98
|
+
return false if age < threshold_seconds
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
payload = adapter.fetch(persistence_key)
|
|
103
|
+
return stuck_for_no_payload?(since, now, threshold_seconds) if payload.nil?
|
|
104
|
+
|
|
105
|
+
if !Smith::PersistenceAdapters.supports?(adapter, :last_heartbeat)
|
|
106
|
+
Smith::PersistenceAdapters.warn_missing_heartbeat(adapter)
|
|
107
|
+
fallback_age = age_from_payload_updated_at(payload, now)
|
|
108
|
+
return false if fallback_age.nil? || fallback_age < threshold_seconds
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
!terminal_in_payload?(payload)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def heartbeat_age(persistence_key:, adapter: Smith.persistence_adapter)
|
|
115
|
+
raise WorkflowError, "persistence_adapter is not configured" if adapter.nil?
|
|
116
|
+
|
|
117
|
+
if Smith::PersistenceAdapters.supports?(adapter, :last_heartbeat)
|
|
118
|
+
hb = adapter.last_heartbeat(persistence_key)
|
|
119
|
+
return [Time.now.utc - hb.to_time.utc, 0.0].max.to_f if hb
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
payload = adapter.fetch(persistence_key)
|
|
123
|
+
return nil if payload.nil?
|
|
124
|
+
|
|
125
|
+
age_from_payload_updated_at(payload, Time.now.utc)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def run_persisted!(key: nil, context: {}, adapter: Smith.persistence_adapter, clear: :done, on_step: nil, **kwargs)
|
|
129
|
+
clear_policy = normalize_clear_policy(clear)
|
|
130
|
+
resolved_key = resolved_persistence_key(key:, context:)
|
|
131
|
+
workflow = restore(resolved_key, adapter:) || new(context:, **kwargs)
|
|
132
|
+
result = workflow.run_persisted!(resolved_key, adapter:, on_step:)
|
|
133
|
+
|
|
134
|
+
workflow.clear_persisted!(resolved_key, adapter:) if clear_persisted_after_run?(clear_policy, workflow)
|
|
135
|
+
result
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def fetch_persisted_payload(key, adapter:)
|
|
141
|
+
persistence_adapter!(adapter).fetch(key)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def stuck_for_no_payload?(since, now, threshold_seconds)
|
|
145
|
+
return false if since.nil?
|
|
146
|
+
|
|
147
|
+
age = (now - since.to_time.utc).to_f
|
|
148
|
+
age.clamp(0.0, Float::INFINITY) >= threshold_seconds
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def age_from_payload_updated_at(payload, now)
|
|
152
|
+
parsed = JSON.parse(payload)
|
|
153
|
+
updated_at = parsed["updated_at"] || parsed[:updated_at]
|
|
154
|
+
return nil if updated_at.nil?
|
|
155
|
+
|
|
156
|
+
(now - Time.parse(updated_at.to_s).utc).to_f
|
|
157
|
+
rescue JSON::ParserError, ArgumentError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def terminal_in_payload?(payload)
|
|
162
|
+
parsed = JSON.parse(payload)
|
|
163
|
+
state_name = parsed["state"] || parsed[:state]
|
|
164
|
+
class_name = parsed["class"] || parsed[:class]
|
|
165
|
+
next_transition = parsed["next_transition_name"] || parsed[:next_transition_name]
|
|
166
|
+
|
|
167
|
+
return false unless state_name && class_name
|
|
168
|
+
|
|
169
|
+
klass = Object.const_get(class_name)
|
|
170
|
+
klass.transitions_from(state_name.to_sym).empty? && next_transition.nil?
|
|
171
|
+
rescue JSON::ParserError, NameError, NoMethodError
|
|
172
|
+
false
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def persistence_adapter!(adapter)
|
|
176
|
+
return adapter if adapter
|
|
177
|
+
|
|
178
|
+
raise WorkflowError, "persistence_adapter is not configured"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def resolve_persistence_key!(key:, context:)
|
|
182
|
+
return key unless key.nil? || blank_key?(key)
|
|
183
|
+
|
|
184
|
+
builder = persistence_key
|
|
185
|
+
raise WorkflowError, "persistence key is required unless workflow defines persistence_key" unless builder
|
|
186
|
+
|
|
187
|
+
derived = if builder.arity == 1
|
|
188
|
+
builder.call(context)
|
|
189
|
+
else
|
|
190
|
+
builder.call
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
return derived unless blank_key?(derived)
|
|
194
|
+
|
|
195
|
+
raise WorkflowError, "persistence_key must return a non-blank key"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def resolved_persistence_key(key:, context:)
|
|
199
|
+
resolve_persistence_key!(key:, context:)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def explicit_persistence_key!(key)
|
|
203
|
+
return key unless blank_key?(key)
|
|
204
|
+
|
|
205
|
+
raise WorkflowError, "restore requires a non-blank explicit persistence key"
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def blank_key?(value)
|
|
209
|
+
return true if value.nil?
|
|
210
|
+
return true if value.respond_to?(:strip) && value.strip.empty?
|
|
211
|
+
|
|
212
|
+
value.respond_to?(:empty?) ? value.empty? : false
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def normalize_clear_policy(clear)
|
|
216
|
+
case clear
|
|
217
|
+
when false, nil
|
|
218
|
+
false
|
|
219
|
+
when true, :done
|
|
220
|
+
:done
|
|
221
|
+
when :terminal
|
|
222
|
+
:terminal
|
|
223
|
+
else
|
|
224
|
+
raise WorkflowError, "invalid clear policy #{clear.inspect}; expected false, :done, or :terminal"
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def clear_persisted_after_run?(clear_policy, workflow)
|
|
229
|
+
return false if clear_policy == false
|
|
230
|
+
return workflow.done? if clear_policy == :done
|
|
231
|
+
|
|
232
|
+
workflow.terminal?
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def run_persisted!(key = nil, adapter: Smith.persistence_adapter, on_step: nil)
|
|
237
|
+
return build_run_result([]) if terminal?
|
|
238
|
+
|
|
239
|
+
resolved_key = resolve_persistence_key!(key)
|
|
240
|
+
steps = []
|
|
241
|
+
persist!(resolved_key, adapter:)
|
|
242
|
+
|
|
243
|
+
until terminal?
|
|
244
|
+
if strict_idempotency?
|
|
245
|
+
mark_step_in_progress!
|
|
246
|
+
persist!(resolved_key, adapter:)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
step = advance!
|
|
250
|
+
steps << step if step
|
|
251
|
+
|
|
252
|
+
clear_step_in_progress!
|
|
253
|
+
persist!(resolved_key, adapter:)
|
|
254
|
+
invoke_on_step_callback(step, on_step) if step
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
build_run_result(steps)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def advance_persisted!(key = nil, adapter: Smith.persistence_adapter, on_step: nil)
|
|
261
|
+
return if terminal?
|
|
262
|
+
|
|
263
|
+
resolved_key = resolve_persistence_key!(key)
|
|
264
|
+
mark_step_in_progress! if strict_idempotency?
|
|
265
|
+
persist!(resolved_key, adapter:)
|
|
266
|
+
step = advance!
|
|
267
|
+
clear_step_in_progress!
|
|
268
|
+
persist!(resolved_key, adapter:) if step
|
|
269
|
+
invoke_on_step_callback(step, on_step) if step
|
|
270
|
+
step
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def persist!(key = nil, adapter: Smith.persistence_adapter)
|
|
274
|
+
resolved_key = resolve_persistence_key!(key)
|
|
275
|
+
store = persistence_adapter!(adapter)
|
|
276
|
+
previous_version = @persistence_version || 0
|
|
277
|
+
next_version = previous_version + 1
|
|
278
|
+
payload = JSON.generate(to_state.merge(persistence_version: next_version))
|
|
279
|
+
|
|
280
|
+
dispatch_store!(store, resolved_key, payload, previous_version: previous_version)
|
|
281
|
+
|
|
282
|
+
# Increment ONLY after successful store. On PersistenceVersionConflict
|
|
283
|
+
# (raised by store_versioned), @persistence_version stays at the
|
|
284
|
+
# previous value so callers can rescue + restore + retry cleanly.
|
|
285
|
+
@persistence_version = next_version
|
|
286
|
+
self
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def clear_persisted!(key = nil, adapter: Smith.persistence_adapter)
|
|
290
|
+
resolved_key = resolve_persistence_key!(key)
|
|
291
|
+
persistence_adapter!(adapter).delete(resolved_key)
|
|
292
|
+
self
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def mark_step_in_progress!
|
|
296
|
+
@step_in_progress = true
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def clear_step_in_progress!
|
|
300
|
+
@step_in_progress = false
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
private
|
|
304
|
+
|
|
305
|
+
def strict_idempotency?
|
|
306
|
+
self.class.idempotency_mode == :strict
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Forwards the persist payload to the adapter, splatting `ttl:`
|
|
310
|
+
# only when a TTL is resolved. The empty-Hash splat is a no-op so
|
|
311
|
+
# external duck-typed adapters that don't accept a `ttl:` kwarg
|
|
312
|
+
# keep working; they only break if the host opts into TTL.
|
|
313
|
+
def dispatch_store!(store, key, payload, previous_version:)
|
|
314
|
+
kwargs = ttl_kwarg(effective_persistence_ttl)
|
|
315
|
+
|
|
316
|
+
if Smith::PersistenceAdapters.supports?(store, :store_versioned)
|
|
317
|
+
store.store_versioned(key, payload, expected_version: previous_version, **kwargs)
|
|
318
|
+
else
|
|
319
|
+
Smith::PersistenceAdapters.warn_missing_versioning(store)
|
|
320
|
+
store.store(key, payload, **kwargs)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
store.record_heartbeat(key, **kwargs) if Smith::PersistenceAdapters.supports?(store, :record_heartbeat)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Resolves the effective TTL once per persist, with the class-level
|
|
327
|
+
# DSL override taking precedence over Smith.config.persistence_ttl.
|
|
328
|
+
# Returns nil when neither is set, which collapses ttl_kwarg to an
|
|
329
|
+
# empty Hash and preserves the pre-TTL adapter call shape.
|
|
330
|
+
def effective_persistence_ttl
|
|
331
|
+
self.class.persistence_ttl || Smith.config.persistence_ttl
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def ttl_kwarg(ttl)
|
|
335
|
+
ttl ? { ttl: ttl } : {}
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def persistence_adapter!(adapter)
|
|
339
|
+
return adapter if adapter
|
|
340
|
+
|
|
341
|
+
raise WorkflowError, "persistence_adapter is not configured"
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def resolve_persistence_key!(key)
|
|
345
|
+
unless key.nil? || blank_key?(key)
|
|
346
|
+
@persistence_key = key
|
|
347
|
+
return key
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
return @persistence_key unless blank_key?(@persistence_key)
|
|
351
|
+
|
|
352
|
+
@persistence_key = self.class.send(:resolve_persistence_key!, key:, context: @context)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def blank_key?(value)
|
|
356
|
+
return true if value.nil?
|
|
357
|
+
return true if value.respond_to?(:strip) && value.strip.empty?
|
|
358
|
+
|
|
359
|
+
value.respond_to?(:empty?) ? value.empty? : false
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def invoke_on_step_callback(step, callback)
|
|
363
|
+
callback&.call(step)
|
|
364
|
+
rescue StandardError => e
|
|
365
|
+
Smith.config.logger&.error("Smith::Workflow on_step callback error: #{e.message}")
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|