phronomy 0.5.4 → 0.7.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +21 -0
  3. data/CHANGELOG.md +379 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +262 -48
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/SECURITY.md +80 -0
  8. data/benchmark/baseline.json +9 -0
  9. data/benchmark/bench_agent_invoke.rb +105 -0
  10. data/benchmark/bench_context_assembler.rb +46 -0
  11. data/benchmark/bench_regression.rb +171 -0
  12. data/benchmark/bench_token_estimator.rb +44 -0
  13. data/benchmark/bench_tool_schema.rb +69 -0
  14. data/benchmark/bench_vector_store.rb +39 -0
  15. data/benchmark/bench_workflow.rb +55 -0
  16. data/benchmark/run_all.rb +118 -0
  17. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  18. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  19. data/docs/decisions/003-event-loop-singleton.md +48 -0
  20. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
  21. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  22. data/docs/decisions/006-no-built-in-guardrails.md +48 -0
  23. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  24. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  25. data/docs/decisions/009-state-store-abstraction.md +141 -0
  26. data/lib/phronomy/agent/base.rb +281 -13
  27. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  28. data/lib/phronomy/agent/checkpoint.rb +1 -0
  29. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  30. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  31. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  32. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  33. data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
  34. data/lib/phronomy/agent/fsm.rb +180 -0
  35. data/lib/phronomy/agent/handoff.rb +3 -0
  36. data/lib/phronomy/agent/orchestrator.rb +123 -11
  37. data/lib/phronomy/agent/parallel_tool_chat.rb +92 -0
  38. data/lib/phronomy/agent/react_agent.rb +8 -6
  39. data/lib/phronomy/agent/runner.rb +2 -0
  40. data/lib/phronomy/agent/shared_state.rb +11 -0
  41. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  42. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  43. data/lib/phronomy/cancellation_token.rb +92 -0
  44. data/lib/phronomy/configuration.rb +32 -2
  45. data/lib/phronomy/context/assembler.rb +6 -0
  46. data/lib/phronomy/context/compaction_context.rb +2 -0
  47. data/lib/phronomy/context/context_version_cache.rb +2 -0
  48. data/lib/phronomy/context/token_budget.rb +3 -0
  49. data/lib/phronomy/context/token_estimator.rb +9 -2
  50. data/lib/phronomy/context/trigger_context.rb +1 -0
  51. data/lib/phronomy/context/trim_context.rb +4 -0
  52. data/lib/phronomy/context.rb +0 -1
  53. data/lib/phronomy/embeddings/base.rb +5 -2
  54. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  55. data/lib/phronomy/eval/comparison.rb +2 -0
  56. data/lib/phronomy/eval/dataset.rb +4 -0
  57. data/lib/phronomy/eval/metrics.rb +6 -0
  58. data/lib/phronomy/eval/runner.rb +2 -0
  59. data/lib/phronomy/eval/scorer/base.rb +1 -0
  60. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  61. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  62. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  63. data/lib/phronomy/event.rb +14 -0
  64. data/lib/phronomy/event_loop.rb +254 -0
  65. data/lib/phronomy/fsm_session.rb +201 -0
  66. data/lib/phronomy/generator_verifier.rb +24 -22
  67. data/lib/phronomy/guardrail/base.rb +3 -0
  68. data/lib/phronomy/guardrail.rb +0 -1
  69. data/lib/phronomy/knowledge_source/base.rb +6 -2
  70. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  71. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  72. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  73. data/lib/phronomy/loader/base.rb +1 -0
  74. data/lib/phronomy/loader/csv_loader.rb +2 -0
  75. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  76. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  77. data/lib/phronomy/output_parser/base.rb +1 -0
  78. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  79. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  80. data/lib/phronomy/prompt_template.rb +5 -0
  81. data/lib/phronomy/runnable.rb +20 -3
  82. data/lib/phronomy/splitter/base.rb +2 -0
  83. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  84. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  85. data/lib/phronomy/state_store/base.rb +48 -0
  86. data/lib/phronomy/state_store/in_memory.rb +62 -0
  87. data/lib/phronomy/tool/agent_tool.rb +1 -0
  88. data/lib/phronomy/tool/base.rb +189 -27
  89. data/lib/phronomy/tool/mcp_tool.rb +68 -13
  90. data/lib/phronomy/tracing/base.rb +3 -0
  91. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  92. data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
  93. data/lib/phronomy/vector_store/base.rb +33 -7
  94. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  95. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  96. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  97. data/lib/phronomy/version.rb +1 -1
  98. data/lib/phronomy/workflow.rb +175 -74
  99. data/lib/phronomy/workflow_context.rb +55 -5
  100. data/lib/phronomy/workflow_runner.rb +197 -114
  101. data/lib/phronomy.rb +74 -1
  102. data/scripts/api_snapshot.rb +91 -0
  103. data/scripts/check_api_annotations.rb +68 -0
  104. data/scripts/check_private_enforcement.rb +93 -0
  105. data/scripts/check_readme_runnable.rb +98 -0
  106. data/scripts/run_mutation.sh +46 -0
  107. metadata +50 -6
  108. data/lib/phronomy/context/builder.rb +0 -92
  109. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +0 -100
  110. data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +0 -67
  111. data/lib/phronomy/guardrail/builtin.rb +0 -16
@@ -11,7 +11,7 @@ module Phronomy
11
11
  # Field update policies:
12
12
  # :replace (default) -- overwrites with the new value
13
13
  # :append -- appends to an Array
14
- # :merge -- deep-merges into a Hash
14
+ # :merge -- shallow-merges into a Hash (top-level keys are merged; nested objects are replaced)
15
15
  #
16
16
  # @example
17
17
  # class ScanContext
@@ -31,7 +31,16 @@ module Phronomy
31
31
  # @param name [Symbol]
32
32
  # @param type [Symbol] :replace / :append / :merge
33
33
  # @param default [Object, Proc, nil]
34
+ # @raise [ArgumentError] if +default+ is a plain Array or Hash (use a Proc instead)
35
+ # @api public
34
36
  def field(name, type: :replace, default: nil)
37
+ if default.is_a?(Array) || default.is_a?(Hash)
38
+ raise ArgumentError,
39
+ "Mutable default for field #{name.inspect} must be wrapped in a Proc " \
40
+ "to avoid shared state across instances. " \
41
+ "Use `default: -> { #{default.inspect} }` instead."
42
+ end
43
+
35
44
  @fields[name] = {type: type, default: default}
36
45
  attr_accessor name
37
46
  end
@@ -49,14 +58,16 @@ module Phronomy
49
58
  # Encoding:
50
59
  # :__end__ — workflow completed (or not yet started)
51
60
  # :awaiting_<name> — halted at a wait_state(:awaiting_<name>) declaration
52
- # :<node> — resuming at <node> (workflow paused before its execution)
61
+ # :<state> — resuming at <state> (workflow paused before its execution)
53
62
  # @return [Symbol]
63
+ # @api public
54
64
  def phase
55
65
  @phase || :__end__
56
66
  end
57
67
 
58
68
  # Returns true if the workflow is paused mid-execution (not yet completed).
59
69
  # @return [Boolean]
70
+ # @api public
60
71
  def halted?
61
72
  phase != :__end__
62
73
  end
@@ -64,6 +75,7 @@ module Phronomy
64
75
  # Sets internal workflow metadata. Returns self.
65
76
  # @param thread_id [String, nil]
66
77
  # @param phase [Symbol, nil]
78
+ # @api public
67
79
  def set_graph_metadata(thread_id: nil, phase: nil)
68
80
  @thread_id = thread_id unless thread_id.nil?
69
81
  @phase = phase unless phase.nil?
@@ -71,6 +83,9 @@ module Phronomy
71
83
  end
72
84
 
73
85
  def initialize(**attrs)
86
+ unknown = attrs.keys - self.class.fields.keys
87
+ raise ArgumentError, "Unknown WorkflowContext field(s): #{unknown.inspect}" unless unknown.empty?
88
+
74
89
  self.class.fields.each do |name, config|
75
90
  default = config[:default].is_a?(Proc) ? config[:default].call : config[:default]
76
91
  send(:"#{name}=", attrs.fetch(name, default))
@@ -79,11 +94,19 @@ module Phronomy
79
94
  @phase = :__end__
80
95
  end
81
96
 
82
- # Immutably updates context fields. Returns a new instance with the applied changes.
83
- # Internal workflow metadata (thread_id, phase) is preserved.
97
+ # Returns a new context instance with the specified field updates applied.
98
+ # Updated fields follow the field's declared +:type+ semantics (:replace, :append,
99
+ # or :merge). Unchanged fields are deep-copied on a best-effort basis — objects
100
+ # that do not support +#dup+ (e.g. integers, frozen objects) are carried over
101
+ # by reference. Internal workflow metadata (thread_id, phase) is preserved.
84
102
  # @param updates [Hash] { field_name => new_value }
85
103
  # @return [self.class] new context instance
104
+ # @raise [ArgumentError] if updates contains keys that are not declared fields
105
+ # @api public
86
106
  def merge(updates)
107
+ unknown = updates.keys - self.class.fields.keys
108
+ raise ArgumentError, "Unknown WorkflowContext field(s): #{unknown.inspect}" unless unknown.empty?
109
+
87
110
  new_attrs = {}
88
111
  self.class.fields.each_key do |name|
89
112
  field_config = self.class.fields[name]
@@ -97,7 +120,7 @@ module Phronomy
97
120
  updates[name]
98
121
  end
99
122
  else
100
- send(name)
123
+ deep_dup_value(send(name))
101
124
  end
102
125
  end
103
126
  new_context = self.class.new(**new_attrs)
@@ -110,10 +133,37 @@ module Phronomy
110
133
 
111
134
  # Converts user-defined fields to a Hash (excludes internal workflow metadata).
112
135
  # @return [Hash]
136
+ # @api public
113
137
  def to_h
114
138
  self.class.fields.keys.each_with_object({}) do |name, h|
115
139
  h[name] = send(name)
116
140
  end
117
141
  end
142
+
143
+ private
144
+
145
+ # Performs a deep copy of a value for immutable context propagation.
146
+ # Arrays and Hashes are deep-duplicated recursively.
147
+ # Immutable values (nil, Symbol, Integer, Float, true/false, frozen String) are returned as-is.
148
+ # Other objects are dup'd (best-effort shallow copy for custom types).
149
+ # Objects that cannot be dup'd (e.g. Proc, Method) are returned as-is.
150
+ def deep_dup_value(val)
151
+ case val
152
+ when Array
153
+ val.map { |v| deep_dup_value(v) }
154
+ when Hash
155
+ val.each_with_object({}) { |(k, v), h| h[k] = deep_dup_value(v) }
156
+ when NilClass, Symbol, Integer, Float, TrueClass, FalseClass
157
+ val
158
+ else
159
+ return val if val.frozen?
160
+
161
+ begin
162
+ val.dup
163
+ rescue TypeError
164
+ val
165
+ end
166
+ end
167
+ end
118
168
  end
119
169
  end
@@ -5,7 +5,7 @@ require "state_machines"
5
5
 
6
6
  module Phronomy
7
7
  # Execution engine for compiled workflows.
8
- # Manages node execution, phase transitions, halt/resume, and wait states.
8
+ # Manages state entry/exit action execution, phase transitions, halt/resume, and wait states.
9
9
  # Instantiated by Phronomy::Workflow and used internally.
10
10
  #
11
11
  # == Design principle
@@ -16,56 +16,78 @@ module Phronomy
16
16
  # the PhaseTracker itself. This ensures that "what happens next" is always
17
17
  # determined by the declared state machine topology, never by Phronomy internals.
18
18
  #
19
- # == Three transition categories registered in PhaseTracker
19
+ # Entry and exit actions are registered as state_machines +after_transition to:+
20
+ # and +before_transition from:+ callbacks respectively. Entry actions may either
21
+ # mutate the context in place or return a new context (e.g. via +s.merge(...)+).
22
+ # When an entry action returns a Phronomy::WorkflowContext, that value replaces
23
+ # the current context; otherwise the return value is ignored.
24
+ # Exit actions are always mutation-in-place; their return value is ignored.
20
25
  #
21
- # 1. advance_<from> — automatic, unconditional after-transitions
22
- # fired when an action state's action completes
23
- # (declared with +after :foo, to: :bar+)
26
+ # The sole exception is the initial state: state_machines does not fire transition
27
+ # callbacks on initialization, so the entry action for the entry point is invoked
28
+ # directly by WorkflowRunner before the main execution loop begins.
24
29
  #
25
- # 2. route — a single event that carries all guarded transitions
26
- # (declared with +event :route, from: :foo, guard: ..., to: :bar+)
30
+ # == Two transition categories registered in PhaseTracker
31
+ #
32
+ # 1. state_completed — all auto-fire transitions (with or without guards).
33
+ # Fired when an action state's action completes.
27
34
  # Guards are evaluated in declaration order; first match wins.
28
- # An unguarded fallback, if declared, is evaluated last.
35
+ # (declared with +transition from: :foo, to: :bar+ or
36
+ # +transition from: :foo, guard: ..., to: :bar+)
29
37
  #
30
- # 3. <event_name> — external events triggered by human input, originating
38
+ # 2. <event_name> — external events triggered by human input, originating
31
39
  # from wait states
32
- # (declared with +event :approve, from: :awaiting, to: :run+)
40
+ # (declared with +transition from: :awaiting, on: :approve, to: :run+)
41
+ # @api private
33
42
  class WorkflowRunner
34
43
  include Phronomy::Runnable
35
44
 
36
45
  # Sentinel value for the terminal state of a workflow.
37
46
  FINISH = :__end__
38
47
 
39
- def initialize(state_class:, nodes:, after_transitions:, route_transitions:,
40
- external_events:, entry_point:, wait_state_names: [],
41
- before_callbacks: {}, after_callbacks: {})
48
+ def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [], state_store: nil)
42
49
  @state_class = state_class
43
- @nodes = nodes
44
- @after_transitions = after_transitions # { from => to }
45
- @route_transitions = route_transitions # { from => [{guard:, to:}, ...] }
50
+ @entry_actions = entry_actions # { state_name => [callable, ...] }
51
+ @declared_states = declared_states
52
+ # Lookup set: states with at least one auto-fire transition declared.
53
+ @auto_state_set = auto_transitions.each_with_object({}) { |t, h| h[t[:from]] = true }
46
54
  @external_events = external_events # { name => [{from:, to:, guard:}, ...] }
47
55
  @entry_point = entry_point
48
56
  @wait_state_names = wait_state_names
49
- @before_callbacks = before_callbacks.dup
50
- @after_callbacks = after_callbacks.dup
51
- @phase_machine_class = build_phase_machine_class
57
+ @state_store = state_store
58
+ @phase_machine_class = build_phase_machine_class(auto_transitions, exit_actions)
52
59
  end
53
60
 
54
61
  # Executes the workflow from the initial state.
55
62
  # @param input [Hash] initial context field values
56
- # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
63
+ # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id:, state_store: }
57
64
  # @return [Object] final context (includes Phronomy::WorkflowContext)
65
+ # @api private
58
66
  def invoke(input, config: {})
59
67
  caller_meta = {}
60
68
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
61
69
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
62
70
 
63
- trace("graph.invoke", input: input.inspect, **caller_meta) do |_span|
71
+ trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
64
72
  thread_id = config[:thread_id] || SecureRandom.uuid
65
73
  recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
66
- state = @state_class.new(**input)
74
+
75
+ store = config.fetch(:state_store, @state_store) || Phronomy.configuration.state_store
76
+ snapshot = (store && config[:thread_id]) ? store.load(thread_id) : nil
77
+ initial_fields = if snapshot && snapshot[:fields]
78
+ snapshot[:fields].transform_keys(&:to_sym).merge(input.transform_keys(&:to_sym))
79
+ else
80
+ input
81
+ end
82
+
83
+ state = @state_class.new(**initial_fields)
67
84
  state.set_graph_metadata(thread_id: thread_id)
68
- result = run_graph(state, recursion_limit: recursion_limit)
85
+ result = if Phronomy.configuration.event_loop
86
+ run_via_event_loop(state, recursion_limit: recursion_limit)
87
+ else
88
+ run_workflow(state, recursion_limit: recursion_limit)
89
+ end
90
+ store&.save(thread_id, {fields: result.to_h, phase: result.phase.to_s}) if config[:thread_id]
69
91
  [result, nil]
70
92
  end
71
93
  end
@@ -74,6 +96,7 @@ module Phronomy
74
96
  # @param state [Object] halted context
75
97
  # @param input [Hash, nil] optional field updates to merge before resuming
76
98
  # @return [Object] final context
99
+ # @api private
77
100
  def resume(state:, input: nil)
78
101
  send_event(state: state, event: :resume, input: input)
79
102
  end
@@ -87,14 +110,12 @@ module Phronomy
87
110
  # @param event [Symbol] named event or +:resume+ for generic resumption
88
111
  # @param input [Hash, nil] optional field updates to merge before resuming
89
112
  # @return [Object] final context
113
+ # @api private
90
114
  def send_event(state:, event:, input: nil)
91
115
  state = state.merge(input) if input
92
116
  event = event.to_sym
93
117
  current_phase = state.phase
94
118
 
95
- tracker = new_phase_machine(current_phase)
96
- tracker.context = state
97
-
98
119
  ev_to_fire = if event == :resume
99
120
  # Find the first external event that can originate from the current wait state.
100
121
  name, = @external_events.find { |_, ts| ts.any? { |t| t[:from] == current_phase } }
@@ -111,162 +132,199 @@ module Phronomy
111
132
  event
112
133
  end
113
134
 
114
- fire_event!(tracker, ev_to_fire, current_phase)
115
-
116
- next_phase = tracker.phase.to_sym
117
- next_node = (next_phase == :__end__) ? FINISH : next_phase
118
- run_graph(state, from_node: next_node)
135
+ if Phronomy.configuration.event_loop
136
+ run_via_event_loop(state,
137
+ recursion_limit: Phronomy.configuration.recursion_limit,
138
+ resume_event: ev_to_fire, resume_phase: current_phase)
139
+ else
140
+ run_workflow(state, resume_event: ev_to_fire, resume_phase: current_phase)
141
+ end
119
142
  end
120
143
 
121
- # Streaming execution. Yields { node: Symbol, state: Object } after each node completes.
144
+ # Streaming execution. Yields { state: Symbol, context: Object } after each state action completes.
122
145
  # @param input [Hash]
123
146
  # @param config [Hash]
124
147
  # @yield [Hash]
125
148
  # @return [Object] final context
149
+ # @api private
126
150
  def stream(input, config: {}, &block)
127
151
  thread_id = config[:thread_id] || SecureRandom.uuid
128
152
  recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
129
153
  state = @state_class.new(**input)
130
154
  state.set_graph_metadata(thread_id: thread_id)
131
- run_graph(state, recursion_limit: recursion_limit, &block)
155
+ run_workflow(state, recursion_limit: recursion_limit, &block)
132
156
  end
133
157
 
134
158
  private
135
159
 
136
- def run_graph(state, from_node: nil, recursion_limit: 25, &event_block)
137
- current_node = from_node || @entry_point
138
- tracker = new_phase_machine(current_node)
139
- tracker.context = state
140
- # Event queue: decouple node execution from transition firing.
141
- # Events are enqueued after a node completes and processed at the top
160
+ # Builds an FSMSession for the given context. Used in EventLoop mode.
161
+ def build_session_for(context:, recursion_limit:, resume_event: nil, resume_phase: nil)
162
+ Phronomy::FSMSession.new(
163
+ id: context.thread_id,
164
+ context: context,
165
+ entry_point: @entry_point,
166
+ entry_actions: @entry_actions,
167
+ auto_state_set: @auto_state_set,
168
+ declared_states: @declared_states,
169
+ wait_state_names: @wait_state_names,
170
+ external_events: @external_events,
171
+ phase_machine_class: @phase_machine_class,
172
+ recursion_limit: recursion_limit,
173
+ resume_event: resume_event,
174
+ resume_phase: resume_phase
175
+ )
176
+ end
177
+
178
+ # Executes the workflow via the singleton EventLoop.
179
+ # Blocks the calling thread on a completion queue until the workflow
180
+ # finishes, halts at a wait state, or raises an error.
181
+ def run_via_event_loop(context, recursion_limit:, resume_event: nil, resume_phase: nil)
182
+ session = build_session_for(
183
+ context: context, recursion_limit: recursion_limit,
184
+ resume_event: resume_event, resume_phase: resume_phase
185
+ )
186
+ completion_queue = Phronomy::EventLoop.instance.register(session)
187
+ result = completion_queue.pop
188
+ raise result if result.is_a?(Exception)
189
+ result
190
+ end
191
+
192
+ def run_workflow(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
193
+ if resume_event
194
+ # -- Resume from a wait state -------------------------------------------
195
+ # Fire the external event on a tracker positioned at the wait state.
196
+ # state_machines will invoke before_transition (exit) and after_transition
197
+ # (entry) callbacks as part of the transition, so both actions fire here.
198
+ current_state = resume_phase
199
+ tracker = new_phase_machine(current_state)
200
+ tracker.context = ctx
201
+ fire_event!(tracker, resume_event, current_state)
202
+ ctx = tracker.context
203
+ next_phase = tracker.phase.to_sym
204
+ current_state = (next_phase == current_state) ? FINISH : next_phase
205
+ else
206
+ # -- Fresh start --------------------------------------------------------
207
+ current_state = @entry_point
208
+ tracker = new_phase_machine(current_state)
209
+ tracker.context = ctx
210
+ # state_machines only fires after_transition callbacks on transitions.
211
+ # The entry point has no prior transition, so we invoke its entry actions directly.
212
+ @entry_actions[current_state]&.each do |c|
213
+ result = c.call(ctx)
214
+ ctx = result if result.is_a?(Phronomy::WorkflowContext)
215
+ end
216
+ tracker.context = ctx
217
+ end
218
+
219
+ # Event queue: decouple action execution from transition firing.
220
+ # Events are enqueued after visiting a state and processed at the top
142
221
  # of the next iteration so that guards always see the freshest context.
143
222
  event_queue = []
144
223
  step = 0
145
224
 
146
225
  loop do
147
- break if current_node == FINISH
226
+ break if current_state == FINISH
148
227
 
149
228
  # -- Process next pending event -----------------------------------------
150
229
  # Dequeue one event and fire it against the state machine. Guards are
151
- # evaluated here (at fire time) so they see the context written by the
152
- # node that enqueued the event.
230
+ # evaluated here (at fire time). Entry/exit callbacks fire inside fire_event!.
153
231
  if (event = event_queue.shift)
154
232
  if step >= recursion_limit
155
233
  raise Phronomy::RecursionLimitError,
156
234
  "Recursion limit (#{recursion_limit}) exceeded"
157
235
  end
158
236
 
159
- fire_event!(tracker, event, current_node)
237
+ fire_event!(tracker, event, current_state)
238
+ ctx = tracker.context
160
239
  next_phase = tracker.phase.to_sym
161
- # When next_phase == current_node no transition matched → terminal node.
162
- current_node = (next_phase == current_node) ? FINISH : next_phase
240
+ # When next_phase == current_state no transition matched → terminal state.
241
+ current_state = (next_phase == current_state) ? FINISH : next_phase
163
242
  step += 1
164
243
  next
165
244
  end
166
245
 
167
246
  # -- Queue empty: check for halt -----------------------------------------
168
247
  # Auto-halt at wait states: persist phase in context and return to caller.
169
- # The caller resumes via send_event, which starts a fresh run_graph call.
170
- if @wait_state_names.include?(current_node)
171
- state.set_graph_metadata(thread_id: state.thread_id, phase: current_node)
172
- return state
248
+ # The caller resumes via send_event.
249
+ if @wait_state_names.include?(current_state)
250
+ ctx.set_graph_metadata(thread_id: ctx.thread_id, phase: current_state)
251
+ return ctx
173
252
  end
174
253
 
175
- # -- Execute node action ------------------------------------------------
176
- node_fn = @nodes[current_node]
177
- raise ArgumentError, "Node #{current_node.inspect} is not defined" unless node_fn
178
-
179
- result = node_fn.call(state)
180
- state = case result
181
- when Hash then state.merge(result)
182
- when @state_class then result
183
- when nil then state
184
- else
185
- raise ArgumentError,
186
- "Node #{current_node} returned #{result.class}; " \
187
- "expected Hash, #{@state_class}, or nil"
254
+ # -- Validate state is known --------------------------------------------
255
+ unless @declared_states.include?(current_state)
256
+ raise ArgumentError, "State #{current_state.inspect} is not defined"
188
257
  end
189
258
 
190
- # Update tracker so guards see the freshest context when the event fires.
191
- tracker.context = state
259
+ # -- Emit stream event and enqueue transition ---------------------------
260
+ # Entry action for current_state has already been invoked (either by the
261
+ # initial manual call above, or by the after_transition callback fired
262
+ # inside fire_event! on the previous iteration).
263
+ event_block&.call({state: current_state, context: ctx})
192
264
 
193
- event_block&.call({node: current_node, state: state})
194
-
195
- # -- Enqueue transition event -------------------------------------------
196
- # node_completed: generic event for all after-transitions (unconditional).
197
- # route event: user-named event carrying guarded conditional branches.
198
- # No enqueue: terminal node — next iteration exits via FINISH check.
199
- if @after_transitions.key?(current_node)
200
- event_queue << :node_completed
201
- elsif @route_transitions.key?(current_node)
202
- event_queue << @route_transitions[current_node][:event_name]
265
+ # state_completed: unified event for all auto-fire transitions.
266
+ # No enqueue: terminal state — next iteration exits via FINISH check.
267
+ if @auto_state_set.key?(current_state)
268
+ event_queue << :state_completed
203
269
  else
204
- current_node = FINISH
270
+ current_state = FINISH
205
271
  end
206
272
  end
207
273
 
208
- state.set_graph_metadata(thread_id: state.thread_id, phase: :__end__)
209
- state
274
+ ctx.set_graph_metadata(thread_id: ctx.thread_id, phase: :__end__)
275
+ ctx
210
276
  end
211
277
 
212
278
  # Fires +event_name+ on +tracker+, raising a descriptive error if no
213
279
  # transition matches. state_machines event methods return false when no
214
280
  # transition can be taken (invalid state or all guards fail).
215
- def fire_event!(tracker, event_name, from_node)
281
+ def fire_event!(tracker, event_name, from_state)
216
282
  return if tracker.send(event_name)
217
283
 
218
284
  raise ArgumentError,
219
- "Transition from #{from_node.inspect} via event #{event_name.inspect} failed. " \
285
+ "Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
220
286
  "Ensure at least one guard matches or add a fallback (no-guard) transition."
221
287
  end
222
288
 
223
289
  # Builds the PhaseTracker class backed by state_machines.
224
290
  #
225
- # Three event types are registered:
226
- # advance_<from> unconditional after-transitions
227
- # route all guarded routing transitions (one event, multiple transitions)
228
- # <external_name>external events originating from wait states
291
+ # Four event/callback types are registered:
292
+ # state_completed all auto-fire transitions (guarded and unguarded)
293
+ # <external_name> external events originating from wait states
294
+ # after_transition to entry callbacks (invoked when entering a state)
295
+ # before_transition from — exit callbacks (invoked when leaving a state)
229
296
  #
230
297
  # Guard lambdas bridge the PhaseTracker and WorkflowContext via +m.context+.
231
- def build_phase_machine_class
298
+ def build_phase_machine_class(auto_transitions, exit_actions)
232
299
  entry = @entry_point
233
- all_states = (@nodes.keys + @wait_state_names + [:__end__]).uniq
234
- after_trans = @after_transitions # { from => to }
235
- route_trans = @route_transitions # { from => [{guard:, to:}, ...] }
236
- ext_events = @external_events # { name => [{from:, to:, guard:}, ...] }
300
+ all_states = (@declared_states + @wait_state_names + [:__end__]).uniq
301
+ auto_trans = auto_transitions # Array of { from:, to:, guard: }
302
+ ext_events = @external_events
303
+ entry_acts = @entry_actions
304
+ exit_acts = exit_actions
237
305
 
238
306
  Class.new do
239
- # Holds the current WorkflowContext so guards can read it.
307
+ # Holds the current WorkflowContext so guards and callbacks can read it.
240
308
  attr_accessor :context
241
309
 
242
310
  state_machine :phase, initial: entry do
243
311
  all_states.each { |s| state s }
244
312
 
245
- # 1. After-transitions: one generic :node_completed event covers all
246
- # unconditional transitions. This keeps event names independent of
247
- # source state names and matches standard state machine semantics.
248
- event :node_completed do
249
- after_trans.each do |from, to|
250
- transition from => to
251
- end
252
- end
253
-
254
- # 2. Route events: one named event per from-state (name may vary).
255
- # Declaration order is preserved; guards first, unguarded fallback last.
256
- route_trans.each do |from, routing|
257
- event routing[:event_name] do
258
- routing[:entries].each do |t|
259
- if t[:guard]
260
- guard_proc = t[:guard]
261
- transition from => t[:to], :if => ->(m) { guard_proc.call(m.context) }
262
- else
263
- transition from => t[:to]
264
- end
313
+ # Auto-fire transitions: all auto transitions unified under :state_completed.
314
+ # Includes unguarded (unconditional) and guarded (conditional) transitions.
315
+ # Declaration order is preserved; guards are evaluated before unguarded fallbacks.
316
+ event :state_completed do
317
+ auto_trans.each do |t|
318
+ if t[:guard]
319
+ guard_proc = t[:guard]
320
+ transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
321
+ else
322
+ transition t[:from] => t[:to]
265
323
  end
266
324
  end
267
325
  end
268
326
 
269
- # 3. External events: human-in-the-loop triggers from wait states.
327
+ # External events: human-in-the-loop triggers from wait states.
270
328
  ext_events.each do |ev_name, transitions|
271
329
  event ev_name do
272
330
  transitions.each do |t|
@@ -279,18 +337,43 @@ module Phronomy
279
337
  end
280
338
  end
281
339
  end
340
+
341
+ # Entry callbacks: fire after_transition into each state.
342
+ # Each callable is registered as a separate callback; state_machines
343
+ # accumulates them and fires in declaration order.
344
+ # If the callable returns a WorkflowContext (e.g. via s.merge(...)),
345
+ # the returned context replaces the current one on the tracker.
346
+ entry_acts.each do |state_name, callables|
347
+ callables.each do |callable|
348
+ after_transition to: state_name do |machine|
349
+ result = callable.call(machine.context)
350
+ machine.context = result if result.is_a?(Phronomy::WorkflowContext)
351
+ end
352
+ end
353
+ end
354
+
355
+ # Exit callbacks: fire before_transition out of each state.
356
+ # Each callable is registered as a separate callback; state_machines
357
+ # accumulates them and fires in declaration order.
358
+ exit_acts.each do |state_name, callables|
359
+ callables.each do |callable|
360
+ before_transition from: state_name do |machine|
361
+ callable.call(machine.context)
362
+ end
363
+ end
364
+ end
282
365
  end
283
366
  end
284
367
  rescue => e
285
368
  raise ArgumentError, "Failed to build phase machine: #{e.message}"
286
369
  end
287
370
 
288
- # Creates a PhaseTracker instance initialized to +from_node+.
289
- def new_phase_machine(from_node)
371
+ # Creates a PhaseTracker instance initialized to +from_state+.
372
+ def new_phase_machine(from_state)
290
373
  machine = @phase_machine_class.new
291
374
  # Override the initial state set by state_machine's initializer so we can
292
- # resume from an arbitrary node (e.g. after a wait state).
293
- machine.instance_variable_set(:@phase, from_node.to_s)
375
+ # resume from an arbitrary state (e.g. after a wait state).
376
+ machine.instance_variable_set(:@phase, from_state.to_s)
294
377
  machine
295
378
  end
296
379
  end