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.
- checksums.yaml +4 -4
- data/.mutant.yml +21 -0
- data/CHANGELOG.md +379 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +262 -48
- data/RELEASE_CHECKLIST.md +86 -0
- data/SECURITY.md +80 -0
- data/benchmark/baseline.json +9 -0
- data/benchmark/bench_agent_invoke.rb +105 -0
- data/benchmark/bench_context_assembler.rb +46 -0
- data/benchmark/bench_regression.rb +171 -0
- data/benchmark/bench_token_estimator.rb +44 -0
- data/benchmark/bench_tool_schema.rb +69 -0
- data/benchmark/bench_vector_store.rb +39 -0
- data/benchmark/bench_workflow.rb +55 -0
- data/benchmark/run_all.rb +118 -0
- data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
- data/docs/decisions/002-workflow-context-immutability.md +42 -0
- data/docs/decisions/003-event-loop-singleton.md +48 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +48 -0
- data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
- data/docs/decisions/009-state-store-abstraction.md +141 -0
- data/lib/phronomy/agent/base.rb +281 -13
- data/lib/phronomy/agent/before_completion_context.rb +1 -0
- data/lib/phronomy/agent/checkpoint.rb +1 -0
- data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
- data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
- data/lib/phronomy/agent/concerns/retryable.rb +12 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
- data/lib/phronomy/agent/fsm.rb +180 -0
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +123 -11
- data/lib/phronomy/agent/parallel_tool_chat.rb +92 -0
- data/lib/phronomy/agent/react_agent.rb +8 -6
- data/lib/phronomy/agent/runner.rb +2 -0
- data/lib/phronomy/agent/shared_state.rb +11 -0
- data/lib/phronomy/agent/suspend_signal.rb +2 -0
- data/lib/phronomy/agent/team_coordinator.rb +17 -5
- data/lib/phronomy/cancellation_token.rb +92 -0
- data/lib/phronomy/configuration.rb +32 -2
- data/lib/phronomy/context/assembler.rb +6 -0
- data/lib/phronomy/context/compaction_context.rb +2 -0
- data/lib/phronomy/context/context_version_cache.rb +2 -0
- data/lib/phronomy/context/token_budget.rb +3 -0
- data/lib/phronomy/context/token_estimator.rb +9 -2
- data/lib/phronomy/context/trigger_context.rb +1 -0
- data/lib/phronomy/context/trim_context.rb +4 -0
- data/lib/phronomy/context.rb +0 -1
- data/lib/phronomy/embeddings/base.rb +5 -2
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
- data/lib/phronomy/eval/comparison.rb +2 -0
- data/lib/phronomy/eval/dataset.rb +4 -0
- data/lib/phronomy/eval/metrics.rb +6 -0
- data/lib/phronomy/eval/runner.rb +2 -0
- data/lib/phronomy/eval/scorer/base.rb +1 -0
- data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
- data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
- data/lib/phronomy/event.rb +14 -0
- data/lib/phronomy/event_loop.rb +254 -0
- data/lib/phronomy/fsm_session.rb +201 -0
- data/lib/phronomy/generator_verifier.rb +24 -22
- data/lib/phronomy/guardrail/base.rb +3 -0
- data/lib/phronomy/guardrail.rb +0 -1
- data/lib/phronomy/knowledge_source/base.rb +6 -2
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
- data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
- data/lib/phronomy/loader/base.rb +1 -0
- data/lib/phronomy/loader/csv_loader.rb +2 -0
- data/lib/phronomy/loader/markdown_loader.rb +2 -0
- data/lib/phronomy/loader/plain_text_loader.rb +1 -0
- data/lib/phronomy/output_parser/base.rb +1 -0
- data/lib/phronomy/output_parser/json_parser.rb +22 -3
- data/lib/phronomy/output_parser/structured_parser.rb +2 -0
- data/lib/phronomy/prompt_template.rb +5 -0
- data/lib/phronomy/runnable.rb +20 -3
- data/lib/phronomy/splitter/base.rb +2 -0
- data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
- data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
- data/lib/phronomy/state_store/base.rb +48 -0
- data/lib/phronomy/state_store/in_memory.rb +62 -0
- data/lib/phronomy/tool/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +189 -27
- data/lib/phronomy/tool/mcp_tool.rb +68 -13
- data/lib/phronomy/tracing/base.rb +3 -0
- data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
- data/lib/phronomy/vector_store/base.rb +33 -7
- data/lib/phronomy/vector_store/in_memory.rb +16 -7
- data/lib/phronomy/vector_store/pgvector.rb +40 -9
- data/lib/phronomy/vector_store/redis_search.rb +29 -8
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +175 -74
- data/lib/phronomy/workflow_context.rb +55 -5
- data/lib/phronomy/workflow_runner.rb +197 -114
- data/lib/phronomy.rb +74 -1
- data/scripts/api_snapshot.rb +91 -0
- data/scripts/check_api_annotations.rb +68 -0
- data/scripts/check_private_enforcement.rb +93 -0
- data/scripts/check_readme_runnable.rb +98 -0
- data/scripts/run_mutation.sh +46 -0
- metadata +50 -6
- data/lib/phronomy/context/builder.rb +0 -92
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +0 -100
- data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +0 -67
- 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 --
|
|
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
|
-
# :<
|
|
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
|
-
#
|
|
83
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
#
|
|
26
|
-
#
|
|
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
|
-
#
|
|
35
|
+
# (declared with +transition from: :foo, to: :bar+ or
|
|
36
|
+
# +transition from: :foo, guard: ..., to: :bar+)
|
|
29
37
|
#
|
|
30
|
-
#
|
|
38
|
+
# 2. <event_name> — external events triggered by human input, originating
|
|
31
39
|
# from wait states
|
|
32
|
-
# (declared with +
|
|
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:,
|
|
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
|
-
@
|
|
44
|
-
@
|
|
45
|
-
|
|
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
|
-
@
|
|
50
|
-
@
|
|
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("
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 {
|
|
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
|
-
|
|
155
|
+
run_workflow(state, recursion_limit: recursion_limit, &block)
|
|
132
156
|
end
|
|
133
157
|
|
|
134
158
|
private
|
|
135
159
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
|
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)
|
|
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,
|
|
237
|
+
fire_event!(tracker, event, current_state)
|
|
238
|
+
ctx = tracker.context
|
|
160
239
|
next_phase = tracker.phase.to_sym
|
|
161
|
-
# When next_phase ==
|
|
162
|
-
|
|
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
|
|
170
|
-
if @wait_state_names.include?(
|
|
171
|
-
|
|
172
|
-
return
|
|
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
|
-
# --
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
#
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
270
|
+
current_state = FINISH
|
|
205
271
|
end
|
|
206
272
|
end
|
|
207
273
|
|
|
208
|
-
|
|
209
|
-
|
|
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,
|
|
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 #{
|
|
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
|
-
#
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
#
|
|
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 = (@
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
#
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
event :
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
#
|
|
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 +
|
|
289
|
-
def new_phase_machine(
|
|
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
|
|
293
|
-
machine.instance_variable_set(:@phase,
|
|
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
|