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
|
@@ -9,25 +9,32 @@ module Phronomy
|
|
|
9
9
|
class Base
|
|
10
10
|
# Add a document with its vector embedding.
|
|
11
11
|
#
|
|
12
|
-
# @param id
|
|
13
|
-
# @param embedding
|
|
14
|
-
# @param metadata
|
|
15
|
-
|
|
12
|
+
# @param id [String] unique document identifier
|
|
13
|
+
# @param embedding [Array<Float>] vector embedding
|
|
14
|
+
# @param metadata [Hash] arbitrary metadata (e.g. the original message object)
|
|
15
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
16
|
+
# @api public
|
|
17
|
+
def add(id:, embedding:, metadata: {}, cancellation_token: nil)
|
|
18
|
+
cancellation_token&.raise_if_cancelled!
|
|
16
19
|
raise NotImplementedError, "#{self.class}#add is not implemented"
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
# Return the k most similar documents to the query embedding.
|
|
20
23
|
#
|
|
21
|
-
# @param query_embedding
|
|
22
|
-
# @param k
|
|
24
|
+
# @param query_embedding [Array<Float>]
|
|
25
|
+
# @param k [Integer] number of results
|
|
26
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
23
27
|
# @return [Array<Hash>] each element: { id:, score:, metadata: }
|
|
24
|
-
|
|
28
|
+
# @api public
|
|
29
|
+
def search(query_embedding:, k: 5, cancellation_token: nil)
|
|
30
|
+
cancellation_token&.raise_if_cancelled!
|
|
25
31
|
raise NotImplementedError, "#{self.class}#search is not implemented"
|
|
26
32
|
end
|
|
27
33
|
|
|
28
34
|
# Remove a single document by id.
|
|
29
35
|
#
|
|
30
36
|
# @param id [String] document identifier
|
|
37
|
+
# @api public
|
|
31
38
|
def remove(id:)
|
|
32
39
|
raise NotImplementedError, "#{self.class}#remove is not implemented"
|
|
33
40
|
end
|
|
@@ -37,6 +44,14 @@ module Phronomy
|
|
|
37
44
|
raise NotImplementedError, "#{self.class}#clear is not implemented"
|
|
38
45
|
end
|
|
39
46
|
|
|
47
|
+
# Return the number of documents stored.
|
|
48
|
+
#
|
|
49
|
+
# @return [Integer]
|
|
50
|
+
# @api public
|
|
51
|
+
def size
|
|
52
|
+
raise NotImplementedError, "#{self.class}#size is not implemented"
|
|
53
|
+
end
|
|
54
|
+
|
|
40
55
|
private
|
|
41
56
|
|
|
42
57
|
# Validates that embedding has the expected dimension.
|
|
@@ -51,6 +66,17 @@ module Phronomy
|
|
|
51
66
|
raise ArgumentError,
|
|
52
67
|
"Embedding dimension mismatch: expected #{expected_dimension}, got #{actual}"
|
|
53
68
|
end
|
|
69
|
+
|
|
70
|
+
# Validates that k is a positive integer.
|
|
71
|
+
# Accepts any value accepted by Integer() (e.g. "5"), but raises
|
|
72
|
+
# ArgumentError for non-integer strings, zero, and negative values.
|
|
73
|
+
def validate_k!(k)
|
|
74
|
+
int_k = Integer(k)
|
|
75
|
+
raise ArgumentError, "k must be a positive integer, got #{int_k}" unless int_k >= 1
|
|
76
|
+
int_k
|
|
77
|
+
rescue ArgumentError => e
|
|
78
|
+
raise ArgumentError, "k must be a positive integer: #{e.message}"
|
|
79
|
+
end
|
|
54
80
|
end
|
|
55
81
|
end
|
|
56
82
|
end
|
|
@@ -16,15 +16,19 @@ module Phronomy
|
|
|
16
16
|
# When nil, the dimension is inferred from the first call to #add.
|
|
17
17
|
# For multi-threaded use, pass dimension: explicitly; concurrent first
|
|
18
18
|
# adds are not guaranteed to be race-free.
|
|
19
|
+
# @api public
|
|
19
20
|
def initialize(dimension: nil)
|
|
20
21
|
@documents = {}
|
|
21
22
|
@expected_dimension = dimension
|
|
22
23
|
end
|
|
23
24
|
|
|
24
|
-
# @param id
|
|
25
|
-
# @param embedding
|
|
26
|
-
# @param metadata
|
|
27
|
-
|
|
25
|
+
# @param id [String]
|
|
26
|
+
# @param embedding [Array<Float>]
|
|
27
|
+
# @param metadata [Hash]
|
|
28
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
29
|
+
# @api public
|
|
30
|
+
def add(id:, embedding:, metadata: {}, cancellation_token: nil)
|
|
31
|
+
cancellation_token&.raise_if_cancelled!
|
|
28
32
|
# Establish expected dimension on first add, then validate.
|
|
29
33
|
@expected_dimension ||= embedding.size
|
|
30
34
|
validate_embedding_dimension!(embedding, @expected_dimension)
|
|
@@ -32,10 +36,14 @@ module Phronomy
|
|
|
32
36
|
self
|
|
33
37
|
end
|
|
34
38
|
|
|
35
|
-
# @param query_embedding
|
|
36
|
-
# @param k
|
|
39
|
+
# @param query_embedding [Array<Float>]
|
|
40
|
+
# @param k [Integer]
|
|
41
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
37
42
|
# @return [Array<Hash>] sorted by descending score
|
|
38
|
-
|
|
43
|
+
# @api public
|
|
44
|
+
def search(query_embedding:, k: 5, cancellation_token: nil)
|
|
45
|
+
cancellation_token&.raise_if_cancelled!
|
|
46
|
+
k = validate_k!(k)
|
|
39
47
|
# search never establishes dimension; validate only when dimension is known.
|
|
40
48
|
validate_embedding_dimension!(query_embedding, @expected_dimension)
|
|
41
49
|
# Take an atomic snapshot before iterating. Hash#dup is a C-level
|
|
@@ -62,6 +70,7 @@ module Phronomy
|
|
|
62
70
|
end
|
|
63
71
|
|
|
64
72
|
# @return [Integer] number of documents stored
|
|
73
|
+
# @api public
|
|
65
74
|
def size
|
|
66
75
|
@documents.size
|
|
67
76
|
end
|
|
@@ -22,6 +22,7 @@ module Phronomy
|
|
|
22
22
|
# @param dimension [Integer, nil] expected embedding dimension for Phronomy-side
|
|
23
23
|
# pre-validation. When nil, dimension enforcement is delegated to the
|
|
24
24
|
# database schema; no pre-validation is performed by Phronomy.
|
|
25
|
+
# @api public
|
|
25
26
|
def initialize(model_class:, dimension: nil)
|
|
26
27
|
begin
|
|
27
28
|
require "pgvector"
|
|
@@ -34,10 +35,13 @@ module Phronomy
|
|
|
34
35
|
@dimension = dimension
|
|
35
36
|
end
|
|
36
37
|
|
|
37
|
-
# @param id
|
|
38
|
-
# @param embedding
|
|
39
|
-
# @param metadata
|
|
40
|
-
|
|
38
|
+
# @param id [String]
|
|
39
|
+
# @param embedding [Array<Float>]
|
|
40
|
+
# @param metadata [Hash]
|
|
41
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
42
|
+
# @api public
|
|
43
|
+
def add(id:, embedding:, metadata: {}, cancellation_token: nil)
|
|
44
|
+
cancellation_token&.raise_if_cancelled!
|
|
41
45
|
validate_embedding_dimension!(embedding, @dimension)
|
|
42
46
|
@model_class.upsert(
|
|
43
47
|
{id: id, embedding: safe_vector(embedding), metadata: metadata.to_json},
|
|
@@ -46,13 +50,16 @@ module Phronomy
|
|
|
46
50
|
self
|
|
47
51
|
end
|
|
48
52
|
|
|
49
|
-
# @param query_embedding
|
|
50
|
-
# @param k
|
|
53
|
+
# @param query_embedding [Array<Float>]
|
|
54
|
+
# @param k [Integer]
|
|
55
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
51
56
|
# @return [Array<Hash>] sorted by descending similarity score
|
|
52
|
-
|
|
57
|
+
# @api public
|
|
58
|
+
def search(query_embedding:, k: 5, cancellation_token: nil)
|
|
59
|
+
cancellation_token&.raise_if_cancelled!
|
|
60
|
+
k_safe = validate_k!(k)
|
|
53
61
|
validate_embedding_dimension!(query_embedding, @dimension)
|
|
54
62
|
vec = safe_vector_literal(query_embedding)
|
|
55
|
-
k_safe = Integer(k)
|
|
56
63
|
conn = @model_class.connection
|
|
57
64
|
quoted_vec = "#{conn.quote(vec)}::vector"
|
|
58
65
|
|
|
@@ -64,7 +71,7 @@ module Phronomy
|
|
|
64
71
|
{
|
|
65
72
|
id: r.id.to_s,
|
|
66
73
|
score: r.score.to_f,
|
|
67
|
-
metadata:
|
|
74
|
+
metadata: parse_metadata(r.metadata)
|
|
68
75
|
}
|
|
69
76
|
end
|
|
70
77
|
end
|
|
@@ -79,8 +86,32 @@ module Phronomy
|
|
|
79
86
|
self
|
|
80
87
|
end
|
|
81
88
|
|
|
89
|
+
# Returns the number of documents in the backing table.
|
|
90
|
+
def size
|
|
91
|
+
@model_class.count
|
|
92
|
+
end
|
|
93
|
+
|
|
82
94
|
private
|
|
83
95
|
|
|
96
|
+
# Parses a metadata value returned by the pg driver.
|
|
97
|
+
# Handles NULL (nil), already-parsed Hash, and JSON string forms.
|
|
98
|
+
def parse_metadata(raw)
|
|
99
|
+
return {} if raw.nil?
|
|
100
|
+
return symbolize_hash_keys(raw) if raw.is_a?(Hash)
|
|
101
|
+
|
|
102
|
+
parsed = JSON.parse(raw.to_s, symbolize_names: true)
|
|
103
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
104
|
+
rescue JSON::ParserError
|
|
105
|
+
{}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Recursively symbolizes keys for an already-parsed Hash.
|
|
109
|
+
def symbolize_hash_keys(hash)
|
|
110
|
+
hash.each_with_object({}) do |(k, v), h|
|
|
111
|
+
h[k.to_sym] = v.is_a?(Hash) ? symbolize_hash_keys(v) : v
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
84
115
|
# Validates that all elements are numeric and converts to a pgvector-
|
|
85
116
|
# compatible literal string (e.g. "[1.0,0.5,-0.3]").
|
|
86
117
|
def safe_vector_literal(embedding)
|
|
@@ -30,6 +30,7 @@ module Phronomy
|
|
|
30
30
|
# dimension: explicitly. Without it, a freshly constructed instance
|
|
31
31
|
# treats the index as uninitialized until #add is called, and #search
|
|
32
32
|
# silently returns [] in the meantime.
|
|
33
|
+
# @api public
|
|
33
34
|
def initialize(redis:, index_name: "phronomy_vectors", dimension: nil)
|
|
34
35
|
begin
|
|
35
36
|
require "redis"
|
|
@@ -45,10 +46,13 @@ module Phronomy
|
|
|
45
46
|
@mutex = Mutex.new
|
|
46
47
|
end
|
|
47
48
|
|
|
48
|
-
# @param id
|
|
49
|
-
# @param embedding
|
|
50
|
-
# @param metadata
|
|
51
|
-
|
|
49
|
+
# @param id [String]
|
|
50
|
+
# @param embedding [Array<Float>]
|
|
51
|
+
# @param metadata [Hash]
|
|
52
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
53
|
+
# @api public
|
|
54
|
+
def add(id:, embedding:, metadata: {}, cancellation_token: nil)
|
|
55
|
+
cancellation_token&.raise_if_cancelled!
|
|
52
56
|
# Establish expected dimension on first add (not race-free for concurrent
|
|
53
57
|
# first adds), then validate, then create/reuse the index.
|
|
54
58
|
@dimension ||= embedding.size
|
|
@@ -62,17 +66,20 @@ module Phronomy
|
|
|
62
66
|
self
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
# @param query_embedding
|
|
66
|
-
# @param k
|
|
69
|
+
# @param query_embedding [Array<Float>]
|
|
70
|
+
# @param k [Integer]
|
|
71
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil]
|
|
67
72
|
# @return [Array<Hash>] sorted by descending similarity score
|
|
68
|
-
|
|
73
|
+
# @api public
|
|
74
|
+
def search(query_embedding:, k: 5, cancellation_token: nil)
|
|
75
|
+
cancellation_token&.raise_if_cancelled!
|
|
69
76
|
# search never establishes dimension. If dimension is unknown and the
|
|
70
77
|
# index has not been created yet, there are no documents to return.
|
|
71
78
|
return [] if @dimension.nil? && !@index_created
|
|
72
79
|
|
|
73
80
|
validate_embedding_dimension!(query_embedding, @dimension)
|
|
74
81
|
ensure_index!(@dimension)
|
|
75
|
-
k_safe =
|
|
82
|
+
k_safe = validate_k!(k)
|
|
76
83
|
blob = pack_vector(query_embedding)
|
|
77
84
|
|
|
78
85
|
raw = @redis.call(
|
|
@@ -92,6 +99,20 @@ module Phronomy
|
|
|
92
99
|
self
|
|
93
100
|
end
|
|
94
101
|
|
|
102
|
+
# Returns the number of documents indexed.
|
|
103
|
+
# Queries FT.INFO when the index has been created; returns 0 otherwise.
|
|
104
|
+
def size
|
|
105
|
+
return 0 unless @index_created
|
|
106
|
+
|
|
107
|
+
raw = @redis.call("FT.INFO", @index_name)
|
|
108
|
+
return 0 unless raw.is_a?(Array)
|
|
109
|
+
|
|
110
|
+
idx = raw.index("num_docs")
|
|
111
|
+
idx ? raw[idx + 1].to_i : 0
|
|
112
|
+
rescue
|
|
113
|
+
0
|
|
114
|
+
end
|
|
115
|
+
|
|
95
116
|
def clear
|
|
96
117
|
@mutex.synchronize do
|
|
97
118
|
begin
|
data/lib/phronomy/version.rb
CHANGED
data/lib/phronomy/workflow.rb
CHANGED
|
@@ -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
|
|
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
|
|
19
|
-
# state :process
|
|
18
|
+
# state :fetch
|
|
19
|
+
# state :process
|
|
20
20
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
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
|
|
35
|
+
# state :propose
|
|
33
36
|
# wait_state :awaiting_approval
|
|
34
|
-
# state :execute
|
|
37
|
+
# state :execute
|
|
38
|
+
#
|
|
39
|
+
# entry :propose, PROPOSE_NODE
|
|
40
|
+
# entry :execute, EXECUTE_NODE
|
|
35
41
|
#
|
|
36
|
-
#
|
|
37
|
-
#
|
|
42
|
+
# transition from: :propose, to: :awaiting_approval
|
|
43
|
+
# transition from: :execute, to: :__finish__
|
|
38
44
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
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,23 +51,29 @@ module Phronomy
|
|
|
45
51
|
#
|
|
46
52
|
# == Conditional transitions
|
|
47
53
|
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
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
|
|
53
59
|
|
|
54
60
|
# Defines a new Workflow.
|
|
55
61
|
# @param context_class [Class] class that includes Phronomy::WorkflowContext
|
|
62
|
+
# @param state_store [Phronomy::StateStore::Base, nil] optional per-workflow state store.
|
|
63
|
+
# Takes precedence over the global +Phronomy.configuration.state_store+.
|
|
56
64
|
# @yield block evaluated in DSL context
|
|
57
65
|
# @return [Phronomy::Workflow] compiled and ready-to-run workflow instance
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
# @raise [ArgumentError] if no states are declared (empty workflow)
|
|
67
|
+
# @raise [ArgumentError] if any transition references an undeclared +to:+ or +from:+ state
|
|
68
|
+
# @api public
|
|
69
|
+
def self.define(context_class, state_store: nil, &block)
|
|
70
|
+
builder = Builder.new(context_class, state_store: state_store)
|
|
60
71
|
builder.instance_eval(&block)
|
|
61
72
|
builder.build
|
|
62
73
|
end
|
|
63
74
|
|
|
64
75
|
# @param runner [Phronomy::WorkflowRunner]
|
|
76
|
+
# @api public
|
|
65
77
|
def initialize(runner)
|
|
66
78
|
@runner = runner
|
|
67
79
|
end
|
|
@@ -70,6 +82,7 @@ module Phronomy
|
|
|
70
82
|
# @param input [Hash] initial context field values
|
|
71
83
|
# @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
|
|
72
84
|
# @return [Object] final context
|
|
85
|
+
# @api public
|
|
73
86
|
def invoke(input, config: {})
|
|
74
87
|
@runner.invoke(input, config: config)
|
|
75
88
|
end
|
|
@@ -78,6 +91,7 @@ module Phronomy
|
|
|
78
91
|
# @param state [Object] halted context
|
|
79
92
|
# @param input [Hash, nil] optional field updates to merge before resuming
|
|
80
93
|
# @return [Object] final context
|
|
94
|
+
# @api public
|
|
81
95
|
def resume(state:, input: nil)
|
|
82
96
|
@runner.resume(state: state, input: input)
|
|
83
97
|
end
|
|
@@ -87,15 +101,17 @@ module Phronomy
|
|
|
87
101
|
# @param event [Symbol] event name (e.g. :approve, :reject, :resume)
|
|
88
102
|
# @param input [Hash, nil] optional field updates to merge before resuming
|
|
89
103
|
# @return [Object] final context
|
|
104
|
+
# @api public
|
|
90
105
|
def send_event(state:, event:, input: nil)
|
|
91
106
|
@runner.send_event(state: state, event: event, input: input)
|
|
92
107
|
end
|
|
93
108
|
|
|
94
|
-
# Streaming execution. Yields {
|
|
109
|
+
# Streaming execution. Yields { state: Symbol, context: Object } after each state action.
|
|
95
110
|
# @param input [Hash]
|
|
96
111
|
# @param config [Hash]
|
|
97
112
|
# @yield [Hash]
|
|
98
113
|
# @return [Object] final context
|
|
114
|
+
# @api public
|
|
99
115
|
def stream(input, config: {}, &block)
|
|
100
116
|
@runner.stream(input, config: config, &block)
|
|
101
117
|
end
|
|
@@ -109,15 +125,18 @@ module Phronomy
|
|
|
109
125
|
class Builder
|
|
110
126
|
FINISH = Phronomy::WorkflowRunner::FINISH
|
|
111
127
|
|
|
112
|
-
def initialize(context_class)
|
|
128
|
+
def initialize(context_class, state_store: nil)
|
|
113
129
|
@context_class = context_class
|
|
130
|
+
@state_store = state_store
|
|
114
131
|
@initial = nil
|
|
115
|
-
#
|
|
116
|
-
@
|
|
117
|
-
#
|
|
118
|
-
@
|
|
119
|
-
#
|
|
120
|
-
@
|
|
132
|
+
# Ordered list of declared state names (action states only, not wait states).
|
|
133
|
+
@declared_states = []
|
|
134
|
+
# { state_name => [callable, ...] } — entry actions registered via entry()
|
|
135
|
+
@entry_actions = {}
|
|
136
|
+
# { state_name => [callable, ...] } — exit actions registered via exit()
|
|
137
|
+
@exit_actions = {}
|
|
138
|
+
# Array of { from:, to:, guard:, on: } — all transitions in declaration order
|
|
139
|
+
@transitions = []
|
|
121
140
|
# Set of wait state names
|
|
122
141
|
@wait_state_names = []
|
|
123
142
|
end
|
|
@@ -125,90 +144,172 @@ module Phronomy
|
|
|
125
144
|
# Declares the initial (entry) state.
|
|
126
145
|
# @param state_name [Symbol]
|
|
127
146
|
# rubocop:disable Style/TrivialAccessors
|
|
147
|
+
# @api public
|
|
128
148
|
def initial(state_name)
|
|
129
149
|
@initial = state_name
|
|
130
150
|
end
|
|
131
151
|
# rubocop:enable Style/TrivialAccessors
|
|
132
152
|
|
|
133
153
|
# Declares an action state.
|
|
134
|
-
# @param name
|
|
135
|
-
# @param action [#call, nil]
|
|
136
|
-
#
|
|
154
|
+
# @param name [Symbol] state name
|
|
155
|
+
# @param action [#call, nil] optional entry action shorthand.
|
|
156
|
+
# +state :generate, action: MY_PROC+ is equivalent to
|
|
157
|
+
# +state :generate; entry :generate, MY_PROC+.
|
|
158
|
+
# @api public
|
|
137
159
|
def state(name, action: nil)
|
|
138
|
-
@
|
|
160
|
+
@declared_states << name
|
|
161
|
+
entry(name, action) if action
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Declares an entry action for a state.
|
|
165
|
+
# The callable is invoked when the workflow enters +name+.
|
|
166
|
+
# It receives the current context. Two styles are supported:
|
|
167
|
+
# - Mutation-in-place: mutate context fields directly (+s.field = value+);
|
|
168
|
+
# the return value is ignored.
|
|
169
|
+
# - Immutable update: return a new context via +s.merge(field: value)+;
|
|
170
|
+
# the returned context replaces the current one.
|
|
171
|
+
# Multiple calls for the same state are allowed; callables fire in declaration order.
|
|
172
|
+
# @param name [Symbol] state name
|
|
173
|
+
# @param callable [#call] receives context; may return a new WorkflowContext
|
|
174
|
+
# @api public
|
|
175
|
+
def entry(name, callable)
|
|
176
|
+
(@entry_actions[name] ||= []) << callable
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Declares an exit action for a state.
|
|
180
|
+
# The callable is invoked when the workflow leaves +name+.
|
|
181
|
+
# It receives the current context and should mutate it in place.
|
|
182
|
+
# Return value is ignored.
|
|
183
|
+
# Multiple calls for the same state are allowed; callables fire in declaration order.
|
|
184
|
+
# @param name [Symbol] state name
|
|
185
|
+
# @param callable [#call] receives context, mutates it in place
|
|
186
|
+
# @api public
|
|
187
|
+
def exit(name, callable)
|
|
188
|
+
(@exit_actions[name] ||= []) << callable
|
|
139
189
|
end
|
|
140
190
|
|
|
141
191
|
# Declares a wait state that automatically halts execution when reached.
|
|
142
|
-
# No action is registered; the workflow pauses here until an event resumes it.
|
|
192
|
+
# No entry action is registered; the workflow pauses here until an event resumes it.
|
|
143
193
|
# @param name [Symbol] wait state name (conventionally :awaiting_something)
|
|
194
|
+
# @api public
|
|
144
195
|
def wait_state(name)
|
|
145
196
|
@wait_state_names << name
|
|
146
197
|
end
|
|
147
198
|
|
|
148
|
-
# Declares
|
|
149
|
-
#
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
199
|
+
# Declares a transition between states.
|
|
200
|
+
# Auto-fire transitions (no +on:+) fire automatically when an action state's
|
|
201
|
+
# action completes. External transitions (+on: :event_name+) are triggered
|
|
202
|
+
# manually via +send_event+.
|
|
203
|
+
# When +guard:+ is provided the transition is taken only if the guard returns
|
|
204
|
+
# truthy for the current context. Multiple transitions from the same source are
|
|
205
|
+
# evaluated in declaration order; the first passing guard wins.
|
|
206
|
+
# @param from [Symbol] source state
|
|
163
207
|
# @param to [Symbol] destination state or :__finish__
|
|
164
208
|
# @param guard [Proc, nil] optional guard — receives context, returns truthy/falsy
|
|
165
|
-
|
|
209
|
+
# @param on [Symbol, nil] named event for manual triggers (e.g. :approve)
|
|
210
|
+
# @api public
|
|
211
|
+
def transition(from:, to:, guard: nil, on: nil)
|
|
166
212
|
dest = (to == :__finish__) ? FINISH : to
|
|
167
|
-
@
|
|
213
|
+
@transitions << {from: from, to: dest, guard: guard, on: on}
|
|
168
214
|
end
|
|
169
215
|
|
|
170
|
-
|
|
171
|
-
def build
|
|
172
|
-
nodes = @states.dup
|
|
216
|
+
private
|
|
173
217
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
218
|
+
# Performs build-time structural validation of the workflow graph.
|
|
219
|
+
# Raises ArgumentError for hard errors; warns for unreachable states.
|
|
220
|
+
def validate_graph!
|
|
221
|
+
all_states = (@declared_states + @wait_state_names).uniq
|
|
222
|
+
entry_point = @initial || @declared_states.first
|
|
223
|
+
|
|
224
|
+
if entry_point.nil?
|
|
225
|
+
raise ArgumentError, "Workflow has no states declared — call state(...) or wait_state(...) at least once"
|
|
178
226
|
end
|
|
179
227
|
|
|
180
|
-
#
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
228
|
+
# Collect all reachable state names from transitions (excluding :__finish__ sentinel).
|
|
229
|
+
referenced_targets = @transitions.map { |t| t[:to] }.reject { |t| t == FINISH }
|
|
230
|
+
undefined = referenced_targets - all_states
|
|
231
|
+
unless undefined.empty?
|
|
232
|
+
raise ArgumentError,
|
|
233
|
+
"Workflow transition(s) reference undefined state(s): #{undefined.sort.inspect}. " \
|
|
234
|
+
"Declare each with state(...) or wait_state(...)."
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Check that all from: states in transitions are declared.
|
|
238
|
+
referenced_sources = @transitions.map { |t| t[:from] }
|
|
239
|
+
undefined_sources = referenced_sources - all_states
|
|
240
|
+
unless undefined_sources.empty?
|
|
241
|
+
raise ArgumentError,
|
|
242
|
+
"Workflow transition(s) originate from undefined state(s): #{undefined_sources.sort.inspect}. " \
|
|
243
|
+
"Declare each with state(...) or wait_state(...)."
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Reachability check: warn about declared states that cannot be reached
|
|
247
|
+
# from the initial state (transition target not referenced by any transition).
|
|
248
|
+
reachable = Set.new([entry_point])
|
|
249
|
+
queue = [entry_point]
|
|
250
|
+
until queue.empty?
|
|
251
|
+
current = queue.shift
|
|
252
|
+
@transitions.each do |t|
|
|
253
|
+
next if t[:from] != current
|
|
254
|
+
next if t[:to] == FINISH
|
|
255
|
+
unless reachable.include?(t[:to])
|
|
256
|
+
reachable.add(t[:to])
|
|
257
|
+
queue << t[:to]
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
unreachable = all_states - reachable.to_a
|
|
263
|
+
unless unreachable.empty?
|
|
264
|
+
msg = "[Phronomy] Workflow has unreachable state(s): #{unreachable.sort.inspect}. " \
|
|
265
|
+
"These states can never be entered from the initial state '#{entry_point}'."
|
|
266
|
+
if Phronomy.configuration.logger
|
|
267
|
+
Phronomy.configuration.logger.warn(msg)
|
|
268
|
+
else
|
|
269
|
+
warn msg
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
public
|
|
275
|
+
|
|
276
|
+
# Builds and returns a Phronomy::Workflow backed by a WorkflowRunner.
|
|
277
|
+
# Performs build-time validation of the graph structure:
|
|
278
|
+
# - raises ArgumentError when no initial state is declared and no states have been defined
|
|
279
|
+
# - raises ArgumentError when a transition references an undeclared target state
|
|
280
|
+
# - warns when declared states are unreachable from the initial state
|
|
281
|
+
# @raise [ArgumentError] on structural errors
|
|
282
|
+
# @api public
|
|
283
|
+
def build
|
|
284
|
+
entry_actions = @entry_actions.dup
|
|
285
|
+
exit_actions = @exit_actions.dup
|
|
286
|
+
|
|
287
|
+
validate_graph!
|
|
186
288
|
|
|
187
|
-
#
|
|
188
|
-
#
|
|
289
|
+
# Auto-fire transitions (no :on): fire automatically when action completes.
|
|
290
|
+
# External events (with :on): triggered manually via send_event.
|
|
291
|
+
auto_transitions = []
|
|
189
292
|
external_events = {}
|
|
190
293
|
|
|
191
|
-
@
|
|
192
|
-
if
|
|
193
|
-
|
|
194
|
-
external_events[t[:
|
|
195
|
-
external_events[t[:name]] << {from: t[:from], to: t[:to], guard: t[:guard]}
|
|
294
|
+
@transitions.each do |t|
|
|
295
|
+
if t[:on]
|
|
296
|
+
external_events[t[:on]] ||= []
|
|
297
|
+
external_events[t[:on]] << {from: t[:from], to: t[:to], guard: t[:guard]}
|
|
196
298
|
else
|
|
197
|
-
|
|
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]}
|
|
299
|
+
auto_transitions << {from: t[:from], to: t[:to], guard: t[:guard]}
|
|
201
300
|
end
|
|
202
301
|
end
|
|
203
302
|
|
|
204
303
|
runner = Phronomy::WorkflowRunner.new(
|
|
205
304
|
state_class: @context_class,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
305
|
+
entry_actions: entry_actions,
|
|
306
|
+
exit_actions: exit_actions,
|
|
307
|
+
declared_states: @declared_states.dup,
|
|
308
|
+
auto_transitions: auto_transitions,
|
|
209
309
|
external_events: external_events,
|
|
210
|
-
entry_point: @initial ||
|
|
211
|
-
wait_state_names: @wait_state_names
|
|
310
|
+
entry_point: @initial || @declared_states.first,
|
|
311
|
+
wait_state_names: @wait_state_names,
|
|
312
|
+
state_store: @state_store
|
|
212
313
|
)
|
|
213
314
|
|
|
214
315
|
Workflow.new(runner)
|