phronomy 0.6.0 → 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 +338 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +242 -27
- 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 +194 -12
- 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 +15 -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 +21 -4
- 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 +26 -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/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_loop.rb +114 -7
- data/lib/phronomy/fsm_session.rb +8 -1
- data/lib/phronomy/generator_verifier.rb +2 -0
- data/lib/phronomy/guardrail/base.rb +3 -0
- 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 +96 -7
- data/lib/phronomy/workflow_context.rb +54 -4
- data/lib/phronomy/workflow_runner.rb +35 -7
- data/lib/phronomy.rb +70 -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 +45 -2
|
@@ -17,6 +17,7 @@ module Phronomy
|
|
|
17
17
|
# end
|
|
18
18
|
class OpenTelemetryTracer < Base
|
|
19
19
|
# @param tracer_name [String] name passed to the OTel TracerProvider
|
|
20
|
+
# @api public
|
|
20
21
|
def initialize(tracer_name: "phronomy")
|
|
21
22
|
require "opentelemetry"
|
|
22
23
|
@otel_tracer = OpenTelemetry.tracer_provider.tracer(tracer_name, Phronomy::VERSION)
|
|
@@ -27,6 +28,7 @@ module Phronomy
|
|
|
27
28
|
# +phronomy.+.
|
|
28
29
|
#
|
|
29
30
|
# @return [OpenTelemetry::Trace::Span]
|
|
31
|
+
# @api public
|
|
30
32
|
def start_span(name, input: nil, **attributes)
|
|
31
33
|
attrs = {}
|
|
32
34
|
attrs["phronomy.input"] = input.to_s if input
|
|
@@ -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
|
@@ -59,15 +59,21 @@ module Phronomy
|
|
|
59
59
|
|
|
60
60
|
# Defines a new Workflow.
|
|
61
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+.
|
|
62
64
|
# @yield block evaluated in DSL context
|
|
63
65
|
# @return [Phronomy::Workflow] compiled and ready-to-run workflow instance
|
|
64
|
-
|
|
65
|
-
|
|
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)
|
|
66
71
|
builder.instance_eval(&block)
|
|
67
72
|
builder.build
|
|
68
73
|
end
|
|
69
74
|
|
|
70
75
|
# @param runner [Phronomy::WorkflowRunner]
|
|
76
|
+
# @api public
|
|
71
77
|
def initialize(runner)
|
|
72
78
|
@runner = runner
|
|
73
79
|
end
|
|
@@ -76,6 +82,7 @@ module Phronomy
|
|
|
76
82
|
# @param input [Hash] initial context field values
|
|
77
83
|
# @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
|
|
78
84
|
# @return [Object] final context
|
|
85
|
+
# @api public
|
|
79
86
|
def invoke(input, config: {})
|
|
80
87
|
@runner.invoke(input, config: config)
|
|
81
88
|
end
|
|
@@ -84,6 +91,7 @@ module Phronomy
|
|
|
84
91
|
# @param state [Object] halted context
|
|
85
92
|
# @param input [Hash, nil] optional field updates to merge before resuming
|
|
86
93
|
# @return [Object] final context
|
|
94
|
+
# @api public
|
|
87
95
|
def resume(state:, input: nil)
|
|
88
96
|
@runner.resume(state: state, input: input)
|
|
89
97
|
end
|
|
@@ -93,6 +101,7 @@ module Phronomy
|
|
|
93
101
|
# @param event [Symbol] event name (e.g. :approve, :reject, :resume)
|
|
94
102
|
# @param input [Hash, nil] optional field updates to merge before resuming
|
|
95
103
|
# @return [Object] final context
|
|
104
|
+
# @api public
|
|
96
105
|
def send_event(state:, event:, input: nil)
|
|
97
106
|
@runner.send_event(state: state, event: event, input: input)
|
|
98
107
|
end
|
|
@@ -102,6 +111,7 @@ module Phronomy
|
|
|
102
111
|
# @param config [Hash]
|
|
103
112
|
# @yield [Hash]
|
|
104
113
|
# @return [Object] final context
|
|
114
|
+
# @api public
|
|
105
115
|
def stream(input, config: {}, &block)
|
|
106
116
|
@runner.stream(input, config: config, &block)
|
|
107
117
|
end
|
|
@@ -115,8 +125,9 @@ module Phronomy
|
|
|
115
125
|
class Builder
|
|
116
126
|
FINISH = Phronomy::WorkflowRunner::FINISH
|
|
117
127
|
|
|
118
|
-
def initialize(context_class)
|
|
128
|
+
def initialize(context_class, state_store: nil)
|
|
119
129
|
@context_class = context_class
|
|
130
|
+
@state_store = state_store
|
|
120
131
|
@initial = nil
|
|
121
132
|
# Ordered list of declared state names (action states only, not wait states).
|
|
122
133
|
@declared_states = []
|
|
@@ -133,6 +144,7 @@ module Phronomy
|
|
|
133
144
|
# Declares the initial (entry) state.
|
|
134
145
|
# @param state_name [Symbol]
|
|
135
146
|
# rubocop:disable Style/TrivialAccessors
|
|
147
|
+
# @api public
|
|
136
148
|
def initial(state_name)
|
|
137
149
|
@initial = state_name
|
|
138
150
|
end
|
|
@@ -143,6 +155,7 @@ module Phronomy
|
|
|
143
155
|
# @param action [#call, nil] optional entry action shorthand.
|
|
144
156
|
# +state :generate, action: MY_PROC+ is equivalent to
|
|
145
157
|
# +state :generate; entry :generate, MY_PROC+.
|
|
158
|
+
# @api public
|
|
146
159
|
def state(name, action: nil)
|
|
147
160
|
@declared_states << name
|
|
148
161
|
entry(name, action) if action
|
|
@@ -150,11 +163,15 @@ module Phronomy
|
|
|
150
163
|
|
|
151
164
|
# Declares an entry action for a state.
|
|
152
165
|
# The callable is invoked when the workflow enters +name+.
|
|
153
|
-
# It receives the current context
|
|
154
|
-
#
|
|
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.
|
|
155
171
|
# Multiple calls for the same state are allowed; callables fire in declaration order.
|
|
156
172
|
# @param name [Symbol] state name
|
|
157
|
-
# @param callable [#call] receives context
|
|
173
|
+
# @param callable [#call] receives context; may return a new WorkflowContext
|
|
174
|
+
# @api public
|
|
158
175
|
def entry(name, callable)
|
|
159
176
|
(@entry_actions[name] ||= []) << callable
|
|
160
177
|
end
|
|
@@ -166,6 +183,7 @@ module Phronomy
|
|
|
166
183
|
# Multiple calls for the same state are allowed; callables fire in declaration order.
|
|
167
184
|
# @param name [Symbol] state name
|
|
168
185
|
# @param callable [#call] receives context, mutates it in place
|
|
186
|
+
# @api public
|
|
169
187
|
def exit(name, callable)
|
|
170
188
|
(@exit_actions[name] ||= []) << callable
|
|
171
189
|
end
|
|
@@ -173,6 +191,7 @@ module Phronomy
|
|
|
173
191
|
# Declares a wait state that automatically halts execution when reached.
|
|
174
192
|
# No entry action is registered; the workflow pauses here until an event resumes it.
|
|
175
193
|
# @param name [Symbol] wait state name (conventionally :awaiting_something)
|
|
194
|
+
# @api public
|
|
176
195
|
def wait_state(name)
|
|
177
196
|
@wait_state_names << name
|
|
178
197
|
end
|
|
@@ -188,16 +207,85 @@ module Phronomy
|
|
|
188
207
|
# @param to [Symbol] destination state or :__finish__
|
|
189
208
|
# @param guard [Proc, nil] optional guard — receives context, returns truthy/falsy
|
|
190
209
|
# @param on [Symbol, nil] named event for manual triggers (e.g. :approve)
|
|
210
|
+
# @api public
|
|
191
211
|
def transition(from:, to:, guard: nil, on: nil)
|
|
192
212
|
dest = (to == :__finish__) ? FINISH : to
|
|
193
213
|
@transitions << {from: from, to: dest, guard: guard, on: on}
|
|
194
214
|
end
|
|
195
215
|
|
|
216
|
+
private
|
|
217
|
+
|
|
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"
|
|
226
|
+
end
|
|
227
|
+
|
|
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
|
+
|
|
196
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
|
|
197
283
|
def build
|
|
198
284
|
entry_actions = @entry_actions.dup
|
|
199
285
|
exit_actions = @exit_actions.dup
|
|
200
286
|
|
|
287
|
+
validate_graph!
|
|
288
|
+
|
|
201
289
|
# Auto-fire transitions (no :on): fire automatically when action completes.
|
|
202
290
|
# External events (with :on): triggered manually via send_event.
|
|
203
291
|
auto_transitions = []
|
|
@@ -220,7 +308,8 @@ module Phronomy
|
|
|
220
308
|
auto_transitions: auto_transitions,
|
|
221
309
|
external_events: external_events,
|
|
222
310
|
entry_point: @initial || @declared_states.first,
|
|
223
|
-
wait_state_names: @wait_state_names
|
|
311
|
+
wait_state_names: @wait_state_names,
|
|
312
|
+
state_store: @state_store
|
|
224
313
|
)
|
|
225
314
|
|
|
226
315
|
Workflow.new(runner)
|
|
@@ -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
|
|
@@ -51,12 +60,14 @@ module Phronomy
|
|
|
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
|