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 +4 -4
- data/CHANGELOG.md +33 -0
- data/lib/phronomy/state_store/file.rb +85 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +40 -100
- data/lib/phronomy/workflow_runner.rb +137 -114
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 84f1944fd2628cdc04bfdaabab8ca91beeb624b7904ea9f50499f863e86e3f4c
|
|
4
|
+
data.tar.gz: 96b4b7c5258b3f2b9c710115e1b27230a87013c9e4953c05d40ea7ae227c43d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/phronomy/version.rb
CHANGED
data/lib/phronomy/workflow.rb
CHANGED
|
@@ -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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
181
|
-
|
|
209
|
+
after_transitions: after_transitions,
|
|
210
|
+
route_transitions: route_transitions,
|
|
211
|
+
external_events: external_events,
|
|
182
212
|
entry_point: @initial || nodes.keys.first,
|
|
183
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
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:,
|
|
25
|
-
|
|
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
|
-
@
|
|
29
|
-
@
|
|
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
|
|
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.
|
|
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+
|
|
71
|
-
#
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
112
|
+
event
|
|
91
113
|
end
|
|
92
114
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 @
|
|
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};
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
|
188
|
-
#
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
#
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
230
|
-
nil
|
|
273
|
+
raise ArgumentError, "Failed to build phase machine: #{e.message}"
|
|
231
274
|
end
|
|
232
275
|
|
|
233
|
-
# Creates a PhaseTracker instance
|
|
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.
|
|
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-
|
|
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
|