phronomy 0.6.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +22 -0
- data/CHANGELOG.md +488 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +374 -36
- data/RELEASE_CHECKLIST.md +86 -0
- data/Rakefile +33 -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 +172 -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 +75 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +66 -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/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +416 -49
- 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 +19 -0
- data/lib/phronomy/agent/fsm.rb +44 -52
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +191 -54
- data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
- data/lib/phronomy/agent/react_agent.rb +16 -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/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +133 -0
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +168 -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/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +22 -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 +11 -9
- 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 +275 -30
- data/lib/phronomy/fsm_session.rb +57 -4
- data/lib/phronomy/generator_verifier.rb +2 -0
- data/lib/phronomy/guardrail/base.rb +3 -0
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +24 -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/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- 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/metrics.rb +38 -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/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +374 -0
- 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/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +298 -28
- data/lib/phronomy/tool/mcp_tool.rb +103 -17
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- 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 +36 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +40 -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 +147 -11
- data/lib/phronomy/workflow_context.rb +83 -6
- data/lib/phronomy/workflow_runner.rb +106 -7
- data/lib/phronomy.rb +112 -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 +83 -2
|
@@ -6,28 +6,42 @@ module Phronomy
|
|
|
6
6
|
#
|
|
7
7
|
# Implementations manage a collection of (embedding, metadata) pairs and
|
|
8
8
|
# support similarity search.
|
|
9
|
+
#
|
|
10
|
+
# Async methods (`search_async`, `add_async`, `remove_async`, `clear_async`)
|
|
11
|
+
# are provided by the {AsyncBackend} mixin which defaults to routing calls
|
|
12
|
+
# through {BlockingAdapterPool}. Backends with native async drivers may
|
|
13
|
+
# override individual async methods without touching the pool at all.
|
|
9
14
|
class Base
|
|
15
|
+
include AsyncBackend
|
|
16
|
+
|
|
10
17
|
# Add a document with its vector embedding.
|
|
11
18
|
#
|
|
12
|
-
# @param id
|
|
13
|
-
# @param embedding
|
|
14
|
-
# @param metadata
|
|
15
|
-
|
|
19
|
+
# @param id [String] unique document identifier
|
|
20
|
+
# @param embedding [Array<Float>] vector embedding
|
|
21
|
+
# @param metadata [Hash] arbitrary metadata (e.g. the original message object)
|
|
22
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
23
|
+
# @api public
|
|
24
|
+
def add(id:, embedding:, metadata: {}, cancellation_token: nil)
|
|
25
|
+
cancellation_token&.raise_if_cancelled!
|
|
16
26
|
raise NotImplementedError, "#{self.class}#add is not implemented"
|
|
17
27
|
end
|
|
18
28
|
|
|
19
29
|
# Return the k most similar documents to the query embedding.
|
|
20
30
|
#
|
|
21
|
-
# @param query_embedding
|
|
22
|
-
# @param k
|
|
31
|
+
# @param query_embedding [Array<Float>]
|
|
32
|
+
# @param k [Integer] number of results
|
|
33
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
|
|
23
34
|
# @return [Array<Hash>] each element: { id:, score:, metadata: }
|
|
24
|
-
|
|
35
|
+
# @api public
|
|
36
|
+
def search(query_embedding:, k: 5, cancellation_token: nil)
|
|
37
|
+
cancellation_token&.raise_if_cancelled!
|
|
25
38
|
raise NotImplementedError, "#{self.class}#search is not implemented"
|
|
26
39
|
end
|
|
27
40
|
|
|
28
41
|
# Remove a single document by id.
|
|
29
42
|
#
|
|
30
43
|
# @param id [String] document identifier
|
|
44
|
+
# @api public
|
|
31
45
|
def remove(id:)
|
|
32
46
|
raise NotImplementedError, "#{self.class}#remove is not implemented"
|
|
33
47
|
end
|
|
@@ -37,6 +51,14 @@ module Phronomy
|
|
|
37
51
|
raise NotImplementedError, "#{self.class}#clear is not implemented"
|
|
38
52
|
end
|
|
39
53
|
|
|
54
|
+
# Return the number of documents stored.
|
|
55
|
+
#
|
|
56
|
+
# @return [Integer]
|
|
57
|
+
# @api public
|
|
58
|
+
def size
|
|
59
|
+
raise NotImplementedError, "#{self.class}#size is not implemented"
|
|
60
|
+
end
|
|
61
|
+
|
|
40
62
|
private
|
|
41
63
|
|
|
42
64
|
# Validates that embedding has the expected dimension.
|
|
@@ -51,6 +73,17 @@ module Phronomy
|
|
|
51
73
|
raise ArgumentError,
|
|
52
74
|
"Embedding dimension mismatch: expected #{expected_dimension}, got #{actual}"
|
|
53
75
|
end
|
|
76
|
+
|
|
77
|
+
# Validates that k is a positive integer.
|
|
78
|
+
# Accepts any value accepted by Integer() (e.g. "5"), but raises
|
|
79
|
+
# ArgumentError for non-integer strings, zero, and negative values.
|
|
80
|
+
def validate_k!(k)
|
|
81
|
+
int_k = Integer(k)
|
|
82
|
+
raise ArgumentError, "k must be a positive integer, got #{int_k}" unless int_k >= 1
|
|
83
|
+
int_k
|
|
84
|
+
rescue ArgumentError => e
|
|
85
|
+
raise ArgumentError, "k must be a positive integer: #{e.message}"
|
|
86
|
+
end
|
|
54
87
|
end
|
|
55
88
|
end
|
|
56
89
|
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
|
|
@@ -75,15 +81,40 @@ module Phronomy
|
|
|
75
81
|
# Executes the workflow from the initial state.
|
|
76
82
|
# @param input [Hash] initial context field values
|
|
77
83
|
# @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
|
|
84
|
+
# @param invocation_context [Phronomy::InvocationContext, nil] optional first-class context
|
|
85
|
+
# object. When present, +thread_id+, +cancellation_token+, and +deadline+ are
|
|
86
|
+
# derived from it (existing +config:+ keys take precedence). The object is also
|
|
87
|
+
# stored in +config[:invocation_context]+ for downstream tracing.
|
|
78
88
|
# @return [Object] final context
|
|
79
|
-
|
|
89
|
+
# @api public
|
|
90
|
+
def invoke(input, config: {}, invocation_context: nil)
|
|
91
|
+
if invocation_context
|
|
92
|
+
config = _apply_invocation_context(config, invocation_context)
|
|
93
|
+
end
|
|
80
94
|
@runner.invoke(input, config: config)
|
|
81
95
|
end
|
|
82
96
|
|
|
97
|
+
# Invokes this workflow asynchronously and returns a {Phronomy::Task}.
|
|
98
|
+
#
|
|
99
|
+
# @param input [Hash]
|
|
100
|
+
# @param config [Hash]
|
|
101
|
+
# @param invocation_context [Phronomy::InvocationContext, nil]
|
|
102
|
+
# @return [Phronomy::Task]
|
|
103
|
+
# @api public
|
|
104
|
+
def invoke_async(input, config: {}, invocation_context: nil)
|
|
105
|
+
if invocation_context
|
|
106
|
+
config = _apply_invocation_context(config, invocation_context)
|
|
107
|
+
end
|
|
108
|
+
Phronomy::Runtime.instance.spawn(name: "workflow-invoke-async") do
|
|
109
|
+
invoke(input, config: config)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
83
113
|
# Resumes a halted workflow. Generic resume that works for all halt types.
|
|
84
114
|
# @param state [Object] halted context
|
|
85
115
|
# @param input [Hash, nil] optional field updates to merge before resuming
|
|
86
116
|
# @return [Object] final context
|
|
117
|
+
# @api public
|
|
87
118
|
def resume(state:, input: nil)
|
|
88
119
|
@runner.resume(state: state, input: input)
|
|
89
120
|
end
|
|
@@ -93,6 +124,7 @@ module Phronomy
|
|
|
93
124
|
# @param event [Symbol] event name (e.g. :approve, :reject, :resume)
|
|
94
125
|
# @param input [Hash, nil] optional field updates to merge before resuming
|
|
95
126
|
# @return [Object] final context
|
|
127
|
+
# @api public
|
|
96
128
|
def send_event(state:, event:, input: nil)
|
|
97
129
|
@runner.send_event(state: state, event: event, input: input)
|
|
98
130
|
end
|
|
@@ -102,10 +134,28 @@ module Phronomy
|
|
|
102
134
|
# @param config [Hash]
|
|
103
135
|
# @yield [Hash]
|
|
104
136
|
# @return [Object] final context
|
|
137
|
+
# @api public
|
|
105
138
|
def stream(input, config: {}, &block)
|
|
106
139
|
@runner.stream(input, config: config, &block)
|
|
107
140
|
end
|
|
108
141
|
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# Merges an {InvocationContext} into the config hash.
|
|
145
|
+
# Existing +config+ keys take precedence (backward-compat).
|
|
146
|
+
def _apply_invocation_context(config, ic)
|
|
147
|
+
effective = config.merge(invocation_context: ic)
|
|
148
|
+
effective = effective.merge(thread_id: ic.thread_id) if effective[:thread_id].nil? && ic.thread_id
|
|
149
|
+
if effective[:cancellation_token].nil?
|
|
150
|
+
if (tok = ic.effective_timeout_token)
|
|
151
|
+
effective = effective.merge(cancellation_token: tok)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
effective
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
public
|
|
158
|
+
|
|
109
159
|
# ---------------------------------------------------------------------------
|
|
110
160
|
# Internal DSL builder
|
|
111
161
|
# ---------------------------------------------------------------------------
|
|
@@ -115,8 +165,9 @@ module Phronomy
|
|
|
115
165
|
class Builder
|
|
116
166
|
FINISH = Phronomy::WorkflowRunner::FINISH
|
|
117
167
|
|
|
118
|
-
def initialize(context_class)
|
|
168
|
+
def initialize(context_class, state_store: nil)
|
|
119
169
|
@context_class = context_class
|
|
170
|
+
@state_store = state_store
|
|
120
171
|
@initial = nil
|
|
121
172
|
# Ordered list of declared state names (action states only, not wait states).
|
|
122
173
|
@declared_states = []
|
|
@@ -128,33 +179,45 @@ module Phronomy
|
|
|
128
179
|
@transitions = []
|
|
129
180
|
# Set of wait state names
|
|
130
181
|
@wait_state_names = []
|
|
182
|
+
# { state_name => Numeric } — per-state action timeout in seconds
|
|
183
|
+
@action_timeouts = {}
|
|
131
184
|
end
|
|
132
185
|
|
|
133
186
|
# Declares the initial (entry) state.
|
|
134
187
|
# @param state_name [Symbol]
|
|
135
188
|
# rubocop:disable Style/TrivialAccessors
|
|
189
|
+
# @api public
|
|
136
190
|
def initial(state_name)
|
|
137
191
|
@initial = state_name
|
|
138
192
|
end
|
|
139
193
|
# rubocop:enable Style/TrivialAccessors
|
|
140
194
|
|
|
141
195
|
# Declares an action state.
|
|
142
|
-
# @param name
|
|
143
|
-
# @param action
|
|
196
|
+
# @param name [Symbol] state name
|
|
197
|
+
# @param action [#call, nil] optional entry action shorthand.
|
|
144
198
|
# +state :generate, action: MY_PROC+ is equivalent to
|
|
145
199
|
# +state :generate; entry :generate, MY_PROC+.
|
|
146
|
-
|
|
200
|
+
# @param action_timeout [Numeric, nil] seconds before an async (Task-returning)
|
|
201
|
+
# entry action is cancelled and {Phronomy::ActionTimeoutError} is raised.
|
|
202
|
+
# Only applies when the action returns a {Task} or {PendingOperation}.
|
|
203
|
+
# @api public
|
|
204
|
+
def state(name, action: nil, action_timeout: nil)
|
|
147
205
|
@declared_states << name
|
|
206
|
+
@action_timeouts[name] = action_timeout if action_timeout
|
|
148
207
|
entry(name, action) if action
|
|
149
208
|
end
|
|
150
209
|
|
|
151
210
|
# Declares an entry action for a state.
|
|
152
211
|
# The callable is invoked when the workflow enters +name+.
|
|
153
|
-
# It receives the current context
|
|
154
|
-
#
|
|
212
|
+
# It receives the current context. Two styles are supported:
|
|
213
|
+
# - Mutation-in-place: mutate context fields directly (+s.field = value+);
|
|
214
|
+
# the return value is ignored.
|
|
215
|
+
# - Immutable update: return a new context via +s.merge(field: value)+;
|
|
216
|
+
# the returned context replaces the current one.
|
|
155
217
|
# Multiple calls for the same state are allowed; callables fire in declaration order.
|
|
156
218
|
# @param name [Symbol] state name
|
|
157
|
-
# @param callable [#call] receives context
|
|
219
|
+
# @param callable [#call] receives context; may return a new WorkflowContext
|
|
220
|
+
# @api public
|
|
158
221
|
def entry(name, callable)
|
|
159
222
|
(@entry_actions[name] ||= []) << callable
|
|
160
223
|
end
|
|
@@ -166,6 +229,7 @@ module Phronomy
|
|
|
166
229
|
# Multiple calls for the same state are allowed; callables fire in declaration order.
|
|
167
230
|
# @param name [Symbol] state name
|
|
168
231
|
# @param callable [#call] receives context, mutates it in place
|
|
232
|
+
# @api public
|
|
169
233
|
def exit(name, callable)
|
|
170
234
|
(@exit_actions[name] ||= []) << callable
|
|
171
235
|
end
|
|
@@ -173,6 +237,7 @@ module Phronomy
|
|
|
173
237
|
# Declares a wait state that automatically halts execution when reached.
|
|
174
238
|
# No entry action is registered; the workflow pauses here until an event resumes it.
|
|
175
239
|
# @param name [Symbol] wait state name (conventionally :awaiting_something)
|
|
240
|
+
# @api public
|
|
176
241
|
def wait_state(name)
|
|
177
242
|
@wait_state_names << name
|
|
178
243
|
end
|
|
@@ -188,16 +253,85 @@ module Phronomy
|
|
|
188
253
|
# @param to [Symbol] destination state or :__finish__
|
|
189
254
|
# @param guard [Proc, nil] optional guard — receives context, returns truthy/falsy
|
|
190
255
|
# @param on [Symbol, nil] named event for manual triggers (e.g. :approve)
|
|
256
|
+
# @api public
|
|
191
257
|
def transition(from:, to:, guard: nil, on: nil)
|
|
192
258
|
dest = (to == :__finish__) ? FINISH : to
|
|
193
259
|
@transitions << {from: from, to: dest, guard: guard, on: on}
|
|
194
260
|
end
|
|
195
261
|
|
|
262
|
+
private
|
|
263
|
+
|
|
264
|
+
# Performs build-time structural validation of the workflow graph.
|
|
265
|
+
# Raises ArgumentError for hard errors; warns for unreachable states.
|
|
266
|
+
def validate_graph!
|
|
267
|
+
all_states = (@declared_states + @wait_state_names).uniq
|
|
268
|
+
entry_point = @initial || @declared_states.first
|
|
269
|
+
|
|
270
|
+
if entry_point.nil?
|
|
271
|
+
raise ArgumentError, "Workflow has no states declared — call state(...) or wait_state(...) at least once"
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Collect all reachable state names from transitions (excluding :__finish__ sentinel).
|
|
275
|
+
referenced_targets = @transitions.map { |t| t[:to] }.reject { |t| t == FINISH }
|
|
276
|
+
undefined = referenced_targets - all_states
|
|
277
|
+
unless undefined.empty?
|
|
278
|
+
raise ArgumentError,
|
|
279
|
+
"Workflow transition(s) reference undefined state(s): #{undefined.sort.inspect}. " \
|
|
280
|
+
"Declare each with state(...) or wait_state(...)."
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Check that all from: states in transitions are declared.
|
|
284
|
+
referenced_sources = @transitions.map { |t| t[:from] }
|
|
285
|
+
undefined_sources = referenced_sources - all_states
|
|
286
|
+
unless undefined_sources.empty?
|
|
287
|
+
raise ArgumentError,
|
|
288
|
+
"Workflow transition(s) originate from undefined state(s): #{undefined_sources.sort.inspect}. " \
|
|
289
|
+
"Declare each with state(...) or wait_state(...)."
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Reachability check: warn about declared states that cannot be reached
|
|
293
|
+
# from the initial state (transition target not referenced by any transition).
|
|
294
|
+
reachable = Set.new([entry_point])
|
|
295
|
+
queue = [entry_point]
|
|
296
|
+
until queue.empty?
|
|
297
|
+
current = queue.shift
|
|
298
|
+
@transitions.each do |t|
|
|
299
|
+
next if t[:from] != current
|
|
300
|
+
next if t[:to] == FINISH
|
|
301
|
+
unless reachable.include?(t[:to])
|
|
302
|
+
reachable.add(t[:to])
|
|
303
|
+
queue << t[:to]
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
unreachable = all_states - reachable.to_a
|
|
309
|
+
unless unreachable.empty?
|
|
310
|
+
msg = "[Phronomy] Workflow has unreachable state(s): #{unreachable.sort.inspect}. " \
|
|
311
|
+
"These states can never be entered from the initial state '#{entry_point}'."
|
|
312
|
+
if Phronomy.configuration.logger
|
|
313
|
+
Phronomy.configuration.logger.warn(msg)
|
|
314
|
+
else
|
|
315
|
+
warn msg
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
public
|
|
321
|
+
|
|
196
322
|
# Builds and returns a Phronomy::Workflow backed by a WorkflowRunner.
|
|
323
|
+
# Performs build-time validation of the graph structure:
|
|
324
|
+
# - raises ArgumentError when no initial state is declared and no states have been defined
|
|
325
|
+
# - raises ArgumentError when a transition references an undeclared target state
|
|
326
|
+
# - warns when declared states are unreachable from the initial state
|
|
327
|
+
# @raise [ArgumentError] on structural errors
|
|
328
|
+
# @api public
|
|
197
329
|
def build
|
|
198
330
|
entry_actions = @entry_actions.dup
|
|
199
331
|
exit_actions = @exit_actions.dup
|
|
200
332
|
|
|
333
|
+
validate_graph!
|
|
334
|
+
|
|
201
335
|
# Auto-fire transitions (no :on): fire automatically when action completes.
|
|
202
336
|
# External events (with :on): triggered manually via send_event.
|
|
203
337
|
auto_transitions = []
|
|
@@ -220,7 +354,9 @@ module Phronomy
|
|
|
220
354
|
auto_transitions: auto_transitions,
|
|
221
355
|
external_events: external_events,
|
|
222
356
|
entry_point: @initial || @declared_states.first,
|
|
223
|
-
wait_state_names: @wait_state_names
|
|
357
|
+
wait_state_names: @wait_state_names,
|
|
358
|
+
state_store: @state_store,
|
|
359
|
+
action_timeouts: @action_timeouts.dup
|
|
224
360
|
)
|
|
225
361
|
|
|
226
362
|
Workflow.new(runner)
|