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.
Files changed (115) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +139 -0
  3. data/CODE_OF_CONDUCT.md +128 -0
  4. data/LICENSE +21 -0
  5. data/README.md +226 -0
  6. data/Rakefile +14 -0
  7. data/UPSTREAM_PROPOSAL.md +141 -0
  8. data/docs/CONFIGURATION.md +123 -0
  9. data/docs/PATTERNS.md +492 -0
  10. data/docs/PERSISTENCE.md +169 -0
  11. data/docs/TOOLS_AND_GUARDRAILS.md +140 -0
  12. data/docs/workflow_claim.md +58 -0
  13. data/exe/smith +7 -0
  14. data/lib/generators/smith/install/install_generator.rb +22 -0
  15. data/lib/generators/smith/install/templates/smith.rb.tt +44 -0
  16. data/lib/smith/agent/lifecycle.rb +264 -0
  17. data/lib/smith/agent/registry.rb +128 -0
  18. data/lib/smith/agent.rb +259 -0
  19. data/lib/smith/artifacts/file.rb +59 -0
  20. data/lib/smith/artifacts/memory.rb +75 -0
  21. data/lib/smith/artifacts/scoped_store.rb +29 -0
  22. data/lib/smith/artifacts.rb +5 -0
  23. data/lib/smith/budget/ledger.rb +42 -0
  24. data/lib/smith/budget.rb +5 -0
  25. data/lib/smith/cli.rb +82 -0
  26. data/lib/smith/context/observation_masking.rb +19 -0
  27. data/lib/smith/context/session.rb +42 -0
  28. data/lib/smith/context/state_injection.rb +24 -0
  29. data/lib/smith/context.rb +61 -0
  30. data/lib/smith/doctor/check.rb +12 -0
  31. data/lib/smith/doctor/checks/baseline.rb +84 -0
  32. data/lib/smith/doctor/checks/configuration.rb +56 -0
  33. data/lib/smith/doctor/checks/durability.rb +103 -0
  34. data/lib/smith/doctor/checks/live.rb +55 -0
  35. data/lib/smith/doctor/checks/models_registry.rb +66 -0
  36. data/lib/smith/doctor/checks/openai_api_mode.rb +51 -0
  37. data/lib/smith/doctor/checks/persistence.rb +99 -0
  38. data/lib/smith/doctor/checks/persistence_capabilities.rb +60 -0
  39. data/lib/smith/doctor/checks/persistence_registry.rb +82 -0
  40. data/lib/smith/doctor/checks/rails.rb +39 -0
  41. data/lib/smith/doctor/checks/serialization.rb +78 -0
  42. data/lib/smith/doctor/installer.rb +103 -0
  43. data/lib/smith/doctor/printer.rb +62 -0
  44. data/lib/smith/doctor/report.rb +39 -0
  45. data/lib/smith/doctor.rb +53 -0
  46. data/lib/smith/errors.rb +191 -0
  47. data/lib/smith/event.rb +11 -0
  48. data/lib/smith/events/.keep +0 -0
  49. data/lib/smith/events/bus.rb +60 -0
  50. data/lib/smith/events/step_completed.rb +11 -0
  51. data/lib/smith/events/subscription.rb +24 -0
  52. data/lib/smith/events.rb +5 -0
  53. data/lib/smith/guardrails/runner.rb +44 -0
  54. data/lib/smith/guardrails/url_verifier.rb +7 -0
  55. data/lib/smith/guardrails.rb +35 -0
  56. data/lib/smith/models/inference.rb +199 -0
  57. data/lib/smith/models/normalizer.rb +186 -0
  58. data/lib/smith/models/profile.rb +39 -0
  59. data/lib/smith/models.rb +132 -0
  60. data/lib/smith/persistence_adapters/active_record_store.rb +99 -0
  61. data/lib/smith/persistence_adapters/cache_store.rb +79 -0
  62. data/lib/smith/persistence_adapters/memory.rb +105 -0
  63. data/lib/smith/persistence_adapters/rails_cache.rb +20 -0
  64. data/lib/smith/persistence_adapters/redis_store.rb +136 -0
  65. data/lib/smith/persistence_adapters/retry.rb +42 -0
  66. data/lib/smith/persistence_adapters.rb +112 -0
  67. data/lib/smith/pricing.rb +65 -0
  68. data/lib/smith/providers/openai/responses.rb +315 -0
  69. data/lib/smith/providers/openai/routing.rb +67 -0
  70. data/lib/smith/providers/openai/tools_extensions.rb +106 -0
  71. data/lib/smith/railtie.rb +9 -0
  72. data/lib/smith/tasks/doctor.rake +38 -0
  73. data/lib/smith/tool/budget_enforcement.rb +33 -0
  74. data/lib/smith/tool/capability_builder.rb +18 -0
  75. data/lib/smith/tool/capture.rb +22 -0
  76. data/lib/smith/tool/compatibility.rb +72 -0
  77. data/lib/smith/tool/policy.rb +40 -0
  78. data/lib/smith/tool.rb +171 -0
  79. data/lib/smith/tools/think.rb +25 -0
  80. data/lib/smith/tools/url_fetcher.rb +16 -0
  81. data/lib/smith/tools/web_search.rb +17 -0
  82. data/lib/smith/tools.rb +5 -0
  83. data/lib/smith/trace/logger.rb +46 -0
  84. data/lib/smith/trace/memory.rb +53 -0
  85. data/lib/smith/trace/open_telemetry.rb +57 -0
  86. data/lib/smith/trace.rb +89 -0
  87. data/lib/smith/types.rb +16 -0
  88. data/lib/smith/version.rb +5 -0
  89. data/lib/smith/workflow/artifact_integration.rb +41 -0
  90. data/lib/smith/workflow/budget_integration.rb +105 -0
  91. data/lib/smith/workflow/claim.rb +118 -0
  92. data/lib/smith/workflow/data_volume_policy.rb +36 -0
  93. data/lib/smith/workflow/deadline_enforcement.rb +100 -0
  94. data/lib/smith/workflow/deterministic_execution.rb +53 -0
  95. data/lib/smith/workflow/deterministic_step.rb +57 -0
  96. data/lib/smith/workflow/dsl.rb +223 -0
  97. data/lib/smith/workflow/durability.rb +369 -0
  98. data/lib/smith/workflow/evaluator_optimizer.rb +220 -0
  99. data/lib/smith/workflow/event_integration.rb +24 -0
  100. data/lib/smith/workflow/execution.rb +127 -0
  101. data/lib/smith/workflow/execution_frame.rb +166 -0
  102. data/lib/smith/workflow/guardrail_integration.rb +40 -0
  103. data/lib/smith/workflow/nested_execution.rb +69 -0
  104. data/lib/smith/workflow/orchestrator_worker.rb +145 -0
  105. data/lib/smith/workflow/parallel.rb +50 -0
  106. data/lib/smith/workflow/parallel_execution.rb +75 -0
  107. data/lib/smith/workflow/persistence.rb +358 -0
  108. data/lib/smith/workflow/pipeline.rb +117 -0
  109. data/lib/smith/workflow/router.rb +53 -0
  110. data/lib/smith/workflow/transition.rb +208 -0
  111. data/lib/smith/workflow.rb +555 -0
  112. data/lib/smith.rb +254 -0
  113. data/script/profile_tool_results.rb +94 -0
  114. data/sig/smith.rbs +4 -0
  115. 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