phronomy 0.5.4 → 0.6.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.
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Singleton event loop that manages all FSMSession instances.
5
+ #
6
+ # A single background thread reads from a global Thread::Queue and dispatches
7
+ # events to their target FSMSession. IO work (LLM calls, tool calls) runs in
8
+ # separate IO threads that post events back to the loop via EventLoop#post.
9
+ #
10
+ # Activated with: +Phronomy.configure { |c| c.event_loop = true }+
11
+ #
12
+ # == Fork safety
13
+ #
14
+ # +EventLoop.instance+ is lazily initialized. The background thread is not
15
+ # created until the first call, so Puma worker forking does not duplicate the
16
+ # thread. No +after_fork+ hook is required.
17
+ #
18
+ # == Deadlock warning
19
+ #
20
+ # Do NOT call +Workflow#invoke+ (in EventLoop mode) from within a workflow
21
+ # entry action. The entry action runs on the EventLoop thread; a nested
22
+ # +invoke+ would block waiting for the same thread to process events →
23
+ # deadlock. Use the async IO pattern instead (spawn a Thread, post events
24
+ # back to the EventLoop).
25
+ class EventLoop
26
+ # Returns the singleton instance, creating and starting it on first call.
27
+ def self.instance
28
+ @instance ||= new.tap(&:start)
29
+ end
30
+
31
+ # Stops and destroys the singleton. Primarily used in tests.
32
+ # @api private
33
+ def self.reset!
34
+ @instance&.stop
35
+ @instance = nil
36
+ end
37
+
38
+ def initialize
39
+ @queue = Thread::Queue.new # global event queue (thread-safe; no Mutex needed)
40
+ @fsms = {} # { id => FSMSession } — EventLoop thread only
41
+ @waiting = {} # { id => completion_queue } — EventLoop thread only
42
+ end
43
+
44
+ # Registers an FSMSession for execution and returns a completion queue.
45
+ #
46
+ # The session and its completion queue are handed off to the EventLoop thread
47
+ # via the queue payload, so +@fsms+ and +@waiting+ are exclusively written
48
+ # and read by the EventLoop thread. No Mutex is required.
49
+ #
50
+ # The caller blocks on +completion_queue.pop+ to receive the final context
51
+ # (WorkflowContext) once the workflow finishes or halts. If an error occurred,
52
+ # the popped value will be an Exception — callers are responsible for re-raising it.
53
+ #
54
+ # @param fsm_session [Phronomy::FSMSession]
55
+ # @return [Thread::Queue] resolves to final/halted context, or an Exception
56
+ def register(fsm_session)
57
+ if Thread.current[:phronomy_event_loop_thread]
58
+ raise Phronomy::Error,
59
+ "Cannot call Workflow#invoke (EventLoop mode) from within an EventLoop " \
60
+ "entry action. Use the async IO pattern: spawn a Thread, post events " \
61
+ "back via Phronomy::EventLoop.instance.post(...) instead."
62
+ end
63
+
64
+ completion_queue = Thread::Queue.new
65
+ # Pass both session and completion_queue in the event payload so that the
66
+ # EventLoop thread is the sole writer of @fsms and @waiting.
67
+ @queue.push(Event.new(type: :start, target_id: fsm_session.id,
68
+ payload: {session: fsm_session, completion: completion_queue}))
69
+ completion_queue
70
+ end
71
+
72
+ # Enqueues an {AgentFSM} as a fire-and-forget child session.
73
+ #
74
+ # Unlike {#register}, this method:
75
+ # - Is safe to call from the EventLoop thread (entry actions).
76
+ # - Does NOT block — no completion queue is created.
77
+ # - Delegates `:finished`/`:error` cleanup to the EventLoop via posted events.
78
+ #
79
+ # @param agent_fsm [Phronomy::Agent::FSM]
80
+ # @return [nil]
81
+ def enqueue_child(agent_fsm)
82
+ @queue.push(Event.new(type: :start, target_id: agent_fsm.id,
83
+ payload: {session: agent_fsm, completion: nil}))
84
+ nil
85
+ end
86
+
87
+ # Posts an event to the loop. Safe to call from any thread (including IO threads).
88
+ #
89
+ # @param event [Phronomy::Event]
90
+ def post(event)
91
+ @queue.push(event)
92
+ end
93
+
94
+ # Starts the background event loop thread.
95
+ # @return [self]
96
+ def start
97
+ @running = true
98
+ @thread = Thread.new do
99
+ Thread.current[:phronomy_event_loop_thread] = true
100
+ run_loop
101
+ end
102
+ @thread.abort_on_exception = false
103
+ self
104
+ end
105
+
106
+ # Stops the background thread. Used in tests only.
107
+ # @api private
108
+ def stop
109
+ @running = false
110
+ @thread&.kill
111
+ @thread = nil
112
+ end
113
+
114
+ private
115
+
116
+ def run_loop
117
+ while @running
118
+ event = @queue.pop
119
+
120
+ case event.type
121
+ when :finished, :halted, :error
122
+ # All three terminal events share the same cleanup path.
123
+ # Both @fsms and @waiting are exclusively owned by this thread.
124
+ @fsms.delete(event.target_id)
125
+ cq = @waiting.delete(event.target_id)
126
+ cq&.push(event.payload)
127
+
128
+ when :start
129
+ # session and completion_queue arrive together in the payload so that
130
+ # this thread is the sole writer of @fsms and @waiting.
131
+ # completion may be nil for fire-and-forget child sessions (AgentFSM).
132
+ @fsms[event.target_id] = event.payload[:session]
133
+ cq = event.payload[:completion]
134
+ @waiting[event.target_id] = cq if cq
135
+ event.payload[:session].start
136
+
137
+ else
138
+ @fsms[event.target_id]&.handle(event)
139
+ end
140
+ end
141
+ rescue => e
142
+ # Unblock all waiting callers if the loop dies unexpectedly.
143
+ @waiting.values.each { |cq| cq.push(e) }
144
+ raise
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Event-driven execution wrapper for a single workflow run.
5
+ #
6
+ # Created by WorkflowRunner and registered with EventLoop. All public methods
7
+ # are called from the EventLoop thread — FSMSession is NOT thread-safe and must
8
+ # not be accessed concurrently from multiple threads.
9
+ #
10
+ # == Lifecycle
11
+ #
12
+ # register(session) → EventLoop posts :start → session.start
13
+ # ↓ (auto-transition present)
14
+ # EventLoop posts :state_completed → session.handle
15
+ # ↓ (repeat)
16
+ # session posts :finished or :halted
17
+ # ↓
18
+ # EventLoop pushes ctx to completion_queue → caller unblocks
19
+ #
20
+ # == Async IO pattern (EventLoop mode only)
21
+ #
22
+ # When a state has no auto-transition and is not a wait_state, but has an
23
+ # external event registered (e.g. +transition from: :fetching, on: :fetch_done+),
24
+ # the FSMSession stays registered in the EventLoop and waits for that event.
25
+ # The entry action is expected to spawn an IO thread that posts the event back:
26
+ #
27
+ # entry :fetching, ->(ctx) {
28
+ # Thread.new {
29
+ # ctx.result = http.get(ctx.url)
30
+ # Phronomy::EventLoop.instance.post(
31
+ # Phronomy::Event.new(type: :fetch_done, target_id: ctx.thread_id, payload: nil)
32
+ # )
33
+ # }
34
+ # }
35
+ # transition from: :fetching, on: :fetch_done, to: :process
36
+ class FSMSession
37
+ FINISH = WorkflowRunner::FINISH
38
+
39
+ # @return [String] workflow thread_id (matches WorkflowContext#thread_id)
40
+ attr_reader :id
41
+
42
+ # @param id [String]
43
+ # @param context [Object] includes Phronomy::WorkflowContext
44
+ # @param entry_point [Symbol] initial state name
45
+ # @param entry_actions [Hash] { state_name => [callable, ...] }
46
+ # @param auto_state_set [Hash] { state_name => true }
47
+ # @param declared_states [Array<Symbol>] all action state names
48
+ # @param wait_state_names [Array<Symbol>]
49
+ # @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
50
+ # @param phase_machine_class [Class] state_machines-backed phase tracker class
51
+ # @param recursion_limit [Integer]
52
+ # @param resume_event [Symbol, nil] external event to fire when resuming
53
+ # @param resume_phase [Symbol, nil] wait state name to resume from
54
+ def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
55
+ declared_states:, wait_state_names:, external_events:, phase_machine_class:,
56
+ recursion_limit:, resume_event: nil, resume_phase: nil)
57
+ @id = id
58
+ @ctx = context
59
+ @entry_point = entry_point
60
+ @entry_actions = entry_actions
61
+ @auto_state_set = auto_state_set
62
+ @declared_states = declared_states
63
+ @wait_state_names = wait_state_names
64
+ @external_events = external_events
65
+ @phase_machine_class = phase_machine_class
66
+ @recursion_limit = recursion_limit
67
+ @resume_event = resume_event
68
+ @resume_phase = resume_phase
69
+ @step = 0
70
+ @done = false
71
+ @current_state = nil
72
+ @tracker = nil
73
+ end
74
+
75
+ # Begins workflow execution. Called by EventLoop on :start event.
76
+ def start
77
+ if @resume_event
78
+ # Resume from wait state: position tracker at the wait state, then fire the
79
+ # external event. state_machines fires before_transition (exit) and
80
+ # after_transition (entry) callbacks, so both actions execute here.
81
+ @current_state = @resume_phase
82
+ @tracker = build_tracker(@current_state)
83
+ @tracker.context = @ctx
84
+ fire_and_advance!(@resume_event)
85
+ else
86
+ # Fresh start: state_machines does not fire callbacks on initialization,
87
+ # so we invoke the entry action for the initial state manually.
88
+ @current_state = @entry_point
89
+ @tracker = build_tracker(@current_state)
90
+ @tracker.context = @ctx
91
+ (@entry_actions[@current_state] || []).each { |c| c.call(@ctx) }
92
+ advance_or_halt
93
+ end
94
+ rescue => e
95
+ finish_with_error(e)
96
+ end
97
+
98
+ # Processes an event dispatched from EventLoop.
99
+ # Called for :state_completed and all user-defined external events.
100
+ #
101
+ # @param event [Phronomy::Event]
102
+ def handle(event)
103
+ return if @done
104
+
105
+ fire_and_advance!(event.type)
106
+ rescue => e
107
+ finish_with_error(e)
108
+ end
109
+
110
+ private
111
+
112
+ # Fires event_name on the phase tracker, updates @current_state, then
113
+ # calls advance_or_halt to decide what to do next.
114
+ def fire_and_advance!(event_name)
115
+ if @step >= @recursion_limit
116
+ raise Phronomy::RecursionLimitError,
117
+ "Recursion limit (#{@recursion_limit}) exceeded"
118
+ end
119
+
120
+ fire_event!(@tracker, event_name, @current_state)
121
+ next_phase = @tracker.phase.to_sym
122
+ # When next_phase == @current_state, no transition matched → treat as terminal.
123
+ @current_state = (next_phase == @current_state) ? FINISH : next_phase
124
+ @step += 1
125
+ advance_or_halt
126
+ end
127
+
128
+ # Determines the next action after the FSM has entered @current_state.
129
+ def advance_or_halt
130
+ return finish! if @current_state == FINISH
131
+
132
+ if @wait_state_names.include?(@current_state)
133
+ return halt!
134
+ end
135
+
136
+ if @auto_state_set.key?(@current_state)
137
+ event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
138
+ return
139
+ end
140
+
141
+ if has_external_event_from?(@current_state)
142
+ # Async IO pattern: the entry action spawned an IO thread that will post
143
+ # an external event back. Stay registered; do nothing here.
144
+ return
145
+ end
146
+
147
+ # No transition declared — validate the state is known, then treat as terminal.
148
+ unless @declared_states.include?(@current_state)
149
+ raise ArgumentError, "State #{@current_state.inspect} is not defined"
150
+ end
151
+
152
+ finish!
153
+ end
154
+
155
+ def finish!
156
+ @done = true
157
+ @ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
158
+ event_loop.post(Event.new(type: :finished, target_id: @id, payload: @ctx))
159
+ end
160
+
161
+ def halt!
162
+ @done = true
163
+ @ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
164
+ event_loop.post(Event.new(type: :halted, target_id: @id, payload: @ctx))
165
+ end
166
+
167
+ def finish_with_error(err)
168
+ @done = true
169
+ event_loop.post(Event.new(type: :error, target_id: @id, payload: err))
170
+ end
171
+
172
+ def fire_event!(tracker, event_name, from_state)
173
+ return if tracker.send(event_name)
174
+
175
+ raise ArgumentError,
176
+ "Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
177
+ "Ensure at least one guard matches or add a fallback (no-guard) transition."
178
+ end
179
+
180
+ def has_external_event_from?(state)
181
+ @external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
182
+ end
183
+
184
+ def build_tracker(from_state)
185
+ machine = @phase_machine_class.new
186
+ machine.instance_variable_set(:@phase, from_state.to_s)
187
+ machine
188
+ end
189
+
190
+ def event_loop
191
+ Phronomy::EventLoop.instance
192
+ end
193
+ end
194
+ end
@@ -133,7 +133,7 @@ module Phronomy
133
133
  @threshold = confidence_threshold.to_f
134
134
  @max_iterations = max_iterations.to_i
135
135
  @raise_if_untrusted = raise_if_untrusted
136
- @compiled_graph = nil
136
+ @compiled_workflow = nil
137
137
  end
138
138
 
139
139
  # Run the generator-verifier pipeline.
@@ -144,7 +144,7 @@ module Phronomy
144
144
  # @raise [Phronomy::LowConfidenceError] when +raise_if_untrusted:+ is +true+
145
145
  # and the result does not meet the confidence threshold
146
146
  def invoke(input, config: {})
147
- app = compiled_graph
147
+ app = compiled_workflow
148
148
  state = app.invoke({input: input}, config: config)
149
149
  confidence = combined_confidence(state)
150
150
  trusted = confidence >= @threshold
@@ -166,8 +166,8 @@ module Phronomy
166
166
  [(state.self_score || 0.0).to_f, (state.review_score || 0.0).to_f].min
167
167
  end
168
168
 
169
- def compiled_graph
170
- @compiled_graph ||= build_workflow
169
+ def compiled_workflow
170
+ @compiled_workflow ||= build_workflow
171
171
  end
172
172
 
173
173
  def build_workflow
@@ -184,42 +184,42 @@ module Phronomy
184
184
  Phronomy::Workflow.define(PipelineState) do
185
185
  initial :draft
186
186
 
187
- state :draft, action: ->(state) {
187
+ state :draft
188
+ state :review
189
+ state :finalize
190
+
191
+ entry :draft, ->(state) {
188
192
  feedback = state.review_notes.last
189
193
  prompt = dpb.call(state.input, feedback)
190
194
  result = draft_agent.invoke(prompt)
191
195
  parsed = drp.call(result[:output])
192
- state.merge(
193
- draft: parsed[:answer].to_s,
194
- self_score: pipeline.__send__(:clamp, parsed[:confidence]),
195
- citations: pipeline.__send__(:normalize_citations, parsed[:citations]),
196
- iteration: state.iteration + 1
197
- )
196
+ state.draft = parsed[:answer].to_s
197
+ state.self_score = pipeline.__send__(:clamp, parsed[:confidence])
198
+ state.citations = pipeline.__send__(:normalize_citations, parsed[:citations])
199
+ state.iteration = state.iteration + 1
198
200
  }
199
201
 
200
- state :review, action: ->(state) {
202
+ entry :review, ->(state) {
201
203
  prompt = rpb.call(state.input, state.draft, state.citations)
202
204
  result = review_agent.invoke(prompt)
203
205
  parsed = rrp.call(result[:output])
204
- state.merge(
205
- review_score: pipeline.__send__(:clamp, parsed[:score]),
206
- approved: parsed[:approved] == true,
207
- review_notes: parsed[:feedback].to_s
208
- )
206
+ state.review_score = pipeline.__send__(:clamp, parsed[:score])
207
+ state.approved = parsed[:approved] == true
208
+ state.review_notes << parsed[:feedback].to_s
209
209
  }
210
210
 
211
- state :finalize, action: ->(state) { state.merge(output: state.draft) }
211
+ entry :finalize, ->(state) { state.output = state.draft }
212
212
 
213
- after :draft, to: :review
214
- after :finalize, to: :__finish__
213
+ transition from: :draft, to: :review
214
+ transition from: :finalize, to: :__finish__
215
215
 
216
- event :route_review, from: :review,
216
+ transition from: :review,
217
217
  guard: ->(state) {
218
218
  confidence = [state.self_score || 0.0, state.review_score || 0.0].min
219
219
  (confidence >= threshold && state.approved) || state.iteration >= max_iter
220
220
  },
221
221
  to: :finalize
222
- event :route_review, from: :review, to: :draft
222
+ transition from: :review, to: :draft
223
223
  end
224
224
  end
225
225
 
@@ -5,4 +5,3 @@
5
5
  require_relative "guardrail/base"
6
6
  require_relative "guardrail/input_guardrail"
7
7
  require_relative "guardrail/output_guardrail"
8
- require_relative "guardrail/builtin"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.5.4"
4
+ VERSION = "0.6.0"
5
5
  end
@@ -8,18 +8,21 @@ module Phronomy
8
8
  #
9
9
  # Defines agent workflows in terms of *states* and *events* backed by
10
10
  # Phronomy::WorkflowRunner. This is the primary high-level API
11
- # for graph-based execution in phronomy.
11
+ # for workflow-based execution in phronomy.
12
12
  #
13
13
  # == Basic usage
14
14
  #
15
15
  # app = Phronomy::Workflow.define(MyContext) do
16
16
  # initial :fetch
17
17
  #
18
- # state :fetch, action: FETCH_NODE
19
- # state :process, action: PROCESS_NODE
18
+ # state :fetch
19
+ # state :process
20
20
  #
21
- # after :fetch, to: :process
22
- # after :process, to: :__finish__
21
+ # entry :fetch, FETCH_NODE
22
+ # entry :process, PROCESS_NODE
23
+ #
24
+ # transition from: :fetch, to: :process
25
+ # transition from: :process, to: :__finish__
23
26
  # end
24
27
  #
25
28
  # result = app.invoke({ url: "https://example.com" })
@@ -29,15 +32,18 @@ module Phronomy
29
32
  # app = Phronomy::Workflow.define(MyContext) do
30
33
  # initial :propose
31
34
  #
32
- # state :propose, action: PROPOSE_NODE
35
+ # state :propose
33
36
  # wait_state :awaiting_approval
34
- # state :execute, action: EXECUTE_NODE
37
+ # state :execute
38
+ #
39
+ # entry :propose, PROPOSE_NODE
40
+ # entry :execute, EXECUTE_NODE
35
41
  #
36
- # after :propose, to: :awaiting_approval
37
- # after :execute, to: :__finish__
42
+ # transition from: :propose, to: :awaiting_approval
43
+ # transition from: :execute, to: :__finish__
38
44
  #
39
- # event :approve, from: :awaiting_approval, to: :execute
40
- # event :reject, from: :awaiting_approval, to: :propose
45
+ # transition from: :awaiting_approval, on: :approve, to: :execute
46
+ # transition from: :awaiting_approval, on: :reject, to: :propose
41
47
  # end
42
48
  #
43
49
  # halted = app.invoke({ ... })
@@ -45,8 +51,8 @@ module Phronomy
45
51
  #
46
52
  # == Conditional transitions
47
53
  #
48
- # event :route, from: :decide, guard: ->(s) { s.score > 5 }, to: :high
49
- # event :route, from: :decide, to: :low # fallback (no guard)
54
+ # transition from: :decide, guard: ->(s) { s.score > 5 }, to: :high
55
+ # transition from: :decide, to: :low # fallback (no guard)
50
56
  #
51
57
  class Workflow
52
58
  include Phronomy::Runnable
@@ -91,7 +97,7 @@ module Phronomy
91
97
  @runner.send_event(state: state, event: event, input: input)
92
98
  end
93
99
 
94
- # Streaming execution. Yields { node: Symbol, state: Object } after each node.
100
+ # Streaming execution. Yields { state: Symbol, context: Object } after each state action.
95
101
  # @param input [Hash]
96
102
  # @param config [Hash]
97
103
  # @yield [Hash]
@@ -112,12 +118,14 @@ module Phronomy
112
118
  def initialize(context_class)
113
119
  @context_class = context_class
114
120
  @initial = nil
115
- # { node_name => callable }
116
- @states = {}
117
- # Array of { from:, to: } — auto-transitions after a state action
118
- @after_transitions = []
119
- # Array of { name:, from:, to:, guard: } — event-driven transitions
120
- @event_transitions = []
121
+ # Ordered list of declared state names (action states only, not wait states).
122
+ @declared_states = []
123
+ # { state_name => [callable, ...] } — entry actions registered via entry()
124
+ @entry_actions = {}
125
+ # { state_name => [callable, ...] } — exit actions registered via exit()
126
+ @exit_actions = {}
127
+ # Array of { from:, to:, guard:, on: } — all transitions in declaration order
128
+ @transitions = []
121
129
  # Set of wait state names
122
130
  @wait_state_names = []
123
131
  end
@@ -131,83 +139,87 @@ module Phronomy
131
139
  # rubocop:enable Style/TrivialAccessors
132
140
 
133
141
  # Declares an action state.
134
- # @param name [Symbol] state name
135
- # @param action [#call, nil] callable invoked when entering the state.
136
- # If nil, the state is treated as a no-op pass-through.
142
+ # @param name [Symbol] state name
143
+ # @param action [#call, nil] optional entry action shorthand.
144
+ # +state :generate, action: MY_PROC+ is equivalent to
145
+ # +state :generate; entry :generate, MY_PROC+.
137
146
  def state(name, action: nil)
138
- @states[name] = action || ->(s) { s }
147
+ @declared_states << name
148
+ entry(name, action) if action
149
+ end
150
+
151
+ # Declares an entry action for a state.
152
+ # The callable is invoked when the workflow enters +name+.
153
+ # It receives the current context and should mutate it in place.
154
+ # Return value is ignored.
155
+ # Multiple calls for the same state are allowed; callables fire in declaration order.
156
+ # @param name [Symbol] state name
157
+ # @param callable [#call] receives context, mutates it in place
158
+ def entry(name, callable)
159
+ (@entry_actions[name] ||= []) << callable
160
+ end
161
+
162
+ # Declares an exit action for a state.
163
+ # The callable is invoked when the workflow leaves +name+.
164
+ # It receives the current context and should mutate it in place.
165
+ # Return value is ignored.
166
+ # Multiple calls for the same state are allowed; callables fire in declaration order.
167
+ # @param name [Symbol] state name
168
+ # @param callable [#call] receives context, mutates it in place
169
+ def exit(name, callable)
170
+ (@exit_actions[name] ||= []) << callable
139
171
  end
140
172
 
141
173
  # Declares a wait state that automatically halts execution when reached.
142
- # No action is registered; the workflow pauses here until an event resumes it.
174
+ # No entry action is registered; the workflow pauses here until an event resumes it.
143
175
  # @param name [Symbol] wait state name (conventionally :awaiting_something)
144
176
  def wait_state(name)
145
177
  @wait_state_names << name
146
178
  end
147
179
 
148
- # Declares an automatic transition that fires after a state's action completes.
149
- # @param from [Symbol] source state name
150
- # @param to [Symbol] destination state name or :__finish__
151
- def after(from, to:)
152
- dest = (to == :__finish__) ? FINISH : to
153
- @after_transitions << {from: from, to: dest}
154
- end
155
-
156
- # Declares an event-driven transition.
157
- # When +guard:+ is provided, the transition is taken only if the guard
158
- # returns truthy for the current context. Multiple events with the same
159
- # name and source are evaluated in declaration order; the first passing
160
- # guard wins.
161
- # @param name [Symbol] event name
162
- # @param from [Symbol] source state where this event can be fired
180
+ # Declares a transition between states.
181
+ # Auto-fire transitions (no +on:+) fire automatically when an action state's
182
+ # action completes. External transitions (+on: :event_name+) are triggered
183
+ # manually via +send_event+.
184
+ # When +guard:+ is provided the transition is taken only if the guard returns
185
+ # truthy for the current context. Multiple transitions from the same source are
186
+ # evaluated in declaration order; the first passing guard wins.
187
+ # @param from [Symbol] source state
163
188
  # @param to [Symbol] destination state or :__finish__
164
189
  # @param guard [Proc, nil] optional guard — receives context, returns truthy/falsy
165
- def event(name, from:, to:, guard: nil)
190
+ # @param on [Symbol, nil] named event for manual triggers (e.g. :approve)
191
+ def transition(from:, to:, guard: nil, on: nil)
166
192
  dest = (to == :__finish__) ? FINISH : to
167
- @event_transitions << {name: name, from: from, to: dest, guard: guard}
193
+ @transitions << {from: from, to: dest, guard: guard, on: on}
168
194
  end
169
195
 
170
196
  # Builds and returns a Phronomy::Workflow backed by a WorkflowRunner.
171
197
  def build
172
- nodes = @states.dup
173
-
174
- # After-transitions: { from => to }
175
- # Unconditional transitions that fire automatically after an action state completes.
176
- after_transitions = @after_transitions.each_with_object({}) do |t, h|
177
- h[t[:from]] = t[:to]
178
- end
179
-
180
- # Route transitions: { from => {event_name:, entries: [{guard:, to:}, ...]} }
181
- # Events declared from action states (not wait states) fire automatically
182
- # after the action completes. The event name is used to register the
183
- # state_machines event and may be any symbol (e.g. :route, :route_review).
184
- # Declaration order is preserved so guarded entries appear before fallbacks.
185
- route_transitions = {}
198
+ entry_actions = @entry_actions.dup
199
+ exit_actions = @exit_actions.dup
186
200
 
187
- # External events: { event_name => [{from:, to:, guard:}, ...] }
188
- # Events declared from wait states, triggered by human input (e.g. :approve).
201
+ # Auto-fire transitions (no :on): fire automatically when action completes.
202
+ # External events (with :on): triggered manually via send_event.
203
+ auto_transitions = []
189
204
  external_events = {}
190
205
 
191
- @event_transitions.each do |t|
192
- if @wait_state_names.include?(t[:from])
193
- # Source is a wait state → external event
194
- external_events[t[:name]] ||= []
195
- external_events[t[:name]] << {from: t[:from], to: t[:to], guard: t[:guard]}
206
+ @transitions.each do |t|
207
+ if t[:on]
208
+ external_events[t[:on]] ||= []
209
+ external_events[t[:on]] << {from: t[:from], to: t[:to], guard: t[:guard]}
196
210
  else
197
- # Source is an action state routing event (auto-fires after action)
198
- # The event name is taken from the first declaration for each from-state.
199
- route_transitions[t[:from]] ||= {event_name: t[:name], entries: []}
200
- route_transitions[t[:from]][:entries] << {guard: t[:guard], to: t[:to]}
211
+ auto_transitions << {from: t[:from], to: t[:to], guard: t[:guard]}
201
212
  end
202
213
  end
203
214
 
204
215
  runner = Phronomy::WorkflowRunner.new(
205
216
  state_class: @context_class,
206
- nodes: nodes,
207
- after_transitions: after_transitions,
208
- route_transitions: route_transitions,
217
+ entry_actions: entry_actions,
218
+ exit_actions: exit_actions,
219
+ declared_states: @declared_states.dup,
220
+ auto_transitions: auto_transitions,
209
221
  external_events: external_events,
210
- entry_point: @initial || nodes.keys.first,
222
+ entry_point: @initial || @declared_states.first,
211
223
  wait_state_names: @wait_state_names
212
224
  )
213
225