phronomy 0.18.0 → 0.20.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/CONTRIBUTING.md +30 -0
  4. data/README.md +2 -0
  5. data/docs/decisions/009-state-store-abstraction.md +1 -1
  6. data/docs/decisions/014-unified-persistence-durable-state.md +273 -0
  7. data/docs/features.md +27 -1
  8. data/docs/getting-started.md +37 -1
  9. data/docs/migrations/0.19.md +154 -0
  10. data/docs/persistence-backends.md +504 -0
  11. data/docs/runtime-and-concurrency.md +93 -2
  12. data/lib/phronomy/agent/agent_execution.rb +29 -0
  13. data/lib/phronomy/agent/base.rb +81 -36
  14. data/lib/phronomy/agent/context_assembler.rb +13 -3
  15. data/lib/phronomy/agent/execution_coordinator.rb +420 -249
  16. data/lib/phronomy/agent/journal_projection.rb +5 -1
  17. data/lib/phronomy/agent/llm_call_record.rb +20 -0
  18. data/lib/phronomy/configuration.rb +2 -1
  19. data/lib/phronomy/engine/event_loop.rb +86 -8
  20. data/lib/phronomy/engine/fsm_session.rb +6 -4
  21. data/lib/phronomy/engine/runtime.rb +7 -0
  22. data/lib/phronomy/persistence/in_memory.rb +113 -8
  23. data/lib/phronomy/persistence.rb +109 -6
  24. data/lib/phronomy/testing/persistence_contract/a_content_store.rb +50 -0
  25. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +164 -0
  26. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +215 -0
  27. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +119 -0
  28. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +99 -0
  29. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +202 -0
  30. data/lib/phronomy/testing/persistence_contract.rb +41 -0
  31. data/lib/phronomy/version.rb +1 -1
  32. data/lib/phronomy/workflow.rb +10 -9
  33. data/lib/phronomy/workflow_runner.rb +361 -95
  34. data/lib/phronomy.rb +9 -0
  35. metadata +12 -5
  36. data/lib/phronomy/state_store/base.rb +0 -48
  37. data/lib/phronomy/state_store/in_memory.rb +0 -62
  38. data/scripts/check_private_enforcement.rb +0 -93
@@ -10,9 +10,13 @@ module Phronomy
10
10
  ].freeze
11
11
  KNOWLEDGE_RESET_KINDS = %i[knowledge_cleared context_reset].freeze
12
12
 
13
- def initialize(persistence:, agent_root:)
13
+ def initialize(agent_root:, persistence: nil, records: nil)
14
14
  @persistence = persistence
15
15
  @agent_root = agent_root
16
+ @records = records&.dup&.freeze
17
+ if @records.nil? && @persistence.nil?
18
+ raise ArgumentError, "JournalProjection requires persistence: or records:"
19
+ end
16
20
  end
17
21
 
18
22
  def transcript_records
@@ -39,6 +39,10 @@ module Phronomy
39
39
  freeze
40
40
  end
41
41
 
42
+ # Returns the canonical durable representation of this LLM Call record.
43
+ #
44
+ # @return [Hash{String => Object}]
45
+ # @api public
42
46
  def to_h
43
47
  ATTRIBUTES.to_h do |name|
44
48
  value = public_send(name)
@@ -46,6 +50,22 @@ module Phronomy
46
50
  [name.to_s, value]
47
51
  end
48
52
  end
53
+
54
+ # Restores an LLM Call record from its canonical durable representation.
55
+ # String and Symbol top-level keys are accepted so database adapters may
56
+ # pass either a parsed JSON object or a Ruby-native Hash.
57
+ #
58
+ # @param hash [Hash]
59
+ # @return [LLMCallRecord]
60
+ # @api public
61
+ def self.from_h(hash)
62
+ attributes = ATTRIBUTES.to_h do |name|
63
+ key = hash.key?(name.to_s) ? name.to_s : name
64
+ [name, hash.fetch(key)]
65
+ end
66
+ attributes[:status] = attributes.fetch(:status).to_sym
67
+ new(**attributes)
68
+ end
49
69
  end
50
70
  end
51
71
  end
@@ -16,7 +16,7 @@ module Phronomy
16
16
  attr_accessor :trace_pii
17
17
  attr_accessor :logger
18
18
  attr_accessor :event_loop_stop_grace_seconds
19
- attr_accessor :state_store
19
+ attr_accessor :persistence
20
20
  attr_accessor :tool_result_max_size
21
21
  attr_accessor :llm_adapter
22
22
  attr_accessor :event_loop_starvation_threshold_seconds
@@ -53,6 +53,7 @@ module Phronomy
53
53
  @authorization_pool_size = 4
54
54
  @authorization_queue_size = 100
55
55
  @authorization_timeout = 5
56
+ @persistence = nil
56
57
  end
57
58
  end
58
59
  end
@@ -29,6 +29,7 @@ module Phronomy
29
29
  @fsms = {}
30
30
  @waiting = {}
31
31
  @admitted_session_ids = Set.new
32
+ @workflow_admissions = {}
32
33
 
33
34
  @lifecycle_mutex = Mutex.new
34
35
  @idle_cond = ConditionVariable.new
@@ -99,7 +100,7 @@ module Phronomy
99
100
  rescue
100
101
  @admitted_session_ids.delete(fsm_session.id)
101
102
  @outstanding_sessions -= 1
102
- @idle_cond.broadcast if @outstanding_sessions.zero?
103
+ @idle_cond.broadcast if runtime_idle_locked?
103
104
  raise
104
105
  end
105
106
  end
@@ -152,6 +153,73 @@ module Phronomy
152
153
  true
153
154
  end
154
155
 
156
+ # Reserves one logical Workflow thread for one concrete FSMSession execution.
157
+ # thread_id is durable Workflow identity; owner_fsm_session_id is the
158
+ # Runtime-only identity of the invocation/resume that currently owns it.
159
+ def admit_workflow(thread_id, owner_fsm_session_id:)
160
+ key = thread_id.to_s
161
+ owner = owner_fsm_session_id.to_s
162
+ raise ArgumentError, "thread_id must not be empty" if key.empty?
163
+ raise ArgumentError, "owner_fsm_session_id must not be empty" if owner.empty?
164
+
165
+ @lifecycle_mutex.synchronize do
166
+ ensure_accepting_registrations!
167
+ current_owner = @workflow_admissions[key]
168
+ if current_owner
169
+ raise Phronomy::Error,
170
+ "Workflow thread #{key.inspect} is already owned by " \
171
+ "FSMSession #{current_owner.inspect}"
172
+ end
173
+ @workflow_admissions[key] = owner
174
+ end
175
+ true
176
+ end
177
+
178
+ # Releases a Workflow reservation only when the caller is its current owner.
179
+ # A failed competing admission can therefore never release another session's
180
+ # reservation during cleanup.
181
+ def release_workflow(thread_id, owner_fsm_session_id:)
182
+ key = thread_id.to_s
183
+ owner = owner_fsm_session_id.to_s
184
+ @lifecycle_mutex.synchronize do
185
+ next false unless @workflow_admissions[key] == owner
186
+
187
+ @workflow_admissions.delete(key)
188
+ @idle_cond.broadcast if runtime_idle_locked?
189
+ true
190
+ end
191
+ end
192
+
193
+ def workflow_admission_owner(thread_id)
194
+ @lifecycle_mutex.synchronize { @workflow_admissions[thread_id.to_s] }
195
+ end
196
+
197
+ # Resolves durable Workflow identity to the currently owning FSMSession and
198
+ # enqueues the event atomically with that ownership check.
199
+ def post_to_workflow(thread_id:, event:, payload: nil)
200
+ queued_depth = nil
201
+ posted_event = nil
202
+ accepted = @lifecycle_mutex.synchronize do
203
+ next false unless accepting_events?
204
+
205
+ owner = @workflow_admissions[thread_id.to_s]
206
+ next false unless owner
207
+ next false unless @admitted_session_ids.include?(owner)
208
+
209
+ posted_event = Phronomy::Event.new(
210
+ type: event.to_sym,
211
+ target_id: owner,
212
+ payload: payload
213
+ )
214
+ queued_depth = enqueue([posted_event, monotonic_nanoseconds])
215
+ true
216
+ end
217
+ return false unless accepted
218
+
219
+ check_queue_backlog(queued_depth, posted_event)
220
+ true
221
+ end
222
+
155
223
  # Interrupts the queue wait so EventLoop can recompute the next timer deadline.
156
224
  def wake
157
225
  @queue.push(WAKE)
@@ -180,12 +248,12 @@ module Phronomy
180
248
  end
181
249
 
182
250
  def idle?
183
- @lifecycle_mutex.synchronize { @outstanding_sessions.zero? }
251
+ @lifecycle_mutex.synchronize { runtime_idle_locked? }
184
252
  end
185
253
 
186
254
  def wait_until_idle(deadline)
187
255
  @lifecycle_mutex.synchronize do
188
- until @outstanding_sessions.zero?
256
+ until runtime_idle_locked?
189
257
  remaining = deadline - monotonic_now
190
258
  return false if remaining <= 0
191
259
  @idle_cond.wait(@lifecycle_mutex, remaining)
@@ -287,7 +355,9 @@ module Phronomy
287
355
  session_id = event.payload.fetch(:session_id)
288
356
  session = @fsms.delete(session_id)
289
357
  waiter = @waiting.delete(session_id)
290
- # decrement before waking caller so wait_until_idle sees zero immediately
358
+ # decrement before waking caller so wait_until_idle sees the control-plane
359
+ # session count immediately; Workflow durable admission may intentionally
360
+ # keep Runtime non-idle until its terminal save completes.
291
361
  decrement_outstanding if session
292
362
  complete_waiter(waiter, event.payload.fetch(:result))
293
363
  when :start
@@ -297,10 +367,11 @@ module Phronomy
297
367
  @waiting[session.id] = waiter if waiter
298
368
  session.start
299
369
  when :agent_terminal_ready
300
- # Existing Agent terminalisation is retained in this proposal. A separate
301
- # follow-up may move it to an Agent-owned terminalising FSM state.
302
370
  cmd = event.payload.fetch(:command)
303
371
  cmd.coordinator.deliver_on_event_loop(cmd)
372
+ when :workflow_persistence_ready
373
+ cmd = event.payload.fetch(:command)
374
+ cmd.runner.deliver_persistence_on_event_loop(cmd)
304
375
  end
305
376
  end
306
377
 
@@ -314,7 +385,7 @@ module Phronomy
314
385
  def begin_stopping_if_idle
315
386
  @lifecycle_mutex.synchronize do
316
387
  return false unless @state == :draining
317
- return false unless @outstanding_sessions.zero?
388
+ return false unless runtime_idle_locked?
318
389
 
319
390
  @state = :stopping
320
391
  @queue.push(STOP)
@@ -337,6 +408,7 @@ module Phronomy
337
408
  @fsms.clear
338
409
  @lifecycle_mutex.synchronize do
339
410
  @admitted_session_ids.clear
411
+ @workflow_admissions.clear
340
412
  @outstanding_sessions = 0
341
413
  @idle_cond.broadcast
342
414
  end
@@ -356,6 +428,7 @@ module Phronomy
356
428
  @lifecycle_mutex.synchronize do
357
429
  @state = :failed
358
430
  @admitted_session_ids.clear
431
+ @workflow_admissions.clear
359
432
  @idle_cond.broadcast
360
433
  end
361
434
  cleanup_abandoned_work(error)
@@ -375,10 +448,14 @@ module Phronomy
375
448
  def decrement_outstanding
376
449
  @lifecycle_mutex.synchronize do
377
450
  @outstanding_sessions -= 1 if @outstanding_sessions.positive?
378
- @idle_cond.broadcast if @outstanding_sessions.zero?
451
+ @idle_cond.broadcast if runtime_idle_locked?
379
452
  end
380
453
  end
381
454
 
455
+ def runtime_idle_locked?
456
+ @outstanding_sessions.zero? && @workflow_admissions.empty?
457
+ end
458
+
382
459
  def join_until(deadline)
383
460
  remaining = deadline - monotonic_now
384
461
  return if remaining <= 0
@@ -391,6 +468,7 @@ module Phronomy
391
468
  @lifecycle_mutex.synchronize do
392
469
  @state = :terminated
393
470
  @admitted_session_ids.clear
471
+ @workflow_admissions.clear
394
472
  @thread = nil unless @thread&.alive?
395
473
  @idle_cond.broadcast
396
474
  end
@@ -25,9 +25,11 @@ module Phronomy
25
25
  event_loop:,
26
26
  resume_event: nil,
27
27
  resume_phase: nil,
28
- stable_observer: nil
28
+ stable_observer: nil,
29
+ graph_thread_id: nil
29
30
  )
30
31
  @id = id
32
+ @graph_thread_id = graph_thread_id || id
31
33
  @ctx = context
32
34
  @context = context
33
35
  @entry_point = entry_point
@@ -217,7 +219,7 @@ module Phronomy
217
219
  return if @done
218
220
 
219
221
  @done = true
220
- @ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
222
+ @ctx.set_graph_metadata(thread_id: @graph_thread_id, phase: :__end__)
221
223
  post_terminal_event(:finished, @ctx)
222
224
  end
223
225
 
@@ -225,7 +227,7 @@ module Phronomy
225
227
  return if @done
226
228
 
227
229
  @done = true
228
- @ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
230
+ @ctx.set_graph_metadata(thread_id: @graph_thread_id, phase: @current_state)
229
231
  post_terminal_event(:halted, @ctx)
230
232
  end
231
233
 
@@ -248,7 +250,7 @@ module Phronomy
248
250
 
249
251
  Phronomy.configuration.logger&.warn(
250
252
  "[Phronomy::FSMSession] EventLoop rejected terminal event " \
251
- "#{type.inspect} for #{@id}"
253
+ "#{type.inspect} for #{@id}"
252
254
  )
253
255
  end
254
256
 
@@ -69,6 +69,7 @@ module Phronomy
69
69
  @pool_registry = Phronomy::Concurrency::PoolRegistry.new(
70
70
  timer_queue_provider: -> { timer_queue }
71
71
  )
72
+ @agent_activations = Phronomy::Agent::ActivationRegistry.new
72
73
  @lifecycle_mutex = Mutex.new
73
74
  @shutdown_mutex = Mutex.new
74
75
  @state = :running
@@ -107,6 +108,12 @@ module Phronomy
107
108
  @timer_service.timer_queue
108
109
  end
109
110
 
111
+ # Process-local live Agent executions. Activations are transient runtime
112
+ # state and deliberately do not belong to Persistence.
113
+ def __agent_activations
114
+ @agent_activations
115
+ end
116
+
110
117
  def event_loop
111
118
  @lifecycle_mutex.synchronize do
112
119
  case @state
@@ -66,7 +66,7 @@ module Phronomy
66
66
 
67
67
  def save(agent_id, expected_revision:, root:)
68
68
  @owner.synchronize do
69
- current = load(agent_id)
69
+ current = @owner.state[:agents].fetch(agent_id.to_s) { raise NotFoundError, "agent not found: #{agent_id}" }
70
70
  unless current.agent_revision == expected_revision
71
71
  raise ConflictError,
72
72
  "agent revision conflict: expected #{expected_revision}, actual #{current.agent_revision}"
@@ -162,7 +162,7 @@ module Phronomy
162
162
 
163
163
  def save(execution_id, expected_revision:, execution:)
164
164
  @owner.synchronize do
165
- current = load(execution_id)
165
+ current = @owner.state[:executions].fetch(execution_id.to_s) { raise NotFoundError, "execution not found: #{execution_id}" }
166
166
  unless current.execution_revision == expected_revision
167
167
  raise ConflictError,
168
168
  "execution revision conflict: expected #{expected_revision}, actual #{current.execution_revision}"
@@ -209,31 +209,112 @@ module Phronomy
209
209
  end
210
210
  end
211
211
 
212
- attr_reader :state
212
+ class WorkflowStates
213
+ def initialize(owner) = @owner = owner
214
+
215
+ def load(thread_id)
216
+ @owner.synchronize do
217
+ record = @owner.workflow_state_data[thread_id.to_s]
218
+ next nil unless record
219
+
220
+ {
221
+ snapshot: @owner.deep_dup_workflow_value(record.fetch(:snapshot)),
222
+ revision: record.fetch(:revision)
223
+ }.freeze
224
+ end
225
+ end
226
+
227
+ def save(thread_id, expected_revision:, snapshot:)
228
+ @owner.synchronize do
229
+ key = thread_id.to_s
230
+ current = @owner.workflow_state_data[key]
231
+ actual_revision = current&.fetch(:revision)
232
+ unless actual_revision == expected_revision
233
+ raise ConflictError,
234
+ "workflow state revision conflict for #{key}: " \
235
+ "expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
236
+ end
237
+
238
+ next_revision = actual_revision ? actual_revision + 1 : 1
239
+ @owner.workflow_state_data[key] = {
240
+ snapshot: @owner.deep_dup_workflow_value(snapshot),
241
+ revision: next_revision
242
+ }
243
+ next_revision
244
+ end
245
+ end
246
+
247
+ def delete(thread_id, expected_revision:)
248
+ @owner.synchronize do
249
+ key = thread_id.to_s
250
+ current = @owner.workflow_state_data[key]
251
+ actual_revision = current&.fetch(:revision)
252
+ unless actual_revision == expected_revision
253
+ raise ConflictError,
254
+ "workflow state revision conflict for #{key}: " \
255
+ "expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
256
+ end
257
+ @owner.workflow_state_data.delete(key)
258
+ end
259
+ nil
260
+ end
261
+ end
262
+
263
+ attr_reader :state, :workflow_state_data
213
264
 
214
265
  def initialize
215
266
  @monitor = Monitor.new
216
267
  @state = {contents: {}, agents: {}, journals: {}, executions: {}}
268
+ @workflow_state_data = {}
217
269
  @contents = Contents.new(self)
218
270
  @agents = Agents.new(self)
219
271
  @journals = Journals.new(self)
220
272
  @executions = Executions.new(self)
221
- @activations = Phronomy::Agent::ActivationRegistry.new
222
- super(contents: @contents, agents: @agents, journals: @journals,
223
- executions: @executions, activations: @activations)
273
+ @workflow_states = WorkflowStates.new(self)
274
+ super(
275
+ contents: @contents,
276
+ agents: @agents,
277
+ journals: @journals,
278
+ executions: @executions,
279
+ workflow_states: @workflow_states
280
+ )
224
281
  end
225
282
 
226
283
  def capabilities
227
284
  {atomic_all: true, atomic_admission: true, optimistic_revision: true}.freeze
228
285
  end
229
286
 
287
+ def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
288
+ synchronize do
289
+ stored = @state[:agents][agent_id.to_s]
290
+ unless stored
291
+ raise NotFoundError, "Agent not found: #{agent_id}"
292
+ end
293
+
294
+ if stored.agent_revision != agent_revision
295
+ raise ConflictError,
296
+ "agent revision conflict: expected #{agent_revision}, actual #{stored.agent_revision}"
297
+ end
298
+
299
+ actual_position = Array(@state[:journals][agent_id.to_s]).length
300
+ if actual_position != journal_position
301
+ raise ConflictError,
302
+ "journal position conflict: expected #{journal_position}, actual #{actual_position}"
303
+ end
304
+
305
+ true
306
+ end
307
+ end
308
+
230
309
  def transaction
231
310
  synchronize do
232
- snapshot = Marshal.load(Marshal.dump(@state))
311
+ state_snapshot = Marshal.load(Marshal.dump(@state))
312
+ workflow_snapshot = deep_dup_workflow_value(@workflow_state_data)
233
313
  begin
234
314
  yield self
235
315
  rescue
236
- @state = snapshot
316
+ @state.replace(state_snapshot)
317
+ @workflow_state_data.replace(workflow_snapshot)
237
318
  raise
238
319
  end
239
320
  end
@@ -242,6 +323,30 @@ module Phronomy
242
323
  def synchronize(&block)
243
324
  @monitor.synchronize(&block)
244
325
  end
326
+
327
+ # Workflow fields historically accepted ordinary Ruby values in the
328
+ # in-memory store. Keep that contract without forcing the Agent durable
329
+ # state Marshal snapshot to serialize arbitrary Workflow values.
330
+ def deep_dup_workflow_value(value)
331
+ case value
332
+ when Hash
333
+ value.each_with_object({}) do |(key, child), result|
334
+ result[deep_dup_workflow_value(key)] = deep_dup_workflow_value(child)
335
+ end
336
+ when Array
337
+ value.map { |child| deep_dup_workflow_value(child) }
338
+ when NilClass, Symbol, Integer, Float, TrueClass, FalseClass
339
+ value
340
+ else
341
+ return value if value.frozen?
342
+
343
+ begin
344
+ value.dup
345
+ rescue TypeError
346
+ value
347
+ end
348
+ end
349
+ end
245
350
  end
246
351
  end
247
352
  end
@@ -5,31 +5,134 @@ 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, :activations
10
+ REQUIRED_CAPABILITIES = {
11
+ atomic_all: true,
12
+ atomic_admission: true,
13
+ optimistic_revision: true
14
+ }.freeze
10
15
 
11
- def initialize(contents:, agents:, journals:, executions:, activations:)
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
55
+ def initialize(contents:, agents:, journals:, executions:, workflow_states:)
12
56
  @contents = contents
13
57
  @agents = agents
14
58
  @journals = journals
15
59
  @executions = executions
16
- @activations = activations
60
+ @workflow_states = workflow_states
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
 
106
+ # Verifies that a live Agent still owns the durable base it hydrated.
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
125
+ def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
126
+ raise UnsupportedBackendError,
127
+ "#{self.class} does not provide Agent durable-watermark checks"
128
+ end
129
+
28
130
  private
29
131
 
30
132
  def validate_capabilities!
31
- required = {atomic_all: true, atomic_admission: true}
32
- missing = required.reject { |key, value| capabilities[key] == value }
133
+ missing = REQUIRED_CAPABILITIES.reject do |key, value|
134
+ capabilities[key] == value
135
+ end
33
136
  return if missing.empty?
34
137
 
35
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