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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +22 -0
  3. data/CHANGELOG.md +488 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +374 -36
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/Rakefile +33 -0
  8. data/SECURITY.md +80 -0
  9. data/benchmark/baseline.json +9 -0
  10. data/benchmark/bench_agent_invoke.rb +105 -0
  11. data/benchmark/bench_context_assembler.rb +46 -0
  12. data/benchmark/bench_regression.rb +172 -0
  13. data/benchmark/bench_token_estimator.rb +44 -0
  14. data/benchmark/bench_tool_schema.rb +69 -0
  15. data/benchmark/bench_vector_store.rb +39 -0
  16. data/benchmark/bench_workflow.rb +55 -0
  17. data/benchmark/run_all.rb +118 -0
  18. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  19. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  20. data/docs/decisions/003-event-loop-singleton.md +48 -0
  21. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
  22. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  23. data/docs/decisions/006-no-built-in-guardrails.md +66 -0
  24. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  25. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  26. data/docs/decisions/009-state-store-abstraction.md +141 -0
  27. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  28. data/lib/phronomy/agent/base.rb +416 -49
  29. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  30. data/lib/phronomy/agent/checkpoint.rb +1 -0
  31. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  32. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  33. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  34. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  35. data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
  36. data/lib/phronomy/agent/fsm.rb +44 -52
  37. data/lib/phronomy/agent/handoff.rb +3 -0
  38. data/lib/phronomy/agent/orchestrator.rb +191 -54
  39. data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
  40. data/lib/phronomy/agent/react_agent.rb +16 -6
  41. data/lib/phronomy/agent/runner.rb +2 -0
  42. data/lib/phronomy/agent/shared_state.rb +11 -0
  43. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  44. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  45. data/lib/phronomy/async_queue.rb +155 -0
  46. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  47. data/lib/phronomy/cancellation_scope.rb +123 -0
  48. data/lib/phronomy/cancellation_token.rb +133 -0
  49. data/lib/phronomy/concurrency_gate.rb +155 -0
  50. data/lib/phronomy/configuration.rb +168 -2
  51. data/lib/phronomy/context/assembler.rb +6 -0
  52. data/lib/phronomy/context/compaction_context.rb +2 -0
  53. data/lib/phronomy/context/context_version_cache.rb +2 -0
  54. data/lib/phronomy/context/token_budget.rb +3 -0
  55. data/lib/phronomy/context/token_estimator.rb +9 -2
  56. data/lib/phronomy/context/trigger_context.rb +1 -0
  57. data/lib/phronomy/context/trim_context.rb +4 -0
  58. data/lib/phronomy/deadline.rb +63 -0
  59. data/lib/phronomy/diagnostics.rb +62 -0
  60. data/lib/phronomy/embeddings/base.rb +22 -2
  61. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  62. data/lib/phronomy/eval/comparison.rb +2 -0
  63. data/lib/phronomy/eval/dataset.rb +4 -0
  64. data/lib/phronomy/eval/metrics.rb +6 -0
  65. data/lib/phronomy/eval/runner.rb +11 -9
  66. data/lib/phronomy/eval/scorer/base.rb +1 -0
  67. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  68. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  69. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  70. data/lib/phronomy/event_loop.rb +275 -30
  71. data/lib/phronomy/fsm_session.rb +57 -4
  72. data/lib/phronomy/generator_verifier.rb +2 -0
  73. data/lib/phronomy/guardrail/base.rb +3 -0
  74. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  75. data/lib/phronomy/invocation_context.rb +152 -0
  76. data/lib/phronomy/knowledge_source/base.rb +24 -2
  77. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  78. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  79. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  80. data/lib/phronomy/llm_adapter/base.rb +104 -0
  81. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  82. data/lib/phronomy/llm_adapter.rb +20 -0
  83. data/lib/phronomy/loader/base.rb +1 -0
  84. data/lib/phronomy/loader/csv_loader.rb +2 -0
  85. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  86. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  87. data/lib/phronomy/metrics.rb +38 -0
  88. data/lib/phronomy/output_parser/base.rb +1 -0
  89. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  90. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  91. data/lib/phronomy/prompt_template.rb +5 -0
  92. data/lib/phronomy/runnable.rb +20 -3
  93. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  94. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  95. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  96. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  97. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  98. data/lib/phronomy/runtime/scheduler.rb +98 -0
  99. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  100. data/lib/phronomy/runtime/task_registry.rb +48 -0
  101. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  102. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  103. data/lib/phronomy/runtime/timer_service.rb +42 -0
  104. data/lib/phronomy/runtime.rb +374 -0
  105. data/lib/phronomy/splitter/base.rb +2 -0
  106. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  107. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  108. data/lib/phronomy/state_store/base.rb +48 -0
  109. data/lib/phronomy/state_store/in_memory.rb +62 -0
  110. data/lib/phronomy/task/backend.rb +80 -0
  111. data/lib/phronomy/task/fiber_backend.rb +157 -0
  112. data/lib/phronomy/task/immediate_backend.rb +89 -0
  113. data/lib/phronomy/task/thread_backend.rb +84 -0
  114. data/lib/phronomy/task.rb +275 -0
  115. data/lib/phronomy/task_group.rb +265 -0
  116. data/lib/phronomy/testing/fake_clock.rb +109 -0
  117. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  118. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  119. data/lib/phronomy/testing.rb +12 -0
  120. data/lib/phronomy/tool/agent_tool.rb +1 -0
  121. data/lib/phronomy/tool/base.rb +298 -28
  122. data/lib/phronomy/tool/mcp_tool.rb +103 -17
  123. data/lib/phronomy/tool/scope_policy.rb +50 -0
  124. data/lib/phronomy/tool_executor.rb +106 -0
  125. data/lib/phronomy/tracing/base.rb +3 -0
  126. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  127. data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
  128. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  129. data/lib/phronomy/vector_store/base.rb +40 -7
  130. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  131. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  132. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  133. data/lib/phronomy/version.rb +1 -1
  134. data/lib/phronomy/workflow.rb +147 -11
  135. data/lib/phronomy/workflow_context.rb +83 -6
  136. data/lib/phronomy/workflow_runner.rb +106 -7
  137. data/lib/phronomy.rb +112 -1
  138. data/scripts/api_snapshot.rb +91 -0
  139. data/scripts/check_api_annotations.rb +68 -0
  140. data/scripts/check_private_enforcement.rb +93 -0
  141. data/scripts/check_readme_runnable.rb +98 -0
  142. data/scripts/run_mutation.sh +46 -0
  143. 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 [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: {})
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 [Array<Float>]
22
- # @param k [Integer] number of results
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
- def search(query_embedding:, k: 5)
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 [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.1"
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
@@ -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
- def invoke(input, config: {})
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 [Symbol] state name
143
- # @param action [#call, nil] optional entry action shorthand.
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
- def state(name, action: nil)
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 and should mutate it in place.
154
- # Return value is ignored.
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, mutates it in place
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)