phronomy 0.19.0 → 0.21.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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -1
  3. data/CHANGELOG.md +41 -11
  4. data/CONTRIBUTING.md +74 -6
  5. data/README.md +15 -6
  6. data/docs/decisions/010-cooperative-first-concurrency.md +86 -69
  7. data/docs/decisions/014-unified-persistence-durable-state.md +5 -0
  8. data/docs/decisions/015-tool-public-facade-and-rbs-boundary.md +184 -0
  9. data/docs/features.md +31 -2
  10. data/docs/getting-started.md +10 -1
  11. data/docs/migrations/0.19.md +12 -6
  12. data/docs/persistence-backends.md +504 -0
  13. data/docs/runtime-and-concurrency.md +110 -147
  14. data/lib/phronomy/agent/agent_execution.rb +29 -0
  15. data/lib/phronomy/agent/llm_call_record.rb +20 -0
  16. data/lib/phronomy/agent/tool_executor.rb +7 -3
  17. data/lib/phronomy/engine/concurrency/offload_pool.rb +142 -245
  18. data/lib/phronomy/engine/task.rb +50 -6
  19. data/lib/phronomy/invocation_context.rb +11 -52
  20. data/lib/phronomy/llm_adapter/base.rb +29 -32
  21. data/lib/phronomy/llm_adapter/ruby_llm.rb +13 -12
  22. data/lib/phronomy/llm_adapter.rb +10 -7
  23. data/lib/phronomy/output_parser/base.rb +5 -1
  24. data/lib/phronomy/persistence.rb +101 -7
  25. data/lib/phronomy/testing/persistence_contract/a_content_store.rb +50 -0
  26. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +164 -0
  27. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +215 -0
  28. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +119 -0
  29. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +99 -0
  30. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +202 -0
  31. data/lib/phronomy/testing/persistence_contract.rb +41 -0
  32. data/lib/phronomy/tool/base.rb +15 -0
  33. data/lib/phronomy/tool.rb +11 -0
  34. data/lib/phronomy/vector_store/async_backend.rb +15 -38
  35. data/lib/phronomy/vector_store/base.rb +12 -13
  36. data/lib/phronomy/vector_store/embeddings/base.rb +11 -9
  37. data/lib/phronomy/version.rb +1 -1
  38. data/lib/phronomy.rb +6 -0
  39. data/scripts/run_mutation.sh +2 -1
  40. data/sig/phronomy/agent.rbs +36 -0
  41. data/sig/phronomy/extensions.rbs +50 -0
  42. data/sig/phronomy/llm_adapter.rbs +11 -0
  43. data/sig/phronomy/persistence.rbs +70 -0
  44. data/sig/phronomy/runtime.rbs +43 -0
  45. data/sig/phronomy/tool.rbs +39 -0
  46. data/sig/phronomy/workflow.rbs +30 -0
  47. data/sig/phronomy.rbs +49 -1
  48. metadata +20 -3
  49. data/scripts/check_private_enforcement.rb +0 -93
@@ -3,20 +3,29 @@
3
3
  module Phronomy
4
4
  # A thread-free asynchronous completion handle.
5
5
  #
6
- # Task no longer executes work. Execution belongs to EventLoop/FSMSession or
7
- # OffloadPool. Task only represents completion, failure, cancellation,
8
- # callbacks and a blocking wait for external callers.
6
+ # Task does not execute work. Execution belongs to EventLoop/FSMSession or
7
+ # OffloadPool. Task represents completion, failure, cancellation, callbacks,
8
+ # and a blocking wait for callers outside EventLoop.
9
+ #
10
+ # Framework components own Task settlement. Application code should observe a
11
+ # Task through {#wait_result}, {#on_complete}, {#map}, and state readers rather
12
+ # than calling {#complete}, {#fail}, or {#cancel!}. Operation-wide cancellation
13
+ # is supplied through the CancellationToken accepted by the API that created
14
+ # the Task.
9
15
  class Task
10
16
  STATES = %i[pending completed failed cancelled].freeze
11
17
  TERMINAL_STATES = %i[completed failed cancelled].freeze
12
18
  private_constant :TERMINAL_STATES
13
19
 
20
+ # Creates an unsettled completion handle for framework-owned execution.
21
+ # @api private
14
22
  def self.deferred(name: nil, parent: nil)
15
23
  new(name: name, parent: parent)
16
24
  end
17
25
 
18
26
  attr_reader :name, :parent
19
27
 
28
+ # @api private
20
29
  def initialize(name: nil, parent: nil)
21
30
  @name = name
22
31
  @parent = parent
@@ -30,20 +39,35 @@ module Phronomy
30
39
  parent&.register_child(self)
31
40
  end
32
41
 
42
+ # @return [Symbol] :pending, :completed, :failed, or :cancelled
43
+ # @api public
33
44
  def status
34
45
  @mutex.synchronize { @status }
35
46
  end
36
47
 
48
+ # @return [Boolean] whether the Task has reached a terminal state
49
+ # @api public
37
50
  def done?
38
51
  @mutex.synchronize { TERMINAL_STATES.include?(@status) }
39
52
  end
40
53
 
54
+ # @return [Boolean] whether the Task has not yet reached a terminal state
55
+ # @api public
41
56
  def alive?
42
57
  !done?
43
58
  end
44
59
 
45
- # Blocks the calling thread until settlement. EventLoop is never allowed to
46
- # wait for a Task; it must continue through explicit events instead.
60
+ # Blocks the calling thread until settlement.
61
+ #
62
+ # EventLoop is never allowed to wait for a Task; framework continuation must
63
+ # proceed through explicit events. The optional timeout is waiter-local: it
64
+ # does not settle or cancel the Task.
65
+ #
66
+ # @param timeout [Numeric, nil] maximum seconds this caller will block
67
+ # @return [Object] the completed value
68
+ # @raise [Phronomy::TimeoutError] when the waiter-local timeout expires
69
+ # @raise [Exception] the error that settled the Task
70
+ # @api public
47
71
  def wait_result(timeout: nil)
48
72
  if Phronomy::Runtime.in_event_loop_context? && !done?
49
73
  raise Phronomy::EventLoopReentrancyError,
@@ -71,8 +95,9 @@ module Phronomy
71
95
  value
72
96
  end
73
97
 
74
- # Compatibility wait that does not re-raise the task error.
98
+ # Compatibility wait that does not re-raise the Task error.
75
99
  # Returns self when settled, nil on timeout.
100
+ # @api private
76
101
  def join(limit = nil)
77
102
  if Phronomy::Runtime.in_event_loop_context? && !done?
78
103
  raise Phronomy::EventLoopReentrancyError,
@@ -96,8 +121,15 @@ module Phronomy
96
121
 
97
122
  # Registers an independent completion notification.
98
123
  #
124
+ # The callback execution thread is not guaranteed. It may be the caller that
125
+ # registers after settlement, an OffloadPool worker, or a framework control
126
+ # thread. Callbacks must therefore be thread-safe and should complete quickly.
99
127
  # A callback failure is logged and does not suppress delivery to other
100
128
  # completion callbacks or change the Task's already-settled result.
129
+ #
130
+ # @yield [value, error]
131
+ # @return [self]
132
+ # @api public
101
133
  def on_complete(&callback)
102
134
  raise ArgumentError, "on_complete requires a block" unless callback
103
135
 
@@ -113,15 +145,25 @@ module Phronomy
113
145
  self
114
146
  end
115
147
 
148
+ # Settles this Task successfully. Framework-owned settlement API.
149
+ # @api private
116
150
  def complete(value = nil)
117
151
  settle!(:completed, value: value)
118
152
  end
119
153
 
154
+ # Settles this Task with a failure. Framework-owned settlement API.
155
+ # @api private
120
156
  def fail(error)
121
157
  raise ArgumentError, "error is required" unless error
122
158
  settle!(:failed, error: error)
123
159
  end
124
160
 
161
+ # Settles this Task as cancelled. Framework-owned settlement API.
162
+ #
163
+ # This method does not propagate backwards into a CancellationToken that may
164
+ # have been used to create the Task. Tokens can be shared across operations;
165
+ # operation-wide cancellation is owned by the creating API.
166
+ # @api private
125
167
  def cancel!(error = Phronomy::CancellationError.new("Task cancelled"))
126
168
  changed = settle!(:cancelled, error: error)
127
169
  if changed
@@ -131,6 +173,8 @@ module Phronomy
131
173
  self
132
174
  end
133
175
 
176
+ # Creates a derived Task by transforming this Task's successful result.
177
+ # @api public
134
178
  def map(&block)
135
179
  raise ArgumentError, "map requires a block" unless block
136
180
 
@@ -3,10 +3,9 @@
3
3
  module Phronomy
4
4
  # Carries all per-invocation context values through the call stack.
5
5
  #
6
- # +InvocationContext+ is a plain value object (struct-like, frozen on
7
- # creation) that replaces ad-hoc +Thread.current[...]+ propagation.
8
- # Pass it explicitly wherever context needs to cross a method boundary
9
- # or be handed to a child {Task} / {TaskGroup}.
6
+ # +InvocationContext+ is a plain struct-like value carrier that replaces
7
+ # ad-hoc +Thread.current[...]+ propagation.
8
+ # Pass it explicitly wherever context needs to cross a method boundary.
10
9
  #
11
10
  # @example Build a context for a new agent invocation
12
11
  # ctx = Phronomy::InvocationContext.new(
@@ -14,40 +13,12 @@ module Phronomy
14
13
  # cancellation_token: Phronomy::Concurrency::CancellationToken.timeout_after(30)
15
14
  # )
16
15
  # agent.invoke("Hello", invocation_context: ctx)
16
+ #
17
+ # @api public
17
18
  class InvocationContext
18
- # @return [String, nil] conversation / workflow thread identifier
19
- attr_reader :thread_id
20
-
21
- # @return [String, nil] session identifier (e.g. Rails session id)
22
- attr_reader :session_id
23
-
24
- # @return [String, nil] end-user identifier for tracing / audit
25
- attr_reader :user_id
26
-
27
- # @return [CancellationToken, nil]
28
- attr_reader :cancellation_token
29
-
30
- # @return [Deadline, nil]
31
- attr_reader :deadline
32
-
33
- # @return [Object, nil] OpenTelemetry / tracing span
34
- attr_reader :tracer_span
35
-
36
- # @return [Integer, nil] max tokens the agent may consume this invocation
37
- attr_reader :token_budget
38
-
39
- # @return [#call, nil] invocation-specific Tool approval policy. The callable
40
- # receives Phronomy::Agent::ApprovalEvaluationRequest.
41
- attr_reader :approval_policy
42
-
43
- # @return [Object, nil] redaction policy applied to tool args / results
44
- attr_reader :redaction_policy
45
-
46
- # @return [String, nil] unique identifier for this task in the trace tree
47
- attr_reader :task_id
48
-
49
- # @return [String, nil] task_id of the parent span / task
50
- attr_reader :parent_task_id
19
+ attr_reader :thread_id, :session_id, :user_id, :cancellation_token,
20
+ :deadline, :tracer_span, :token_budget, :approval_policy,
21
+ :redaction_policy, :task_id, :parent_task_id
51
22
 
52
23
  # @param thread_id [String, nil]
53
24
  # @param session_id [String, nil]
@@ -56,11 +27,11 @@ module Phronomy
56
27
  # @param deadline [Deadline, nil]
57
28
  # @param tracer_span [Object, nil]
58
29
  # @param token_budget [Integer, nil]
59
- # @param approval_policy [#call, nil] invocation-specific Tool approval policy
30
+ # @param approval_policy [#call, nil]
60
31
  # @param redaction_policy [Object, nil]
61
32
  # @param task_id [String, nil]
62
33
  # @param parent_task_id [String, nil]
63
- # @api private
34
+ # @api public
64
35
  def initialize(
65
36
  thread_id: nil,
66
37
  session_id: nil,
@@ -88,10 +59,6 @@ module Phronomy
88
59
  end
89
60
 
90
61
  # Returns a new +InvocationContext+ with the given attributes merged in.
91
- # All other attributes are carried over unchanged.
92
- #
93
- # @param overrides [Hash] keyword arguments to override
94
- # @return [InvocationContext]
95
62
  # @api private
96
63
  def merge(**overrides)
97
64
  InvocationContext.new(
@@ -110,21 +77,13 @@ module Phronomy
110
77
  end
111
78
 
112
79
  # Convenience: returns the cancellation token or a new never-cancelled token.
113
- # @return [CancellationToken]
114
80
  # @api private
115
81
  def effective_cancellation_token
116
82
  @cancellation_token || Phronomy::Concurrency::CancellationToken.new
117
83
  end
118
84
 
119
85
  # Returns the cancellation token to use for an invocation, taking both the
120
- # explicit +cancellation_token+ and the +deadline+ into account.
121
- #
122
- # - When +cancellation_token+ is set, it is returned unchanged.
123
- # - When only +deadline+ is set, a new {CancellationToken} is created and
124
- # the deadline is attached to it via {Deadline#attach_to}.
125
- # - When neither is set, returns +nil+.
126
- #
127
- # @return [CancellationToken, nil]
86
+ # explicit cancellation_token and deadline into account.
128
87
  # @api private
129
88
  def effective_timeout_token
130
89
  return @cancellation_token if @cancellation_token
@@ -2,56 +2,57 @@
2
2
 
3
3
  module Phronomy
4
4
  module LLMAdapter
5
- # Abstract base class for LLM adapters.
5
+ # Beta extension SPI for LLM call adapters.
6
6
  #
7
- # Subclasses must implement {#complete} and {#stream}. The adapter or the
7
+ # External adapters implement {#complete} and {#stream}. The adapter or the
8
8
  # underlying provider client owns transport timeout, retry, backoff, and
9
- # rate-limit behavior. Phronomy supplies cooperative cancellation and
10
- # isolates synchronous provider calls in {OffloadPool}.
9
+ # rate-limit behavior. Phronomy owns cooperative cancellation and isolates
10
+ # synchronous provider calls in {OffloadPool}.
11
11
  #
12
- # The agent pipeline calls {#complete_async} / {#stream_async} which wrap
13
- # those methods in an {OffloadPool} submission.
12
+ # The Agent pipeline calls the framework-owned {#complete_async} and
13
+ # {#stream_async} wrappers. Adapter implementers do not need to depend on
14
+ # EventLoop, FSMSession, OffloadPool, AgentInvocation, or ExecutionCoordinator.
15
+ #
16
+ # The current input to this SPI is the configured/materialized chat runtime
17
+ # object. Formalizing this SPI therefore does not imply a provider-neutral
18
+ # replacement for RubyLLMMaterializer.
19
+ #
20
+ # @api public
14
21
  class Base
15
22
  # Performs a blocking (non-streaming) LLM completion.
16
- # Implementors must call +chat.ask(message)+ (or equivalent) and
17
- # return the response object.
18
23
  #
19
- # @param chat [Object] the configured chat session object
20
- # @param message [String] the user message
24
+ # Implementors call the configured chat/runtime client and return its
25
+ # response object. Transport/retry policy remains adapter-owned.
26
+ #
27
+ # @param chat [Object] the configured/materialized chat runtime object
28
+ # @param message [String, nil] user message, or nil to continue without adding a new user turn
21
29
  # @param config [Hash] invocation config (e.g. +:cancellation_token+)
22
30
  # @return [Object] LLM response object
23
31
  # @raise [NotImplementedError]
24
- # @api private
32
+ # @api public
25
33
  def complete(chat, message, config: {})
26
34
  raise NotImplementedError, "#{self.class}#complete is not implemented"
27
35
  end
28
36
 
29
37
  # Performs a blocking streaming LLM completion.
30
- # Implementors must call +chat.ask(message) { |chunk| block.call(chunk) }+
31
- # (or equivalent) and return the response object.
32
38
  #
33
- # @param chat [Object] the configured chat session object
34
- # @param message [String] the user message
39
+ # @param chat [Object] the configured/materialized chat runtime object
40
+ # @param message [String, nil] user message, or nil to continue without adding a new user turn
35
41
  # @param config [Hash] invocation config
36
42
  # @yield [chunk] streaming chunk from the LLM
37
43
  # @return [Object] LLM response object
38
44
  # @raise [NotImplementedError]
39
- # @api private
45
+ # @api public
40
46
  def stream(chat, message, config: {}, &block)
41
47
  raise NotImplementedError, "#{self.class}#stream is not implemented"
42
48
  end
43
49
 
44
- # Submits a non-streaming LLM call to {OffloadPool} and returns
45
- # an {OffloadPool::PendingOperation}.
50
+ # Submits a non-streaming LLM call to {OffloadPool}.
46
51
  #
47
52
  # Transport timeout and retry remain the responsibility of the adapter or
48
53
  # provider client; Phronomy does not attach an additional operation timeout.
49
54
  #
50
- # @param chat [Object] configured chat session
51
- # @param message [String] user message
52
- # @param config [Hash] invocation config
53
- # @param pool [OffloadPool] pool to submit to
54
- # @return [OffloadPool::PendingOperation]
55
+ # @return [Phronomy::Task] caller-facing completion handle
55
56
  # @api private
56
57
  def complete_async(chat, message, config: {}, pool: default_pool)
57
58
  token = config[:cancellation_token]
@@ -60,18 +61,14 @@ module Phronomy
60
61
  end
61
62
  end
62
63
 
63
- # Submits a streaming LLM call to {OffloadPool} and returns
64
- # an {OffloadPool::PendingOperation}.
65
- #
66
- # The block is invoked on an OffloadPool worker thread. Agent code must
67
- # pass only a lightweight internal sink that posts a value to EventLoop;
68
- # Application callbacks must never be passed directly to this method.
64
+ # Submits a streaming LLM call to {OffloadPool}.
69
65
  #
70
- # Transport timeout and retry remain the responsibility of the adapter or
71
- # provider client; Phronomy does not attach an additional operation timeout.
66
+ # The block is invoked on an OffloadPool worker thread. Agent code must pass
67
+ # only a lightweight internal sink that posts a value to EventLoop;
68
+ # application callbacks must never be passed directly to this method.
72
69
  #
73
70
  # @yield [chunk] streaming chunk on the worker thread
74
- # @return [OffloadPool::PendingOperation]
71
+ # @return [Phronomy::Task] caller-facing completion handle
75
72
  # @api private
76
73
  def stream_async(chat, message, config: {}, pool: default_pool, &block)
77
74
  raise ArgumentError, "stream_async requires a block" unless block
@@ -2,42 +2,43 @@
2
2
 
3
3
  module Phronomy
4
4
  module LLMAdapter
5
- # LLM adapter that delegates to the RubyLLM blocking client.
5
+ # Default LLMAdapter SPI implementation backed by RubyLLM.
6
6
  #
7
- # This is the default adapter used by Phronomy agents. It wraps
8
- # +chat.ask+ (and its streaming variant) so that the synchronous provider
9
- # call runs inside {OffloadPool} rather than on the EventLoop thread.
7
+ # The synchronous +chat.ask+ / +chat.complete+ calls are invoked through the
8
+ # framework-owned async bridge in {LLMAdapter::Base}, so adapter consumers do
9
+ # not need to manage OffloadPool themselves.
10
10
  #
11
11
  # @example Explicitly configuring this adapter
12
12
  # Phronomy.configure do |c|
13
13
  # c.llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
14
14
  # end
15
+ #
16
+ # @api public
15
17
  class RubyLLM < Base
16
18
  # Delegates to +chat.ask(message)+ or +chat.complete+ when message is nil.
17
19
  #
18
20
  # Passing +nil+ for +message+ is used by the ReAct loop for continuation
19
21
  # turns where the user message has already been added to the chat history
20
- # (e.g. after a tool result) and the LLM should continue without a new
21
- # user turn.
22
+ # (for example after a Tool result).
22
23
  #
23
24
  # @param chat [Object] RubyLLM chat session
24
25
  # @param message [String, nil] user message, or nil to continue the chat
25
- # @param config [Hash] invocation config (not used directly by this impl)
26
+ # @param config [Hash] invocation config (not used directly here)
26
27
  # @return [Object] RubyLLM response
27
- # @api private
28
+ # @api public
28
29
  def complete(chat, message, config: {})
29
30
  message ? chat.ask(message) : chat.complete
30
31
  end
31
32
 
32
- # Delegates to +chat.ask(message) { |chunk| block.call(chunk) }+ or
33
- # +chat.complete(&block)+ when message is nil.
33
+ # Delegates to +chat.ask(message) { |chunk| ... }+ or +chat.complete(&block)+
34
+ # when message is nil.
34
35
  #
35
36
  # @param chat [Object] RubyLLM chat session
36
37
  # @param message [String, nil] user message, or nil to continue the chat
37
38
  # @param config [Hash] invocation config
38
- # @yield [chunk] streaming chunk forwarded from +chat.ask+ / +chat.complete+
39
+ # @yield [chunk] streaming chunk forwarded from RubyLLM
39
40
  # @return [Object] RubyLLM response
40
- # @api private
41
+ # @api public
41
42
  def stream(chat, message, config: {}, &block)
42
43
  message ? chat.ask(message, &block) : chat.complete(&block)
43
44
  end
@@ -1,20 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- # Namespace for LLM adapter implementations.
4
+ # Namespace for LLM call adapters.
5
5
  #
6
- # An LLMAdapter decouples Phronomy's agent pipeline from direct
7
- # dependency on the RubyLLM blocking client. All LLM calls in
8
- # {Agent::Base} are routed through the adapter so that:
6
+ # The public extension boundary is {LLMAdapter::Base#complete} and
7
+ # {LLMAdapter::Base#stream}. Those methods receive Phronomy's currently
8
+ # materialized chat/runtime object and perform the provider call. Phronomy owns
9
+ # the async/offload bridge around that synchronous contract.
9
10
  #
10
- # - Synchronous provider work can be submitted to {OffloadPool} for bounded
11
- # off-EventLoop execution.
12
- # - Alternative LLM clients can be swapped in without changing agent code.
11
+ # This SPI does not by itself make the complete input-materialization pipeline
12
+ # provider-neutral: the current Agent pipeline still materializes the canonical
13
+ # LLM input through RubyLLM-specific runtime objects before invoking the adapter.
13
14
  #
14
15
  # @example Configuring a custom adapter
15
16
  # Phronomy.configure do |c|
16
17
  # c.llm_adapter = MyCustomAdapter.new
17
18
  # end
19
+ #
20
+ # @api public
18
21
  module LLMAdapter
19
22
  end
20
23
  end
@@ -14,7 +14,11 @@ module Phronomy
14
14
  parse(input.is_a?(String) ? input : input.to_s)
15
15
  end
16
16
 
17
- # Implement in subclasses.
17
+ # Extension point implemented by custom parsers.
18
+ #
19
+ # @param text [String]
20
+ # @return [Object]
21
+ # @api public
18
22
  def parse(text)
19
23
  raise NotImplementedError, "#{self.class}#parse is not implemented"
20
24
  end
@@ -5,9 +5,53 @@ module Phronomy
5
5
  class ConflictError < Phronomy::Error; end
6
6
  class NotFoundError < Phronomy::Error; end
7
7
  class UnsupportedBackendError < Phronomy::Error; end
8
+ class SerializationError < Phronomy::Error; end
8
9
 
9
- attr_reader :contents, :agents, :journals, :executions, :workflow_states
10
+ REQUIRED_CAPABILITIES = {
11
+ atomic_all: true,
12
+ atomic_admission: true,
13
+ optimistic_revision: true
14
+ }.freeze
10
15
 
16
+ # Durable repository accessors supplied by a Persistence backend.
17
+ #
18
+ # The repository objects are part of the Backend SPI. They may be private
19
+ # implementation classes owned by the backend; they do not need to inherit
20
+ # from Phronomy repository base classes.
21
+ #
22
+ # @return [Object] content-addressed immutable content repository
23
+ # @api public
24
+ attr_reader :contents
25
+
26
+ # @return [Object] AgentRoot repository
27
+ # @api public
28
+ attr_reader :agents
29
+
30
+ # @return [Object] append-only Agent Journal repository
31
+ # @api public
32
+ attr_reader :journals
33
+
34
+ # @return [Object] AgentExecution repository
35
+ # @api public
36
+ attr_reader :executions
37
+
38
+ # @return [Object] durable Workflow snapshot repository
39
+ # @api public
40
+ attr_reader :workflow_states
41
+
42
+ # Initializes a Persistence backend with its durable repositories.
43
+ #
44
+ # Subclasses normally construct backend-specific repository objects and then
45
+ # call +super+. The backend must advertise every capability in
46
+ # {REQUIRED_CAPABILITIES}; construction fails fast otherwise.
47
+ #
48
+ # @param contents [Object]
49
+ # @param agents [Object]
50
+ # @param journals [Object]
51
+ # @param executions [Object]
52
+ # @param workflow_states [Object]
53
+ # @raise [UnsupportedBackendError] when a required capability is missing
54
+ # @api public
11
55
  def initialize(contents:, agents:, journals:, executions:, workflow_states:)
12
56
  @contents = contents
13
57
  @agents = agents
@@ -17,18 +61,67 @@ module Phronomy
17
61
  validate_capabilities!
18
62
  end
19
63
 
64
+ # Declares storage semantics provided by this backend.
65
+ #
66
+ # Required meanings:
67
+ # - +atomic_all+: all durable repositories can participate in one atomic
68
+ # transaction domain.
69
+ # - +atomic_admission+: Agent execution admission is atomic; at most one
70
+ # active/suspended execution may be admitted for one Agent. This does not
71
+ # mean cross-process Workflow admission or distributed locking.
72
+ # - +optimistic_revision+: Agent, Execution, Workflow revision checks and
73
+ # Journal position checks provide compare-and-swap conflict detection.
74
+ #
75
+ # @return [Hash{Symbol => Boolean}]
76
+ # @api public
20
77
  def capabilities
21
- {atomic_all: false, atomic_admission: false}.freeze
78
+ {
79
+ atomic_all: false,
80
+ atomic_admission: false,
81
+ optimistic_revision: false
82
+ }.freeze
22
83
  end
23
84
 
85
+ # Executes one atomic durable transaction.
86
+ #
87
+ # The object yielded to the block is a transaction-scoped Persistence view.
88
+ # It must respond to +contents+, +agents+, +journals+, +executions+,
89
+ # +workflow_states+, and +assert_agent_watermark!+. It may be +self+, but
90
+ # backends are free to yield a separate transaction view backed by a checked
91
+ # out connection/session.
92
+ #
93
+ # If the block raises, mutations made through the transaction view must not
94
+ # be committed. Storage failures whose commit outcome is fundamentally
95
+ # unknown remain backend/database failures; Phronomy does not claim
96
+ # exactly-once semantics for such failures.
97
+ #
98
+ # @yieldparam transaction_view [Object]
99
+ # @return [Object] the block result
100
+ # @raise [UnsupportedBackendError] when atomic transactions are unavailable
101
+ # @api public
24
102
  def transaction
25
103
  raise UnsupportedBackendError, "#{self.class} does not provide atomic_all"
26
104
  end
27
105
 
28
106
  # Verifies that a live Agent still owns the durable base it hydrated.
29
- # Backends should implement this as a revision/position precondition check,
30
- # not as a state reload returned to the caller.
31
- # @api private
107
+ #
108
+ # This is a Backend SPI operation invoked by Phronomy at durable barriers.
109
+ # Ordinary application code should not call it directly. The backend must
110
+ # compare the stored Agent revision and current Journal position against the
111
+ # supplied watermark in the same storage consistency view used by subsequent
112
+ # writes in the surrounding transaction.
113
+ #
114
+ # The method is a precondition check only. It must not reload or return
115
+ # replacement mutable Agent state; the live Agent remains the logical owner.
116
+ #
117
+ # @param agent_id [String]
118
+ # @param agent_revision [Integer]
119
+ # @param journal_position [Integer]
120
+ # @return [true]
121
+ # @raise [NotFoundError] when the Agent does not exist
122
+ # @raise [ConflictError] when either durable watermark component differs
123
+ # @raise [UnsupportedBackendError] when the backend does not implement the check
124
+ # @api public
32
125
  def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
33
126
  raise UnsupportedBackendError,
34
127
  "#{self.class} does not provide Agent durable-watermark checks"
@@ -37,8 +130,9 @@ module Phronomy
37
130
  private
38
131
 
39
132
  def validate_capabilities!
40
- required = {atomic_all: true, atomic_admission: true}
41
- missing = required.reject { |key, value| capabilities[key] == value }
133
+ missing = REQUIRED_CAPABILITIES.reject do |key, value|
134
+ capabilities[key] == value
135
+ end
42
136
  return if missing.empty?
43
137
 
44
138
  raise UnsupportedBackendError,
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.shared_examples "a persistence content store" do
4
+ let(:content_store) { persistence.contents }
5
+
6
+ it "returns the same content_id for the same bytes" do
7
+ first = content_store.put("same".b, canonicalization_version: 1)
8
+ second = content_store.put("same".b, canonicalization_version: 1)
9
+
10
+ expect(second).to eq(first)
11
+ end
12
+
13
+ it "round-trips binary bytes" do
14
+ bytes = "\x00\xFFpayload".b
15
+ content_id = content_store.put(bytes, canonicalization_version: 1)
16
+
17
+ expect(content_store.fetch(content_id)).to eq(bytes)
18
+ expect(content_store.exist?(content_id)).to be(true)
19
+ end
20
+
21
+ it "raises NotFoundError for a missing content_id" do
22
+ expect do
23
+ content_store.fetch("sha256:#{"0" * 64}")
24
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
25
+ end
26
+
27
+ it "isolates durable bytes from mutation of fetched values" do
28
+ content_id = content_store.put("immutable".b, canonicalization_version: 1)
29
+ fetched = content_store.fetch(content_id)
30
+ fetched << "-caller-change"
31
+
32
+ expect(content_store.fetch(content_id)).to eq("immutable".b)
33
+ end
34
+
35
+ it "supports UTF-8 text helpers" do
36
+ content_id = content_store.put_text("Grüße")
37
+
38
+ expect(content_store.fetch_text(content_id)).to eq("Grüße")
39
+ end
40
+
41
+ it "supports canonical JSON helpers" do
42
+ value = {
43
+ "z" => [1, true, nil],
44
+ "a" => {"message" => "hello"}
45
+ }
46
+ content_id = content_store.put_json(value)
47
+
48
+ expect(content_store.fetch_json(content_id)).to eq(value)
49
+ end
50
+ end