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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +21 -0
  3. data/CHANGELOG.md +338 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +242 -27
  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 +194 -12
  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 +15 -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 +21 -4
  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 +26 -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/embeddings/base.rb +5 -2
  53. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  54. data/lib/phronomy/eval/comparison.rb +2 -0
  55. data/lib/phronomy/eval/dataset.rb +4 -0
  56. data/lib/phronomy/eval/metrics.rb +6 -0
  57. data/lib/phronomy/eval/runner.rb +2 -0
  58. data/lib/phronomy/eval/scorer/base.rb +1 -0
  59. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  60. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  61. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  62. data/lib/phronomy/event_loop.rb +114 -7
  63. data/lib/phronomy/fsm_session.rb +8 -1
  64. data/lib/phronomy/generator_verifier.rb +2 -0
  65. data/lib/phronomy/guardrail/base.rb +3 -0
  66. data/lib/phronomy/knowledge_source/base.rb +6 -2
  67. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  68. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  69. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  70. data/lib/phronomy/loader/base.rb +1 -0
  71. data/lib/phronomy/loader/csv_loader.rb +2 -0
  72. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  73. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  74. data/lib/phronomy/output_parser/base.rb +1 -0
  75. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  76. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  77. data/lib/phronomy/prompt_template.rb +5 -0
  78. data/lib/phronomy/runnable.rb +20 -3
  79. data/lib/phronomy/splitter/base.rb +2 -0
  80. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  81. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  82. data/lib/phronomy/state_store/base.rb +48 -0
  83. data/lib/phronomy/state_store/in_memory.rb +62 -0
  84. data/lib/phronomy/tool/agent_tool.rb +1 -0
  85. data/lib/phronomy/tool/base.rb +189 -27
  86. data/lib/phronomy/tool/mcp_tool.rb +68 -13
  87. data/lib/phronomy/tracing/base.rb +3 -0
  88. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  89. data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
  90. data/lib/phronomy/vector_store/base.rb +33 -7
  91. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  92. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  93. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  94. data/lib/phronomy/version.rb +1 -1
  95. data/lib/phronomy/workflow.rb +96 -7
  96. data/lib/phronomy/workflow_context.rb +54 -4
  97. data/lib/phronomy/workflow_runner.rb +35 -7
  98. data/lib/phronomy.rb +70 -1
  99. data/scripts/api_snapshot.rb +91 -0
  100. data/scripts/check_api_annotations.rb +68 -0
  101. data/scripts/check_private_enforcement.rb +93 -0
  102. data/scripts/check_readme_runnable.rb +98 -0
  103. data/scripts/run_mutation.sh +46 -0
  104. 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 [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.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -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
- def self.define(context_class, &block)
65
- 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)
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 and should mutate it in place.
154
- # Return value is ignored.
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, mutates it in place
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 -- deep-merges into a Hash
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
- # Immutably updates context fields. Returns a new instance with the applied changes.
83
- # Internal workflow metadata (thread_id, phase) is preserved.
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