phronomy 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c0aaadfedad1ee8b4afa1efb2e205a626a20cd636f268e711dce29128c84b6fe
4
- data.tar.gz: f781f66ae3d570caca771d2b5579874169acef5eeed7f6cf99474a4c82fd077a
3
+ metadata.gz: 84f1944fd2628cdc04bfdaabab8ca91beeb624b7904ea9f50499f863e86e3f4c
4
+ data.tar.gz: 96b4b7c5258b3f2b9c710115e1b27230a87013c9e4953c05d40ea7ae227c43d8
5
5
  SHA512:
6
- metadata.gz: 73793efee9cf2c0cb81828fc86927181edca2b4d3fa830a6cefa176b30fd70c57c75e71173d57b51d385377ec3795f1d8cb42e25c3650be40eff543fc2f856fd
7
- data.tar.gz: b7dd9cc538e478774d46cf7823e4f5e52b599a02fdd1edf300439287dabfe2fc9a90e93f50647fffa246f7f3c90bd4abf6cf258e9723af1222f836d89125dc59
6
+ metadata.gz: d0cc9d4473f67a88f449c0b120d66f8975a21fda6feac2e361d8ba5931176a4aee38766cff5efd21a92344808a03e332611ac8c0d33fff1f547d25ea2dab54f4
7
+ data.tar.gz: 650e770db4aedfe6d6dc63deeef096014a574c73ac05438bcff6dce03605ec6b079a50f9fe860890e127f49b84e0970d7716d26606956f471665d56cec66a940
data/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [0.2.1] — Unreleased
11
+
12
+ ### Changed
13
+
14
+ - **`WorkflowRunner` — state_machines fully drives execution** (architecture overhaul).
15
+ Previously `state_machines` was used only for post-hoc transition validation;
16
+ the next-node was calculated by Phronomy internally (`resolve_next_node`).
17
+ As of 0.3.0, all state transition decisions — including guard evaluation for
18
+ routing events — are delegated entirely to `state_machines`.
19
+ - `PhaseTracker` now exposes `attr_accessor :context` so guard lambdas can
20
+ access the `WorkflowContext` via `m.context`.
21
+ - Guard bridge pattern: `if: ->(m) { guard_proc.call(m.context) }`.
22
+ - Three event types registered per workflow:
23
+ 1. `advance_<from>` — unconditional after-transitions
24
+ 2. `<routing_event>` — guarded branching from action states (name is the
25
+ event name used in the DSL, e.g. `:route`, `:route_review`)
26
+ 3. `<external_event>` — human-in-the-loop triggers from wait states
27
+ - Invalid transitions now raise `ArgumentError` instead of logging warnings.
28
+ - **`WorkflowRunner` initializer signature changed** — `edges:`,
29
+ `conditional_edges:`, and `wait_states:` replaced by `after_transitions:`,
30
+ `route_transitions:`, `external_events:`, and `wait_state_names:`.
31
+ This is an **internal-only** change; the public `Phronomy::Workflow.define` DSL
32
+ is unchanged.
33
+
34
+ ### Removed (internal)
35
+
36
+ - `WorkflowRunner#resolve_next_node` — logic moved to state_machines
37
+ - `WorkflowRunner#advance_phase` — replaced by `fire_event!`
38
+ - `Workflow::Builder#build_edges`, `#build_conditional_edges`,
39
+ `#build_wait_states` — replaced by unified event classification in `build`
40
+
41
+ ---
42
+
10
43
  ## [Unreleased]
11
44
 
12
45
  ### Added
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+
6
+ module Phronomy
7
+ module StateStore
8
+ # File-system-backed state store.
9
+ # Persists graph state as a JSON file under a configurable directory.
10
+ # No additional server or database migration is required — it works with
11
+ # the local file system out of the box.
12
+ #
13
+ # Each thread_id is stored as a separate file named "<thread_id>.json".
14
+ # The thread_id is sanitised before use as a filename to prevent path
15
+ # traversal: only alphanumeric characters, hyphens, underscores, and dots
16
+ # are allowed; all other characters are replaced with underscores.
17
+ #
18
+ # @note This store is suitable for single-process use (development, CLI
19
+ # tools, tests). It is not safe for concurrent access across multiple
20
+ # processes without external locking.
21
+ #
22
+ # @example
23
+ # store = Phronomy::StateStore::File.new(dir: "tmp/workflow_states")
24
+ # Phronomy::Workflow.define(MyContext, state_store: store) do
25
+ # # ...
26
+ # end
27
+ class File < Base
28
+ # @param dir [String] directory where state files are stored.
29
+ # Created automatically if it does not exist.
30
+ def initialize(dir: ::File.join(::Dir.tmpdir, "phronomy_states"))
31
+ @dir = ::File.expand_path(dir)
32
+ ::FileUtils.mkdir_p(@dir)
33
+ end
34
+
35
+ # @param state [Object] includes Phronomy::WorkflowContext; must have a non-nil thread_id
36
+ # @return [self]
37
+ def save(state)
38
+ ::File.write(path(state.thread_id), serialize_state(state))
39
+ self
40
+ end
41
+
42
+ # @param thread_id [String]
43
+ # @return [Object, nil] state instance or nil if not found
44
+ def load(thread_id)
45
+ file = path(thread_id)
46
+ return nil unless ::File.exist?(file)
47
+
48
+ deserialize_state(::File.read(file))
49
+ end
50
+
51
+ # Removes the saved state file for the given thread_id.
52
+ # @param thread_id [String]
53
+ # @return [self]
54
+ def clear(thread_id)
55
+ file = path(thread_id)
56
+ ::File.delete(file) if ::File.exist?(file)
57
+ self
58
+ end
59
+
60
+ # Removes all state files managed by this store instance.
61
+ # @return [self]
62
+ def clear_all
63
+ ::Dir.glob(::File.join(@dir, "*.json")).each { |f| ::File.delete(f) }
64
+ self
65
+ end
66
+
67
+ # @return [String] the directory used by this store
68
+ def directory
69
+ @dir
70
+ end
71
+
72
+ private
73
+
74
+ # Converts a thread_id into a safe filename component.
75
+ # Characters outside [A-Za-z0-9._-] are replaced with underscores.
76
+ def sanitize(thread_id)
77
+ thread_id.to_s.gsub(/[^A-Za-z0-9._-]/, "_")
78
+ end
79
+
80
+ def path(thread_id)
81
+ ::File.join(@dir, "#{sanitize(thread_id)}.json")
82
+ end
83
+ end
84
+ end
85
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -53,10 +53,11 @@ module Phronomy
53
53
 
54
54
  # Defines a new Workflow.
55
55
  # @param context_class [Class] class that includes Phronomy::WorkflowContext
56
+ # @param state_store [Object, nil] optional state store override (passed to WorkflowRunner)
56
57
  # @yield block evaluated in DSL context
57
58
  # @return [Phronomy::Workflow] compiled and ready-to-run workflow instance
58
- def self.define(context_class, &block)
59
- builder = Builder.new(context_class)
59
+ def self.define(context_class, state_store: nil, &block)
60
+ builder = Builder.new(context_class, state_store: state_store)
60
61
  builder.instance_eval(&block)
61
62
  builder.build
62
63
  end
@@ -109,8 +110,9 @@ module Phronomy
109
110
  class Builder
110
111
  FINISH = Phronomy::WorkflowRunner::FINISH
111
112
 
112
- def initialize(context_class)
113
+ def initialize(context_class, state_store: nil)
113
114
  @context_class = context_class
115
+ @state_store = state_store
114
116
  @initial = nil
115
117
  # { node_name => callable }
116
118
  @states = {}
@@ -170,112 +172,50 @@ module Phronomy
170
172
  # Builds and returns a Phronomy::Workflow backed by a WorkflowRunner.
171
173
  def build
172
174
  nodes = @states.dup
173
- edges = build_edges
174
- conditional_edges = build_conditional_edges
175
- wait_states = build_wait_states
175
+
176
+ # After-transitions: { from => to }
177
+ # Unconditional transitions that fire automatically after an action state completes.
178
+ after_transitions = @after_transitions.each_with_object({}) do |t, h|
179
+ h[t[:from]] = t[:to]
180
+ end
181
+
182
+ # Route transitions: { from => {event_name:, entries: [{guard:, to:}, ...]} }
183
+ # Events declared from action states (not wait states) fire automatically
184
+ # after the action completes. The event name is used to register the
185
+ # state_machines event and may be any symbol (e.g. :route, :route_review).
186
+ # Declaration order is preserved so guarded entries appear before fallbacks.
187
+ route_transitions = {}
188
+
189
+ # External events: { event_name => [{from:, to:, guard:}, ...] }
190
+ # Events declared from wait states, triggered by human input (e.g. :approve).
191
+ external_events = {}
192
+
193
+ @event_transitions.each do |t|
194
+ if @wait_state_names.include?(t[:from])
195
+ # Source is a wait state → external event
196
+ external_events[t[:name]] ||= []
197
+ external_events[t[:name]] << {from: t[:from], to: t[:to], guard: t[:guard]}
198
+ else
199
+ # Source is an action state → routing event (auto-fires after action)
200
+ # The event name is taken from the first declaration for each from-state.
201
+ route_transitions[t[:from]] ||= {event_name: t[:name], entries: []}
202
+ route_transitions[t[:from]][:entries] << {guard: t[:guard], to: t[:to]}
203
+ end
204
+ end
176
205
 
177
206
  runner = Phronomy::WorkflowRunner.new(
178
207
  state_class: @context_class,
179
208
  nodes: nodes,
180
- edges: edges,
181
- conditional_edges: conditional_edges,
209
+ after_transitions: after_transitions,
210
+ route_transitions: route_transitions,
211
+ external_events: external_events,
182
212
  entry_point: @initial || nodes.keys.first,
183
- wait_states: wait_states
213
+ wait_state_names: @wait_state_names,
214
+ state_store: @state_store
184
215
  )
185
216
 
186
217
  Workflow.new(runner)
187
218
  end
188
-
189
- private
190
-
191
- # Converts @after_transitions and non-guarded @event_transitions into
192
- # the edges hash expected by WorkflowRunner: { from => [{to:, condition:}] }
193
- #
194
- # Event transitions whose from-node also has guarded transitions are omitted
195
- # here; they are handled inside build_conditional_edges as a fallback.
196
- def build_edges
197
- edges = {}
198
-
199
- # After-transitions (unconditional edges fired after action completes)
200
- @after_transitions.each do |t|
201
- edges[t[:from]] ||= []
202
- edges[t[:from]] << {to: t[:to], condition: nil}
203
- end
204
-
205
- # Collect from-nodes that already have at least one guarded event
206
- from_with_guards = @event_transitions.select { |t| t[:guard] }.map { |t| t[:from] }.to_set
207
-
208
- # Unconditional event transitions are plain edges ONLY when no guarded
209
- # event exists from the same source node. When guards are present the
210
- # unguarded transition acts as a fallback and is wired inside
211
- # build_conditional_edges instead.
212
- @event_transitions.reject { |t| t[:guard] }.each do |t|
213
- next if from_with_guards.include?(t[:from])
214
- edges[t[:from]] ||= []
215
- edges[t[:from]] << {to: t[:to], condition: nil}
216
- end
217
-
218
- edges
219
- end
220
-
221
- # Converts guarded event transitions into the conditional_edges hash:
222
- # { from => { condition: Proc, mapping: nil } }
223
- #
224
- # Multiple guarded transitions from the same source are combined into a
225
- # single routing proc. An unguarded transition from the same source is
226
- # used as an automatic fallback when all guards fail.
227
- def build_conditional_edges
228
- conditional_edges = {}
229
-
230
- guarded = @event_transitions.select { |t| t[:guard] }
231
- guarded.group_by { |t| t[:from] }.each do |from, transitions|
232
- # Unguarded fallback for this from-node (may be nil)
233
- fallback = @event_transitions.find { |t| t[:from] == from && t[:guard].nil? }
234
-
235
- routing = lambda do |state|
236
- matched = transitions.find { |t| t[:guard].call(state) }
237
- next matched[:to] if matched
238
- fallback&.fetch(:to)
239
- end
240
- conditional_edges[from] = {condition: routing, mapping: nil}
241
- end
242
-
243
- conditional_edges
244
- end
245
-
246
- # Converts wait_state declarations plus event-driven transitions *to*
247
- # wait states into the wait_states hash:
248
- # { wait_state_name => { resume_event: Symbol, resume_to: Symbol } }
249
- #
250
- # For each wait state, we look for the first event declared as
251
- # `event :X, from: :wait_state_name, to: :Y` and use that as the
252
- # resume_event / resume_to pair. If multiple events exist for the same
253
- # wait state, subsequent ones are registered as additional named events.
254
- def build_wait_states
255
- wait_states = {}
256
-
257
- @wait_state_names.each do |ws|
258
- # Find events that originate from this wait state
259
- outgoing = @event_transitions.select { |t| t[:from] == ws }
260
- primary = outgoing.first
261
-
262
- wait_states[ws] = {
263
- resume_event: primary&.fetch(:name),
264
- resume_to: primary&.fetch(:to)
265
- }
266
-
267
- # Additional events from the same wait state are also registered so
268
- # that send_event(:other_event) works for branching wait states.
269
- outgoing.drop(1).each do |t|
270
- wait_states[:"#{ws}__#{t[:name]}"] = {
271
- resume_event: t[:name],
272
- resume_to: t[:to]
273
- }
274
- end
275
- end
276
-
277
- wait_states
278
- end
279
219
  end
280
220
  end
281
221
  end
@@ -8,35 +8,51 @@ module Phronomy
8
8
  # Manages node execution, phase transitions, halt/resume, and wait states.
9
9
  # Instantiated by Phronomy::Workflow and used internally.
10
10
  #
11
- # Wait states (registered via wait_states:) are virtual nodes
12
- # that automatically halt execution when reached. They can be resumed with
13
- # either #resume (generic) or #send_event (event-typed).
11
+ # == Design principle
14
12
  #
15
- # Internally, a state_machines-based PhaseTracker class is generated at
16
- # initialization time. The tracker validates phase transitions during
17
- # execution; invalid transitions are logged as warnings without halting.
13
+ # State transitions are driven entirely by state_machines. The PhaseTracker
14
+ # holds a reference to the current WorkflowContext via +attr_accessor :context+,
15
+ # and guard lambdas evaluate +m.context+ (the WorkflowContext) rather than
16
+ # the PhaseTracker itself. This ensures that "what happens next" is always
17
+ # determined by the declared state machine topology, never by Phronomy internals.
18
+ #
19
+ # == Three transition categories registered in PhaseTracker
20
+ #
21
+ # 1. advance_<from> — automatic, unconditional after-transitions
22
+ # fired when an action state's action completes
23
+ # (declared with +after :foo, to: :bar+)
24
+ #
25
+ # 2. route — a single event that carries all guarded transitions
26
+ # (declared with +event :route, from: :foo, guard: ..., to: :bar+)
27
+ # Guards are evaluated in declaration order; first match wins.
28
+ # An unguarded fallback, if declared, is evaluated last.
29
+ #
30
+ # 3. <event_name> — external events triggered by human input, originating
31
+ # from wait states
32
+ # (declared with +event :approve, from: :awaiting, to: :run+)
18
33
  class WorkflowRunner
19
34
  include Phronomy::Runnable
20
35
 
21
36
  # Sentinel value for the terminal state of a workflow.
22
37
  FINISH = :__end__
23
38
 
24
- def initialize(state_class:, nodes:, edges:, conditional_edges:, entry_point:,
25
- before_callbacks: {}, after_callbacks: {}, wait_states: {}, state_store: nil)
39
+ def initialize(state_class:, nodes:, after_transitions:, route_transitions:,
40
+ external_events:, entry_point:, wait_state_names: [],
41
+ before_callbacks: {}, after_callbacks: {}, state_store: nil)
26
42
  @state_class = state_class
27
43
  @nodes = nodes
28
- @edges = edges
29
- @conditional_edges = conditional_edges
44
+ @after_transitions = after_transitions # { from => to }
45
+ @route_transitions = route_transitions # { from => [{guard:, to:}, ...] }
46
+ @external_events = external_events # { name => [{from:, to:, guard:}, ...] }
30
47
  @entry_point = entry_point
48
+ @wait_state_names = wait_state_names
31
49
  @before_callbacks = before_callbacks.dup
32
50
  @after_callbacks = after_callbacks.dup
33
- # { wait_state_name => { resume_event: Symbol, resume_to: Symbol } }
34
- @wait_states = wait_states.dup
35
51
  @state_store_override = state_store
36
52
  @phase_machine_class = build_phase_machine_class
37
53
  end
38
54
 
39
- # Executes the workflow from the entry point.
55
+ # Executes the workflow from the initial state.
40
56
  # @param input [Hash] initial context field values
41
57
  # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
42
58
  # @return [Object] final context (includes Phronomy::WorkflowContext)
@@ -55,9 +71,7 @@ module Phronomy
55
71
  end
56
72
  end
57
73
 
58
- # Generic resume. Routes based on the current phase encoding.
59
- # Equivalent to +send_event(state:, event: :resume, input:)+.
60
- #
74
+ # Generic resume. Equivalent to +send_event(state:, event: :resume, input:)+.
61
75
  # @param state [Object] halted context
62
76
  # @param input [Hash, nil] optional field updates to merge before resuming
63
77
  # @return [Object] final context
@@ -67,14 +81,11 @@ module Phronomy
67
81
 
68
82
  # Fires a named event to advance a halted workflow.
69
83
  #
70
- # The special event +:resume+ is accepted for all halt types:
71
- # - Named wait state resumes at +resume_to+ node
72
- #
73
- # Any other event name must match the +resume_event:+ declared in
74
- # the wait_states configuration.
84
+ # The special event +:resume+ selects the first external event registered
85
+ # for the current wait state and fires it.
75
86
  #
76
87
  # @param state [Object] halted context
77
- # @param event [Symbol] +:resume+ for generic resumption, or a named event
88
+ # @param event [Symbol] named event or +:resume+ for generic resumption
78
89
  # @param input [Hash, nil] optional field updates to merge before resuming
79
90
  # @return [Object] final context
80
91
  def send_event(state:, event:, input: nil)
@@ -82,21 +93,30 @@ module Phronomy
82
93
  event = event.to_sym
83
94
  current_phase = state.phase
84
95
 
85
- if event == :resume
86
- # Named wait state: use resume_to
87
- if @wait_states.key?(current_phase)
88
- return run_graph(state, from_node: @wait_states[current_phase][:resume_to])
96
+ tracker = new_phase_machine(current_phase)
97
+ tracker.context = state
98
+
99
+ ev_to_fire = if event == :resume
100
+ # Find the first external event that can originate from the current wait state.
101
+ name, = @external_events.find { |_, ts| ts.any? { |t| t[:from] == current_phase } }
102
+ unless name
103
+ raise ArgumentError,
104
+ "No external event registered for wait state #{current_phase.inspect}"
105
+ end
106
+ name
107
+ else
108
+ unless @external_events.key?(event)
109
+ raise ArgumentError,
110
+ "Unknown event #{event.inspect}. Valid events: #{@external_events.keys.inspect}"
89
111
  end
90
- raise ArgumentError, "State has no wait state registered for phase #{current_phase.inspect}"
112
+ event
91
113
  end
92
114
 
93
- # Named event lookup
94
- _, wait_cfg = @wait_states.find { |_, c| c[:resume_event] == event }
95
- unless wait_cfg
96
- valid = @wait_states.values.filter_map { |c| c[:resume_event] }.uniq
97
- raise ArgumentError, "Unknown event #{event.inspect}. Valid events: #{valid.inspect}"
98
- end
99
- run_graph(state, from_node: wait_cfg[:resume_to])
115
+ fire_event!(tracker, ev_to_fire, current_phase)
116
+
117
+ next_phase = tracker.phase.to_sym
118
+ next_node = (next_phase == :__end__) ? FINISH : next_phase
119
+ run_graph(state, from_node: next_node)
100
120
  end
101
121
 
102
122
  # Streaming execution. Yields { node: Symbol, state: Object } after each node completes.
@@ -121,6 +141,7 @@ module Phronomy
121
141
  def run_graph(state, from_node: nil, recursion_limit: 25, &event_block)
122
142
  current_node = from_node || @entry_point
123
143
  tracker = new_phase_machine(current_node)
144
+ tracker.context = state
124
145
  step = 0
125
146
 
126
147
  while current_node && current_node != FINISH
@@ -129,15 +150,15 @@ module Phronomy
129
150
  "Recursion limit (#{recursion_limit}) exceeded"
130
151
  end
131
152
 
132
- # Auto-halt at wait states.
133
- if @wait_states.key?(current_node)
153
+ # Auto-halt at wait states: save context and return to caller.
154
+ if @wait_state_names.include?(current_node)
134
155
  state.set_graph_metadata(thread_id: state.thread_id, phase: current_node)
135
156
  state_store&.save(state)
136
157
  return state
137
158
  end
138
159
 
139
160
  node_fn = @nodes[current_node]
140
- raise ArgumentError, "Node #{current_node} is not defined" unless node_fn
161
+ raise ArgumentError, "Node #{current_node.inspect} is not defined" unless node_fn
141
162
 
142
163
  result = node_fn.call(state)
143
164
  state = case result
@@ -146,14 +167,29 @@ module Phronomy
146
167
  when nil then state
147
168
  else
148
169
  raise ArgumentError,
149
- "Node #{current_node} returned #{result.class}; expected Hash, #{@state_class}, or nil"
170
+ "Node #{current_node} returned #{result.class}; " \
171
+ "expected Hash, #{@state_class}, or nil"
150
172
  end
151
173
 
174
+ # Update tracker so guards see the freshest context.
175
+ tracker.context = state
176
+
152
177
  event_block&.call({node: current_node, state: state})
153
178
 
154
- next_n = resolve_next_node(current_node, state)
155
- advance_phase(tracker, current_node, next_n || FINISH)
156
- current_node = next_n
179
+ # Delegate transition decision to state_machines.
180
+ if @after_transitions.key?(current_node)
181
+ fire_event!(tracker, :"advance_#{current_node}", current_node)
182
+ elsif @route_transitions.key?(current_node)
183
+ ev_name = @route_transitions[current_node][:event_name]
184
+ fire_event!(tracker, ev_name, current_node)
185
+ end
186
+ # Nodes with no declared outgoing transition are treated as terminal:
187
+ # next_phase == current_node triggers the FINISH assignment below.
188
+
189
+ next_phase = tracker.phase.to_sym
190
+ # When next_phase == current_node: no transition fired (terminal node) → end.
191
+ # When next_phase == :__end__ (== FINISH): route led to finish → exit loop.
192
+ current_node = (next_phase == current_node) ? FINISH : next_phase
157
193
 
158
194
  step += 1
159
195
  end
@@ -163,100 +199,87 @@ module Phronomy
163
199
  state
164
200
  end
165
201
 
166
- def resolve_next_node(current, state)
167
- if (cond = @conditional_edges[current])
168
- result = cond[:condition].call(state)
169
- if cond[:mapping]
170
- unless cond[:mapping].key?(result)
171
- raise ArgumentError,
172
- "Conditional edge from #{current.inspect} returned #{result.inspect}, " \
173
- "which is not present in the mapping (#{cond[:mapping].keys.inspect})"
174
- end
175
- return cond[:mapping][result]
176
- end
177
- return result
178
- end
202
+ # Fires +event_name+ on +tracker+, raising a descriptive error if no
203
+ # transition matches. state_machines event methods return false when no
204
+ # transition can be taken (invalid state or all guards fail).
205
+ def fire_event!(tracker, event_name, from_node)
206
+ return if tracker.send(event_name)
179
207
 
180
- edges = @edges[current]
181
- return nil unless edges&.any?
182
-
183
- matched = edges.find { |edge| edge[:condition].nil? || edge[:condition].call(state) }
184
- matched&.fetch(:to)
208
+ raise ArgumentError,
209
+ "Transition from #{from_node.inspect} via event #{event_name.inspect} failed. " \
210
+ "Ensure at least one guard matches or add a fallback (no-guard) transition."
185
211
  end
186
212
 
187
- # Builds a state_machines-based PhaseTracker class encoding the workflow topology.
188
- # Returns nil if the build fails (execution continues without phase validation).
213
+ # Builds the PhaseTracker class backed by state_machines.
214
+ #
215
+ # Three event types are registered:
216
+ # advance_<from> — unconditional after-transitions
217
+ # route — all guarded routing transitions (one event, multiple transitions)
218
+ # <external_name> — external events originating from wait states
219
+ #
220
+ # Guard lambdas bridge the PhaseTracker and WorkflowContext via +m.context+.
189
221
  def build_phase_machine_class
190
222
  entry = @entry_point
191
- nodes = @nodes.keys
192
- ws_names = @wait_states.keys
193
-
194
- # Collect all valid (from, to) pairs; use a Hash to deduplicate.
195
- trans = {}
196
-
197
- @edges.each do |from, edge_list|
198
- edge_list.each do |edge|
199
- to = (edge[:to] == FINISH) ? :__end__ : edge[:to]
200
- trans[[from, to]] = true
201
- end
202
- end
203
-
204
- @conditional_edges.each do |from, cfg|
205
- targets = cfg[:mapping] ? cfg[:mapping].values : (nodes + ws_names + [:__end__])
206
- targets.each do |to|
207
- t = (to == FINISH) ? :__end__ : to
208
- trans[[from, t]] = true
209
- end
210
- end
211
-
212
- # Any node can be terminal (no outgoing edge = implicit advance to :__end__).
213
- nodes.each { |n| trans[[n, :__end__]] = true }
214
-
215
- all_states = (nodes + ws_names + [:__end__]).uniq
216
- trans_pairs = trans.keys
223
+ all_states = (@nodes.keys + @wait_state_names + [:__end__]).uniq
224
+ after_trans = @after_transitions # { from => to }
225
+ route_trans = @route_transitions # { from => [{guard:, to:}, ...] }
226
+ ext_events = @external_events # { name => [{from:, to:, guard:}, ...] }
217
227
 
218
228
  Class.new do
229
+ # Holds the current WorkflowContext so guards can read it.
230
+ attr_accessor :context
231
+
219
232
  state_machine :phase, initial: entry do
220
233
  all_states.each { |s| state s }
221
- trans_pairs.each do |from, to|
222
- event :"advance_#{from}_to_#{to}" do
234
+
235
+ # 1. After-transitions: unconditional, fire on action completion.
236
+ after_trans.each do |from, to|
237
+ event :"advance_#{from}" do
223
238
  transition from => to
224
239
  end
225
240
  end
241
+
242
+ # 2. Route events: one named event per from-state (name may vary).
243
+ # Declaration order is preserved; guards first, unguarded fallback last.
244
+ route_trans.each do |from, routing|
245
+ event routing[:event_name] do
246
+ routing[:entries].each do |t|
247
+ if t[:guard]
248
+ guard_proc = t[:guard]
249
+ transition from => t[:to], :if => ->(m) { guard_proc.call(m.context) }
250
+ else
251
+ transition from => t[:to]
252
+ end
253
+ end
254
+ end
255
+ end
256
+
257
+ # 3. External events: human-in-the-loop triggers from wait states.
258
+ ext_events.each do |ev_name, transitions|
259
+ event ev_name do
260
+ transitions.each do |t|
261
+ if t[:guard]
262
+ guard_proc = t[:guard]
263
+ transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
264
+ else
265
+ transition t[:from] => t[:to]
266
+ end
267
+ end
268
+ end
269
+ end
226
270
  end
227
271
  end
228
272
  rescue => e
229
- warn "[Phronomy] Could not build phase machine: #{e.message}"
230
- nil
273
+ raise ArgumentError, "Failed to build phase machine: #{e.message}"
231
274
  end
232
275
 
233
- # Creates a PhaseTracker instance initialised to +from_node+.
276
+ # Creates a PhaseTracker instance initialized to +from_node+.
234
277
  def new_phase_machine(from_node)
235
- return nil unless @phase_machine_class && from_node
236
-
237
278
  machine = @phase_machine_class.new
279
+ # Override the initial state set by state_machine's initializer so we can
280
+ # resume from an arbitrary node (e.g. after a wait state).
238
281
  machine.instance_variable_set(:@phase, from_node.to_s)
239
282
  machine
240
- rescue => e
241
- warn "[Phronomy] Phase machine init failed: #{e.message}"
242
- nil
243
- end
244
-
245
- # Fires a transition event on the tracker from +from+ to +to+.
246
- # Logs a warning if the transition is not declared; does not raise.
247
- def advance_phase(tracker, from, to)
248
- return unless tracker && from
249
-
250
- to_sym = case to
251
- when nil, FINISH then :__end__
252
- else to
253
- end
254
- event_name = :"advance_#{from}_to_#{to_sym}"
255
- unless tracker.fire_events(event_name)
256
- warn "[Phronomy] Unexpected phase transition #{from.inspect} → #{to_sym.inspect}"
257
- end
258
- rescue => e
259
- warn "[Phronomy] Phase tracker error (#{from}→#{to}): #{e.message}"
260
283
  end
261
284
  end
262
285
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-14 00:00:00.000000000 Z
11
+ date: 2026-05-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -157,6 +157,7 @@ files:
157
157
  - lib/phronomy/state_store/encryptor.rb
158
158
  - lib/phronomy/state_store/encryptor/active_support.rb
159
159
  - lib/phronomy/state_store/encryptor/base.rb
160
+ - lib/phronomy/state_store/file.rb
160
161
  - lib/phronomy/state_store/in_memory.rb
161
162
  - lib/phronomy/state_store/redis.rb
162
163
  - lib/phronomy/thread_actor_registry.rb