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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +21 -0
  3. data/CHANGELOG.md +379 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +262 -48
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/SECURITY.md +80 -0
  8. data/benchmark/baseline.json +9 -0
  9. data/benchmark/bench_agent_invoke.rb +105 -0
  10. data/benchmark/bench_context_assembler.rb +46 -0
  11. data/benchmark/bench_regression.rb +171 -0
  12. data/benchmark/bench_token_estimator.rb +44 -0
  13. data/benchmark/bench_tool_schema.rb +69 -0
  14. data/benchmark/bench_vector_store.rb +39 -0
  15. data/benchmark/bench_workflow.rb +55 -0
  16. data/benchmark/run_all.rb +118 -0
  17. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  18. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  19. data/docs/decisions/003-event-loop-singleton.md +48 -0
  20. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
  21. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  22. data/docs/decisions/006-no-built-in-guardrails.md +48 -0
  23. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  24. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  25. data/docs/decisions/009-state-store-abstraction.md +141 -0
  26. data/lib/phronomy/agent/base.rb +281 -13
  27. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  28. data/lib/phronomy/agent/checkpoint.rb +1 -0
  29. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  30. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  31. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  32. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  33. data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
  34. data/lib/phronomy/agent/fsm.rb +180 -0
  35. data/lib/phronomy/agent/handoff.rb +3 -0
  36. data/lib/phronomy/agent/orchestrator.rb +123 -11
  37. data/lib/phronomy/agent/parallel_tool_chat.rb +92 -0
  38. data/lib/phronomy/agent/react_agent.rb +8 -6
  39. data/lib/phronomy/agent/runner.rb +2 -0
  40. data/lib/phronomy/agent/shared_state.rb +11 -0
  41. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  42. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  43. data/lib/phronomy/cancellation_token.rb +92 -0
  44. data/lib/phronomy/configuration.rb +32 -2
  45. data/lib/phronomy/context/assembler.rb +6 -0
  46. data/lib/phronomy/context/compaction_context.rb +2 -0
  47. data/lib/phronomy/context/context_version_cache.rb +2 -0
  48. data/lib/phronomy/context/token_budget.rb +3 -0
  49. data/lib/phronomy/context/token_estimator.rb +9 -2
  50. data/lib/phronomy/context/trigger_context.rb +1 -0
  51. data/lib/phronomy/context/trim_context.rb +4 -0
  52. data/lib/phronomy/context.rb +0 -1
  53. data/lib/phronomy/embeddings/base.rb +5 -2
  54. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  55. data/lib/phronomy/eval/comparison.rb +2 -0
  56. data/lib/phronomy/eval/dataset.rb +4 -0
  57. data/lib/phronomy/eval/metrics.rb +6 -0
  58. data/lib/phronomy/eval/runner.rb +2 -0
  59. data/lib/phronomy/eval/scorer/base.rb +1 -0
  60. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  61. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  62. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  63. data/lib/phronomy/event.rb +14 -0
  64. data/lib/phronomy/event_loop.rb +254 -0
  65. data/lib/phronomy/fsm_session.rb +201 -0
  66. data/lib/phronomy/generator_verifier.rb +24 -22
  67. data/lib/phronomy/guardrail/base.rb +3 -0
  68. data/lib/phronomy/guardrail.rb +0 -1
  69. data/lib/phronomy/knowledge_source/base.rb +6 -2
  70. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  71. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  72. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  73. data/lib/phronomy/loader/base.rb +1 -0
  74. data/lib/phronomy/loader/csv_loader.rb +2 -0
  75. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  76. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  77. data/lib/phronomy/output_parser/base.rb +1 -0
  78. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  79. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  80. data/lib/phronomy/prompt_template.rb +5 -0
  81. data/lib/phronomy/runnable.rb +20 -3
  82. data/lib/phronomy/splitter/base.rb +2 -0
  83. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  84. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  85. data/lib/phronomy/state_store/base.rb +48 -0
  86. data/lib/phronomy/state_store/in_memory.rb +62 -0
  87. data/lib/phronomy/tool/agent_tool.rb +1 -0
  88. data/lib/phronomy/tool/base.rb +189 -27
  89. data/lib/phronomy/tool/mcp_tool.rb +68 -13
  90. data/lib/phronomy/tracing/base.rb +3 -0
  91. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  92. data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
  93. data/lib/phronomy/vector_store/base.rb +33 -7
  94. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  95. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  96. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  97. data/lib/phronomy/version.rb +1 -1
  98. data/lib/phronomy/workflow.rb +175 -74
  99. data/lib/phronomy/workflow_context.rb +55 -5
  100. data/lib/phronomy/workflow_runner.rb +197 -114
  101. data/lib/phronomy.rb +74 -1
  102. data/scripts/api_snapshot.rb +91 -0
  103. data/scripts/check_api_annotations.rb +68 -0
  104. data/scripts/check_private_enforcement.rb +93 -0
  105. data/scripts/check_readme_runnable.rb +98 -0
  106. data/scripts/run_mutation.sh +46 -0
  107. metadata +50 -6
  108. data/lib/phronomy/context/builder.rb +0 -92
  109. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +0 -100
  110. data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +0 -67
  111. 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 [String] unique document identifier
13
- # @param embedding [Array<Float>] vector embedding
14
- # @param metadata [Hash] arbitrary metadata (e.g. the original message object)
15
- def add(id:, embedding:, metadata: {})
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 [Array<Float>]
22
- # @param k [Integer] number of results
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
- def search(query_embedding:, k: 5)
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 [String]
25
- # @param embedding [Array<Float>]
26
- # @param metadata [Hash]
27
- def add(id:, embedding:, metadata: {})
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 [Array<Float>]
36
- # @param k [Integer]
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
- def search(query_embedding:, k: 5)
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 [String]
38
- # @param embedding [Array<Float>]
39
- # @param metadata [Hash]
40
- def add(id:, embedding:, metadata: {})
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 [Array<Float>]
50
- # @param k [Integer]
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
- def search(query_embedding:, k: 5)
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: JSON.parse(r.metadata.to_s, symbolize_names: true)
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 [String]
49
- # @param embedding [Array<Float>]
50
- # @param metadata [Hash]
51
- def add(id:, embedding:, metadata: {})
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 [Array<Float>]
66
- # @param k [Integer]
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
- def search(query_embedding:, k: 5)
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 = Integer(k)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.5.4"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -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 graph-based execution in phronomy.
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, action: FETCH_NODE
19
- # state :process, action: PROCESS_NODE
18
+ # state :fetch
19
+ # state :process
20
20
  #
21
- # after :fetch, to: :process
22
- # after :process, to: :__finish__
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, action: PROPOSE_NODE
35
+ # state :propose
33
36
  # wait_state :awaiting_approval
34
- # state :execute, action: EXECUTE_NODE
37
+ # state :execute
38
+ #
39
+ # entry :propose, PROPOSE_NODE
40
+ # entry :execute, EXECUTE_NODE
35
41
  #
36
- # after :propose, to: :awaiting_approval
37
- # after :execute, to: :__finish__
42
+ # transition from: :propose, to: :awaiting_approval
43
+ # transition from: :execute, to: :__finish__
38
44
  #
39
- # event :approve, from: :awaiting_approval, to: :execute
40
- # event :reject, from: :awaiting_approval, to: :propose
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
- # event :route, from: :decide, guard: ->(s) { s.score > 5 }, to: :high
49
- # event :route, from: :decide, to: :low # fallback (no guard)
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
- def self.define(context_class, &block)
59
- builder = Builder.new(context_class)
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 { node: Symbol, state: Object } after each node.
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
- # { node_name => callable }
116
- @states = {}
117
- # Array of { from:, to: } — auto-transitions after a state action
118
- @after_transitions = []
119
- # Array of { name:, from:, to:, guard: } — event-driven transitions
120
- @event_transitions = []
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 [Symbol] state name
135
- # @param action [#call, nil] callable invoked when entering the state.
136
- # If nil, the state is treated as a no-op pass-through.
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
- @states[name] = action || ->(s) { s }
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 an automatic transition that fires after a state's action completes.
149
- # @param from [Symbol] source state name
150
- # @param to [Symbol] destination state name or :__finish__
151
- def after(from, to:)
152
- dest = (to == :__finish__) ? FINISH : to
153
- @after_transitions << {from: from, to: dest}
154
- end
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
- def event(name, from:, to:, guard: nil)
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
- @event_transitions << {name: name, from: from, to: dest, guard: guard}
213
+ @transitions << {from: from, to: dest, guard: guard, on: on}
168
214
  end
169
215
 
170
- # Builds and returns a Phronomy::Workflow backed by a WorkflowRunner.
171
- def build
172
- nodes = @states.dup
216
+ private
173
217
 
174
- # After-transitions: { from => to }
175
- # Unconditional transitions that fire automatically after an action state completes.
176
- after_transitions = @after_transitions.each_with_object({}) do |t, h|
177
- h[t[:from]] = t[:to]
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
- # Route transitions: { from => {event_name:, entries: [{guard:, to:}, ...]} }
181
- # Events declared from action states (not wait states) fire automatically
182
- # after the action completes. The event name is used to register the
183
- # state_machines event and may be any symbol (e.g. :route, :route_review).
184
- # Declaration order is preserved so guarded entries appear before fallbacks.
185
- route_transitions = {}
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
- # External events: { event_name => [{from:, to:, guard:}, ...] }
188
- # Events declared from wait states, triggered by human input (e.g. :approve).
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
- @event_transitions.each do |t|
192
- if @wait_state_names.include?(t[:from])
193
- # Source is a wait state → external event
194
- external_events[t[:name]] ||= []
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
- # Source is an action state routing event (auto-fires after action)
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
- nodes: nodes,
207
- after_transitions: after_transitions,
208
- route_transitions: route_transitions,
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 || nodes.keys.first,
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)