smith-agents 0.12.6 → 0.13.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -0
  3. data/README.md +11 -4
  4. data/docs/AGENTS.md +9 -5
  5. data/docs/ERRORS_AND_RETRIES.md +20 -4
  6. data/docs/PATTERNS.md +5 -3
  7. data/docs/PERSISTENCE.md +41 -9
  8. data/docs/TOOLS_AND_GUARDRAILS.md +86 -6
  9. data/lib/smith/agent/attempt_captures.rb +62 -0
  10. data/lib/smith/agent/lifecycle.rb +7 -3
  11. data/lib/smith/agent/output_integrity.rb +7 -6
  12. data/lib/smith/agent/output_recovery.rb +6 -4
  13. data/lib/smith/agent/provider_attempt.rb +3 -0
  14. data/lib/smith/agent/provider_completion.rb +18 -16
  15. data/lib/smith/agent/provider_failure_handling.rb +5 -3
  16. data/lib/smith/agent/provider_streaming.rb +3 -1
  17. data/lib/smith/errors.rb +6 -3
  18. data/lib/smith/response_memo.rb +2 -1
  19. data/lib/smith/tool/budget_enforcement.rb +1 -1
  20. data/lib/smith/tool/capability_builder.rb +14 -1
  21. data/lib/smith/tool/capability_declaration.rb +45 -0
  22. data/lib/smith/tool/capture_configuration.rb +5 -2
  23. data/lib/smith/tool/declarations.rb +73 -0
  24. data/lib/smith/tool/execution_tracker.rb +13 -2
  25. data/lib/smith/tool.rb +8 -38
  26. data/lib/smith/version.rb +1 -1
  27. data/lib/smith/workflow/budget_pause.rb +8 -1
  28. data/lib/smith/workflow/child_run_execution.rb +2 -1
  29. data/lib/smith/workflow/dsl.rb +123 -0
  30. data/lib/smith/workflow/durability.rb +4 -3
  31. data/lib/smith/workflow/graph/rename_diagnostics.rb +69 -0
  32. data/lib/smith/workflow/graph/validator.rb +1 -0
  33. data/lib/smith/workflow/graph.rb +1 -0
  34. data/lib/smith/workflow/guarded_step_execution.rb +8 -8
  35. data/lib/smith/workflow/nested_execution.rb +3 -3
  36. data/lib/smith/workflow/persistence.rb +69 -1
  37. data/lib/smith/workflow/remembered_calls.rb +14 -2
  38. data/lib/smith/workflow/retry_execution.rb +24 -3
  39. data/lib/smith/workflow/retry_wait.rb +2 -0
  40. data/lib/smith/workflow/step_context.rb +2 -1
  41. data/lib/smith/workflow/wait_execution.rb +2 -1
  42. data/lib/smith/workflow.rb +11 -0
  43. metadata +5 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "provider_failure_handling"
4
+ require_relative "attempt_captures"
4
5
  require_relative "content_bytes"
5
6
  require_relative "content_parts"
6
7
  require_relative "invocation_preparation"
@@ -15,6 +16,7 @@ module Smith
15
16
  class Agent
16
17
  module ProviderCompletion
17
18
  include ProviderFailureHandling
19
+ include AttemptCaptures
18
20
  include InvocationPreparation
19
21
  include ContentParts
20
22
  include ProviderCallTiming
@@ -26,13 +28,14 @@ module Smith
26
28
 
27
29
  # The input's documents and images are read once (ContentBytes), and
28
30
  # its cached parts checked against every Anthropic model in the chain,
29
- # before the first attempt.
30
- def complete_with_provider(agent_class, prepared_input, shape:)
31
+ # before the first attempt. A candidate that fails over to the next
32
+ # joins `set_aside` (AttemptCaptures).
33
+ def complete_with_provider(agent_class, prepared_input, shape:, set_aside: [])
31
34
  chain = build_model_chain(agent_class).tap { check_chain_cache_breakpoints!(_1, prepared_input) }
32
- ContentBytes.with(prepared_input) { attempt_candidates(agent_class, prepared_input, chain, shape) }
35
+ ContentBytes.with(prepared_input) { attempt_candidates(agent_class, prepared_input, chain, shape, set_aside) }
33
36
  end
34
37
 
35
- def attempt_candidates(agent_class, prepared_input, chain, shape)
38
+ def attempt_candidates(agent_class, prepared_input, chain, shape, set_aside)
36
39
  candidates = ProviderCandidateSequence.new(chain)
37
40
  interrupted = nil
38
41
  candidates.each do |model_reference, index|
@@ -43,7 +46,7 @@ module Smith
43
46
 
44
47
  interrupted ||= attempt if Interruptions.find(attempt.error)
45
48
  candidates.suppress(account_failed_provider(attempt, model_reference))
46
- fail_over!(attempt, agent_class, candidates, interrupted)
49
+ fail_over!(attempt, agent_class, candidates, interrupted, set_aside)
47
50
  end
48
51
 
49
52
  # Fail closed: an empty candidate chain (agent without a model)
@@ -53,16 +56,15 @@ module Smith
53
56
  "no executable model candidate for #{agent_class}; declare a model or fallback_models"
54
57
  end
55
58
 
56
- # Returns when another candidate is left to try. A candidate that met
57
- # an interrupting error (an account out of credit) outlasts how a later
58
- # one fails: the step was cut short, not failed, and is taken again
59
- # from its first candidate once the host can do its part.
60
- def fail_over!(attempt, agent_class, candidates, interrupted)
61
- handle_provider_failure!(
62
- attempt.error, attempt.model_reference, agent_class,
63
- fallback_available: candidates.fallback_available?,
64
- attempt_id: attempt.attempt_id
65
- )
59
+ # Returns when another candidate is left to try, the failed attempt's
60
+ # captures set aside. A candidate that met an interrupting error (an
61
+ # account out of credit) outlasts how a later one fails: the step was
62
+ # cut short, not failed, and is taken again from its first candidate
63
+ # once the host can do its part.
64
+ def fail_over!(attempt, agent_class, candidates, interrupted, set_aside)
65
+ handle_provider_failure!(attempt.error, attempt.model_reference, agent_class,
66
+ fallback_available: candidates.fallback_available?, attempt_id: attempt.attempt_id)
67
+ set_aside.concat(attempt.captures)
66
68
  rescue StandardError => e
67
69
  raise if interrupted.nil? || Interruptions.find(e)
68
70
 
@@ -126,7 +128,7 @@ module Smith
126
128
  message_count = chat_message_count(chat)
127
129
  observed_reference = observed_model_reference(chat, fallback: model_reference)
128
130
  timer = ProviderCallTiming::Timer.start
129
- response, stream, mode, call_facts = complete_chat(chat, observed_reference, collected = [], facts)
131
+ response, stream, mode, call_facts = captured_completion(chat, observed_reference, collected = [], facts)
130
132
  timer.stop
131
133
  completion = Completion.from_messages(response:, messages: new_chat_messages(chat, message_count), stream:,
132
134
  mode:)
@@ -23,7 +23,7 @@ module Smith
23
23
 
24
24
  def handle_provider_failure!(error, model_reference, agent_class, fallback_available:, attempt_id: nil)
25
25
  account_failed_attempt(error, model_reference, agent_class, attempt_id:)
26
- if completed_tool_calls?
26
+ if uncertain_tool_calls?
27
27
  raise Smith::ToolOutcomeUncertain.new(
28
28
  "provider failed after tool execution began; retry or fallback could replay an uncertain outcome"
29
29
  ), cause: error
@@ -44,8 +44,10 @@ module Smith
44
44
  PROVIDER_ACCOUNT_ERRORS.any? { |error_class| error.is_a?(error_class) }
45
45
  end
46
46
 
47
- def completed_tool_calls?
48
- Tool.current_tool_execution_tracker&.started?
47
+ # A tool that is not replay-safe has run in the step: a fallback would
48
+ # run it again.
49
+ def uncertain_tool_calls?
50
+ Tool.current_tool_execution_tracker&.uncertain?
49
51
  end
50
52
 
51
53
  def fallback_eligible?(error)
@@ -72,7 +72,9 @@ module Smith
72
72
 
73
73
  def paused_loop(error, chat, start)
74
74
  continuation = Budget::Continuation.dump(@budget_continuation, chat.messages.drop(start || 0))
75
- BudgetUnavailable.new(error.message, continuation:) if continuation && storable_pause?(continuation)
75
+ return unless continuation && storable_pause?(continuation)
76
+
77
+ BudgetUnavailable.new(error.message, continuation:, paused_by: self)
76
78
  end
77
79
 
78
80
  # Whether the run's state, with the paused loop kept, stays within
data/lib/smith/errors.rb CHANGED
@@ -74,12 +74,15 @@ module Smith
74
74
  # hold; nothing was sent. A BudgetExceeded, so it is not retried unless a
75
75
  # step's `retry_on` names it (a durable retry waits for budget). One that
76
76
  # paused a tool loop carries its `continuation` (Budget::Continuation),
77
- # which the step's retry continues from.
77
+ # which the step's retry continues from, and the run whose step paused
78
+ # (`paused_by`): only that run's retry continues it, never a parent's,
79
+ # whose retry would start a nested workflow over.
78
80
  class BudgetUnavailable < BudgetExceeded
79
- attr_reader :continuation
81
+ attr_reader :continuation, :paused_by
80
82
 
81
- def initialize(message = "the budget ledger refused the request", continuation: nil)
83
+ def initialize(message = "the budget ledger refused the request", continuation: nil, paused_by: nil)
82
84
  @continuation = continuation
85
+ @paused_by = paused_by
83
86
  super(message)
84
87
  end
85
88
  end
@@ -31,7 +31,8 @@ module Smith
31
31
  # whose tools had not run answers the call's first request without
32
32
  # sending it, and its tools run again, under the same keys; kept tool
33
33
  # results count as tools the step ran (charged to its tool-call
34
- # allowances, and never run again by a retry or fallback). A call whose
34
+ # allowances, and never run again by a retry or fallback unless each is a
35
+ # replay-safe tool's). A call whose
35
36
  # messages cannot be kept (an attachment, or too many bytes) is not
36
37
  # stored, and is logged. A record Smith does not keep (its shape, or tool
37
38
  # results that do not answer its replies' calls) is ignored, and an error
@@ -31,7 +31,7 @@ module Smith
31
31
  end
32
32
 
33
33
  def mark_tool_execution_started!
34
- self.class.current_tool_execution_tracker&.mark_started!
34
+ self.class.current_tool_execution_tracker&.mark_started!(replay_safe: self.class.replay_safe?)
35
35
  self.class.__send__(:current_tool_dispatch_start_handler)&.call(self)
36
36
  end
37
37
  end
@@ -3,6 +3,9 @@
3
3
  module Smith
4
4
  class Tool < RubyLLM::Tool
5
5
  class CapabilityBuilder
6
+ REPLAY = %i[safe unsafe].freeze
7
+ private_constant :REPLAY
8
+
6
9
  def initialize
7
10
  @capabilities = {}
8
11
  end
@@ -12,7 +15,17 @@ module Smith
12
15
  def network(value) = @capabilities[:network] = value
13
16
  def approval(value) = @capabilities[:approval] = value
14
17
  def data_volume(value) = @capabilities[:data_volume] = value
15
- def to_h = @capabilities
18
+
19
+ # :safe when running the tool again under the same keys does what
20
+ # running it once did (a lookup), so a step whose tools are all safe
21
+ # keeps its retries and fallbacks; :unsafe, the default, otherwise.
22
+ def replay(value)
23
+ raise ArgumentError, "replay is :safe or :unsafe" unless REPLAY.include?(value)
24
+
25
+ @capabilities[:replay] = value
26
+ end
27
+
28
+ def to_h = @capabilities
16
29
  end
17
30
  end
18
31
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ # What a tool class declares about itself (`capabilities do ... end`,
6
+ # CapabilityBuilder), read for its privilege check, its traces'
7
+ # sensitivity and whether a step may run it again. A subclass inherits
8
+ # its parent's, key by key: its own block sets or replaces only the keys
9
+ # it names, so a subclass is never less guarded than its parent unless
10
+ # it says so. `replay` alone is not inherited: whether a tool is safe to
11
+ # run again is a fact about its own `perform`, so a subclass is
12
+ # replay-safe only when it declares `replay :safe` itself.
13
+ module CapabilityDeclaration
14
+ NOT_INHERITED = %i[replay].freeze
15
+ private_constant :NOT_INHERITED
16
+
17
+ def capabilities(&)
18
+ return inherited_capabilities unless block_given?
19
+
20
+ builder = CapabilityBuilder.new
21
+ builder.instance_eval(&)
22
+ @capabilities = builder.to_h.freeze
23
+ end
24
+
25
+ # Whether this class itself declares `capabilities { replay :safe }`.
26
+ def replay_safe? = capabilities&.dig(:replay) == :safe
27
+
28
+ private
29
+
30
+ def inherited_capabilities
31
+ inherited = parent_capabilities
32
+ own = @capabilities if instance_variable_defined?(:@capabilities)
33
+ return own unless inherited
34
+
35
+ own ? inherited.merge(own) : inherited
36
+ end
37
+
38
+ # The parent's capabilities this class takes (all but `replay`), or nil.
39
+ def parent_capabilities
40
+ inherited = parent_tool&.capabilities&.except(*NOT_INHERITED)
41
+ inherited if inherited&.any?
42
+ end
43
+ end
44
+ end
45
+ end
@@ -2,9 +2,12 @@
2
2
 
3
3
  module Smith
4
4
  class Tool < RubyLLM::Tool
5
+ # A tool class's capture, inherited (Declarations) with its strictness: a
6
+ # subclass's own `capture_result` replaces its parent's, block and
7
+ # strictness together.
5
8
  module CaptureConfiguration
6
9
  def capture_result(strict: false, &block)
7
- return @capture_result unless block
10
+ return declared(:@capture_result) unless block
8
11
 
9
12
  raise ArgumentError, "capture_result strict must be true or false" unless [true, false].include?(strict)
10
13
 
@@ -13,7 +16,7 @@ module Smith
13
16
  end
14
17
 
15
18
  def capture_result_strict?
16
- @capture_result_strict == true
19
+ declaring_tool(:@capture_result)&.instance_variable_get(:@capture_result_strict) == true
17
20
  end
18
21
  end
19
22
  end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Smith
4
+ class Tool < RubyLLM::Tool
5
+ # A tool class's declarations, which its subclasses inherit: each reads
6
+ # the nearest class up the chain that declared it, so a subclass
7
+ # overrides one by declaring it again. Parameters add up by name (a
8
+ # subclass's `param` adds one or replaces its parent's of that name), up
9
+ # to a class that gave a schema with `params`, which replaced the ones
10
+ # above it; that schema gives way to a subclass's own `param`s.
11
+ # Capabilities (CapabilityDeclaration) merge key by key, and a capture
12
+ # (CaptureConfiguration) comes with its strictness. A tool none of whose
13
+ # classes declares `authorize` or `before_execute` runs without one, as
14
+ # before. `O(D)` for a chain `D` classes deep.
15
+ module Declarations
16
+ def description(text = nil)
17
+ return declared(:@description) unless text
18
+
19
+ @description = text
20
+ end
21
+ alias desc description
22
+
23
+ def param(name, **) = own_parameters[name] = RubyLLM::Parameter.new(name, **)
24
+
25
+ def parameters
26
+ parent = parent_tool
27
+ inherited = parent.parameters if parent && !parent.instance_variable_defined?(:@params_schema_definition)
28
+ inherited.nil? || inherited.empty? ? own_parameters : inherited.merge(own_parameters)
29
+ end
30
+
31
+ def params_schema_definition
32
+ return @params_schema_definition if instance_variable_defined?(:@params_schema_definition)
33
+
34
+ parent_tool&.params_schema_definition if own_parameters.empty?
35
+ end
36
+
37
+ def provider_params = declared(:@provider_params) || {}
38
+
39
+ def category(value = nil)
40
+ return declared(:@category) if value.nil?
41
+
42
+ @category = value
43
+ end
44
+
45
+ def authorize(&block)
46
+ return declared(:@authorize) unless block
47
+
48
+ @authorize = block
49
+ end
50
+
51
+ def before_execute(&block)
52
+ return declared(:@before_execute) unless block
53
+
54
+ @before_execute = block
55
+ end
56
+
57
+ private
58
+
59
+ def own_parameters = (@own_parameters ||= {})
60
+
61
+ def parent_tool = (superclass if superclass <= Tool)
62
+
63
+ # The nearest class from this one up that set `ivar`, or nil.
64
+ def declaring_tool(ivar)
65
+ tool = self
66
+ tool = tool.__send__(:parent_tool) while tool && !tool.instance_variable_defined?(ivar)
67
+ tool
68
+ end
69
+
70
+ def declared(ivar) = declaring_tool(ivar)&.instance_variable_get(ivar)
71
+ end
72
+ end
73
+ end
@@ -2,19 +2,30 @@
2
2
 
3
3
  module Smith
4
4
  class Tool < RubyLLM::Tool
5
+ # What the tools of one step attempt did: whether any began, and whether
6
+ # one that is not replay-safe did, which leaves the step's outcome
7
+ # uncertain (nothing may run its tools again).
5
8
  class ExecutionTracker
6
9
  def initialize
7
10
  @started = false
11
+ @uncertain = false
8
12
  @mutex = Mutex.new
9
13
  end
10
14
 
11
- def mark_started!
12
- @mutex.synchronize { @started = true }
15
+ def mark_started!(replay_safe: false)
16
+ @mutex.synchronize do
17
+ @started = true
18
+ @uncertain = true unless replay_safe
19
+ end
13
20
  end
14
21
 
15
22
  def started?
16
23
  @mutex.synchronize { @started }
17
24
  end
25
+
26
+ def uncertain?
27
+ @mutex.synchronize { @uncertain }
28
+ end
18
29
  end
19
30
  end
20
31
  end
data/lib/smith/tool.rb CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  require "ruby_llm"
4
4
 
5
+ require_relative "tool/declarations"
5
6
  require_relative "tool/capability_builder"
7
+ require_relative "tool/capability_declaration"
6
8
  require_relative "tool/policy"
7
9
  require_relative "tool/call_budget"
8
10
  require_relative "tool/call_batch"
@@ -61,22 +63,14 @@ module Smith
61
63
  include ExecutionLifecycle
62
64
  include BudgetEnforcement
63
65
  include Capture
66
+ extend Declarations
67
+ extend CapabilityDeclaration
64
68
  extend CaptureConfiguration
65
69
  extend ScopedContext
66
70
 
67
71
  private_constant :ExecutionAuthority
68
72
 
69
73
  class << self
70
- # Tool subclasses inherit the parent's compatible_with spec by
71
- # reference (the spec is a frozen Hash; immutability makes shared
72
- # references safe). Subclasses can override by calling
73
- # `compatible_with` again — assigns a NEW frozen Hash to its own
74
- # @compatible_with_spec, leaving the parent untouched.
75
- def inherited(subclass)
76
- super
77
- subclass.instance_variable_set(:@compatible_with_spec, @compatible_with_spec)
78
- end
79
-
80
74
  # Declarative compatibility DSL. Examples:
81
75
  # compatible_with :anthropic, :gemini
82
76
  # compatible_with :anthropic, :gemini, openai: :responses
@@ -85,38 +79,14 @@ module Smith
85
79
  # Tools that NEVER declare compatible_with are universally compatible.
86
80
  # Consumed by Smith::Models::Normalizer.drop_incompatible_tools when
87
81
  # the resolved model rejects the (tools + thinking) combo and no
88
- # routing fallback (e.g., openai_api_mode :auto) is available.
82
+ # routing fallback (e.g., openai_api_mode :auto) is available. The
83
+ # spec is a frozen Hash, read like the other declarations: a subclass
84
+ # takes the nearest one up its chain (Declarations).
89
85
  def compatible_with(*providers, except: nil, **provider_endpoints)
90
86
  @compatible_with_spec = Compatibility.parse(providers, except: except, **provider_endpoints)
91
87
  end
92
88
 
93
- attr_reader :compatible_with_spec
94
-
95
- def category(value = nil)
96
- return @category if value.nil?
97
-
98
- @category = value
99
- end
100
-
101
- def capabilities(&)
102
- return @capabilities unless block_given?
103
-
104
- builder = CapabilityBuilder.new
105
- builder.instance_eval(&)
106
- @capabilities = builder.to_h
107
- end
108
-
109
- def authorize(&block)
110
- return @authorize unless block_given?
111
-
112
- @authorize = block
113
- end
114
-
115
- def before_execute(&block)
116
- return @before_execute unless block_given?
117
-
118
- @before_execute = block
119
- end
89
+ def compatible_with_spec = declared(:@compatible_with_spec)
120
90
  end
121
91
 
122
92
  def execute(**kwargs)
data/lib/smith/version.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Smith
4
- VERSION = "0.12.6"
4
+ VERSION = "0.13.0"
5
5
  EXECUTION_SEMANTICS_VERSION = "7"
6
6
  end
@@ -37,7 +37,14 @@ module Smith
37
37
  def budget_pausable? = !@budget_pause_attempts.nil? && @step_attempt.to_i < @budget_pause_attempts
38
38
 
39
39
  def keep_budget_continuation(error)
40
- @budget_continuation = error.continuation if error.is_a?(BudgetUnavailable) && error.continuation
40
+ @budget_continuation = error.continuation if own_pause?(error)
41
+ end
42
+
43
+ # A tool loop this run's own step paused, which its retry continues
44
+ # from its tools' results. One a nested workflow paused is not: the
45
+ # retry starts the child over, and its tools run again.
46
+ def own_pause?(error)
47
+ error.is_a?(BudgetUnavailable) && !error.continuation.nil? && error.paused_by.equal?(self)
41
48
  end
42
49
 
43
50
  # The paused loop's messages, after the attempt's prepared input.
@@ -141,7 +141,8 @@ module Smith
141
141
  check_deadline!
142
142
  namespace = ArtifactIntegration.instance_method(:execution_namespace).bind_call(self)
143
143
  token = IdempotencyKey.__send__(:root_scope, namespace)
144
- .within(:step, @step_count + 1, transition.name.to_s).within(:spawn).key
144
+ .within(:step, @step_count + 1, self.class.transition_key_name(transition.name))
145
+ .within(:spawn).key
145
146
  launch_child_runs(transition, token)
146
147
  nil
147
148
  end
@@ -33,6 +33,8 @@ module Smith
33
33
  subclass.instance_variable_set(:@persistence_key_builder, @persistence_key_builder)
34
34
  subclass.instance_variable_set(:@persistence_schema_version, @persistence_schema_version)
35
35
  subclass.instance_variable_set(:@migrations, (@migrations || {}).dup)
36
+ subclass.instance_variable_set(:@renamed_states, @renamed_states)
37
+ subclass.instance_variable_set(:@renamed_transitions, @renamed_transitions)
36
38
  subclass.instance_variable_set(:@seed_validation_mode, @seed_validation_mode)
37
39
  subclass.instance_variable_set(:@idempotency_mode, @idempotency_mode)
38
40
  subclass.instance_variable_set(:@session_mode, @session_mode)
@@ -165,6 +167,77 @@ module Smith
165
167
  @migrations || {}
166
168
  end
167
169
 
170
+ # A state a later version renamed: a run persisted in `old`
171
+ # restores in `to` and carries on from there. Renames chain (a to
172
+ # b, then b to c); a name is renamed at most once and never back
173
+ # to a name it had. A name the class still declares is never taken
174
+ # for an old one (validate_graph warns of such a rename). The
175
+ # persisted run is mapped after migrate_from, before anything
176
+ # checks its names, so a rename needs no persistence_schema_version.
177
+ # A rename stays declared while runs persisted under its old name
178
+ # may remain.
179
+ def renamed_state(old, to:)
180
+ @renamed_states = renamed_name_map(renamed_states, old, to, kind: "state")
181
+ end
182
+
183
+ # A transition a later version renamed: a run persisted about to
184
+ # take `old`, or waiting in it (its wait, pending retry or batch),
185
+ # restores at `to`, as renamed_state. A renamed transition keeps
186
+ # its keys: its steps' idempotency keys and wait tokens are made
187
+ # under the first name in its chain (transition_key_name), so a
188
+ # step a deploy cut short, a retry or an early delivery meets the
189
+ # keys its earlier attempt used. Hence at most one name is renamed
190
+ # to a transition, and the rename stays declared for good: dropping
191
+ # it moves the transition's keys to its new name.
192
+ def renamed_transition(old, to:)
193
+ renames = renamed_name_map(renamed_transitions, old, to, kind: "transition")
194
+ earlier = renamed_transitions.key(renames.fetch(old.to_s))
195
+ if earlier
196
+ raise ArgumentError, "transition #{to.inspect} is already the new name of #{earlier.inspect}; a " \
197
+ "transition keeps the keys of one first name, so only one name is renamed to it"
198
+ end
199
+
200
+ @renamed_transitions = renames
201
+ end
202
+
203
+ def renamed_states
204
+ @renamed_states || {}.freeze
205
+ end
206
+
207
+ def renamed_transitions
208
+ @renamed_transitions || {}.freeze
209
+ end
210
+
211
+ # The state a persisted state name stands for now, renames
212
+ # followed, or nil when the class knows none. `O(R·(S + T))` for
213
+ # `R` renamed states.
214
+ def current_state_name(name)
215
+ return if name.nil?
216
+
217
+ resolve_state_name(follow_renames(name, renamed_states) { |candidate| resolve_state_name(candidate) })
218
+ end
219
+
220
+ # The transition a persisted transition name stands for now,
221
+ # renames followed, or nil when the class knows none. `O(R·T)` for
222
+ # `R` renamed transitions.
223
+ def current_transition_name(name)
224
+ return if name.nil?
225
+
226
+ transition_named(follow_renames(name, renamed_transitions) { |candidate| transition_named(candidate) })&.name
227
+ end
228
+
229
+ # The name a transition's steps are keyed under (their idempotency
230
+ # keys and wait tokens): the first name in its rename chain, so a
231
+ # renamed transition keeps its keys. Renames have no cycles and at
232
+ # most one name renamed to each, so the chain back is a line.
233
+ # `O(R)` for `R` renamed transitions.
234
+ def transition_key_name(name)
235
+ earlier = renamed_transitions.invert
236
+ current = name.to_s
237
+ current = earlier[current] while earlier.key?(current)
238
+ current
239
+ end
240
+
168
241
  # Controls whether restore validates that the seed_messages
169
242
  # builder still produces the same digest as when this workflow
170
243
  # was originally persisted.
@@ -313,6 +386,56 @@ module Smith
313
386
  Identifier.normalize(identifier, label: "workflow identifier")
314
387
  end
315
388
 
389
+ # The renames with `old` renamed to `to`: refused when `old` is
390
+ # already renamed, or when the chain from `to` leads back to `old`
391
+ # (a rename back), so every chain is a line. `O(R)`.
392
+ def renamed_name_map(renames, old, to, kind:)
393
+ old_name = renamed_identifier(old, kind:)
394
+ new_name = renamed_identifier(to, kind:)
395
+ check_rename!(renames, old_name, new_name, kind:)
396
+ renames.merge(old_name => new_name).freeze
397
+ end
398
+
399
+ def check_rename!(renames, old_name, new_name, kind:)
400
+ if old_name == new_name
401
+ raise ArgumentError, "a renamed #{kind} must differ from its new name, got #{old_name.inspect}"
402
+ end
403
+
404
+ if renames.key?(old_name)
405
+ raise ArgumentError, "#{kind} #{old_name.inspect} is already renamed to #{renames[old_name].inspect}; " \
406
+ "rename #{renames[old_name].inspect} instead"
407
+ end
408
+
409
+ return unless renamed_back?(renames, old_name, new_name)
410
+
411
+ raise ArgumentError, "#{kind} #{new_name.inspect} was renamed from #{old_name.inspect}; a #{kind} is " \
412
+ "never renamed back to a name it had"
413
+ end
414
+
415
+ def renamed_back?(renames, old_name, new_name)
416
+ current = new_name
417
+ current = renames[current] while renames.key?(current) && current != old_name
418
+ current == old_name
419
+ end
420
+
421
+ def renamed_identifier(name, kind:)
422
+ unless (name.is_a?(Symbol) || name.is_a?(String)) && !name.to_s.strip.empty?
423
+ raise ArgumentError, "a renamed #{kind} must be a non-blank Symbol or String, got #{name.inspect}"
424
+ end
425
+
426
+ name.to_s
427
+ end
428
+
429
+ # The name `name` stands for now: itself while the class declares
430
+ # it (`declared` says so), else where its renames lead. Chains are
431
+ # lines (renamed_name_map), so the walk ends. `O(R)` calls of
432
+ # `declared`.
433
+ def follow_renames(name, renames, &declared)
434
+ current = name.to_s
435
+ current = renames[current] until declared.call(current) || !renames.key?(current)
436
+ current
437
+ end
438
+
316
439
  def transitions_by_state
317
440
  @transitions_by_state ||= Hash.new { |index, state| index[state] = [] }
318
441
  end
@@ -244,10 +244,11 @@ module Smith
244
244
  false
245
245
  end
246
246
 
247
- # A state the class no longer knows can never advance or restore, so
248
- # it is not terminal: an old one reports as stuck.
247
+ # A state the class no longer knows, renames followed, can never
248
+ # advance or restore, so it is not terminal: an old one reports as
249
+ # stuck.
249
250
  def terminal_payload_state?(klass, state_name, next_transition)
250
- state = klass.resolve_state_name(state_name)
251
+ state = klass.current_state_name(state_name)
251
252
  !state.nil? && !klass.transition_from?(state) && next_transition.nil?
252
253
  end
253
254