phronomy 0.19.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.
@@ -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
@@ -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
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ RSpec.shared_examples "a Journal repository" do
6
+ let(:journal_repository) { persistence.journals }
7
+ let(:journal_agent_root) do
8
+ Phronomy::Agent::AgentRoot.create(
9
+ agent_id: "journal-agent-#{SecureRandom.uuid}",
10
+ agent_definition_id: "contract-agent",
11
+ definition_version: 1
12
+ )
13
+ end
14
+
15
+ def build_contract_journal_record(agent_id:, record_id: SecureRandom.uuid, content_ref: nil)
16
+ Phronomy::Agent::JournalRecord.new(
17
+ agent_id: agent_id,
18
+ record_id: record_id,
19
+ kind: :knowledge,
20
+ channel: :context,
21
+ role: :user,
22
+ content_ref: content_ref,
23
+ context_candidate: true
24
+ )
25
+ end
26
+
27
+ before do
28
+ persistence.agents.create(journal_agent_root)
29
+ end
30
+
31
+ it "appends records at the expected position and assigns durable sequences" do
32
+ first = build_contract_journal_record(agent_id: journal_agent_root.agent_id)
33
+ second = build_contract_journal_record(agent_id: journal_agent_root.agent_id)
34
+
35
+ appended = journal_repository.append(
36
+ journal_agent_root.agent_id,
37
+ expected_position: 0,
38
+ records: [first, second]
39
+ )
40
+
41
+ expect(appended.map(&:sequence)).to eq([1, 2])
42
+ expect(journal_repository.head(journal_agent_root.agent_id)).to eq(2)
43
+ end
44
+
45
+ it "rejects an unexpected append position" do
46
+ record = build_contract_journal_record(agent_id: journal_agent_root.agent_id)
47
+
48
+ expect do
49
+ journal_repository.append(
50
+ journal_agent_root.agent_id,
51
+ expected_position: 1,
52
+ records: [record]
53
+ )
54
+ end.to raise_error(Phronomy::Persistence::ConflictError)
55
+ end
56
+
57
+ it "rejects a duplicate record_id already present in the Journal" do
58
+ record_id = SecureRandom.uuid
59
+ first = build_contract_journal_record(
60
+ agent_id: journal_agent_root.agent_id,
61
+ record_id: record_id
62
+ )
63
+ duplicate = build_contract_journal_record(
64
+ agent_id: journal_agent_root.agent_id,
65
+ record_id: record_id
66
+ )
67
+ journal_repository.append(
68
+ journal_agent_root.agent_id,
69
+ expected_position: 0,
70
+ records: [first]
71
+ )
72
+
73
+ expect do
74
+ journal_repository.append(
75
+ journal_agent_root.agent_id,
76
+ expected_position: 1,
77
+ records: [duplicate]
78
+ )
79
+ end.to raise_error(Phronomy::Persistence::ConflictError)
80
+ end
81
+
82
+ it "rejects duplicate record_ids within one append" do
83
+ record_id = SecureRandom.uuid
84
+ records = 2.times.map do
85
+ build_contract_journal_record(
86
+ agent_id: journal_agent_root.agent_id,
87
+ record_id: record_id
88
+ )
89
+ end
90
+
91
+ expect do
92
+ journal_repository.append(
93
+ journal_agent_root.agent_id,
94
+ expected_position: 0,
95
+ records: records
96
+ )
97
+ end.to raise_error(Phronomy::Persistence::ConflictError)
98
+ end
99
+
100
+ it "rejects records belonging to another Agent" do
101
+ record = build_contract_journal_record(agent_id: "other-agent")
102
+
103
+ expect do
104
+ journal_repository.append(
105
+ journal_agent_root.agent_id,
106
+ expected_position: 0,
107
+ records: [record]
108
+ )
109
+ end.to raise_error(Phronomy::Persistence::ConflictError)
110
+ end
111
+
112
+ it "reads records in sequence order and supports after/limit" do
113
+ records = 3.times.map do
114
+ build_contract_journal_record(agent_id: journal_agent_root.agent_id)
115
+ end
116
+ appended = journal_repository.append(
117
+ journal_agent_root.agent_id,
118
+ expected_position: 0,
119
+ records: records
120
+ )
121
+
122
+ expect(journal_repository.read(journal_agent_root.agent_id).map(&:record_id))
123
+ .to eq(appended.map(&:record_id))
124
+ expect(journal_repository.read(journal_agent_root.agent_id, after: 1).map(&:sequence))
125
+ .to eq([2, 3])
126
+ expect(journal_repository.read(journal_agent_root.agent_id, limit: 2).map(&:sequence))
127
+ .to eq([1, 2])
128
+ end
129
+
130
+ it "isolates durable Journal state from mutation of returned collections" do
131
+ record = build_contract_journal_record(agent_id: journal_agent_root.agent_id)
132
+ journal_repository.append(
133
+ journal_agent_root.agent_id,
134
+ expected_position: 0,
135
+ records: [record]
136
+ )
137
+ loaded = journal_repository.read(journal_agent_root.agent_id)
138
+
139
+ begin
140
+ loaded.clear
141
+ rescue FrozenError
142
+ nil
143
+ end
144
+
145
+ expect(journal_repository.read(journal_agent_root.agent_id).length).to eq(1)
146
+ end
147
+
148
+ it "returns an empty Journal with head zero when no records exist" do
149
+ expect(journal_repository.read(journal_agent_root.agent_id)).to eq([])
150
+ expect(journal_repository.head(journal_agent_root.agent_id)).to eq(0)
151
+ end
152
+
153
+ it "deletes the Agent Journal" do
154
+ journal_repository.append(
155
+ journal_agent_root.agent_id,
156
+ expected_position: 0,
157
+ records: [build_contract_journal_record(agent_id: journal_agent_root.agent_id)]
158
+ )
159
+ journal_repository.delete(journal_agent_root.agent_id)
160
+
161
+ expect(journal_repository.read(journal_agent_root.agent_id)).to eq([])
162
+ expect(journal_repository.head(journal_agent_root.agent_id)).to eq(0)
163
+ end
164
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ RSpec.shared_examples "a Persistence backend" do
6
+ let(:backend_agent_root) do
7
+ Phronomy::Agent::AgentRoot.create(
8
+ agent_id: "backend-agent-#{SecureRandom.uuid}",
9
+ agent_definition_id: "contract-agent",
10
+ definition_version: 1
11
+ )
12
+ end
13
+
14
+ def build_backend_execution(root)
15
+ record = Phronomy::Agent::JournalRecord.new(
16
+ agent_id: root.agent_id,
17
+ kind: :input_received,
18
+ channel: :external,
19
+ role: :user,
20
+ context_candidate: false
21
+ )
22
+ Phronomy::Agent::AgentExecution.start(agent_root: root, input_record: record)
23
+ end
24
+
25
+ it "advertises every required capability" do
26
+ Phronomy::Persistence::REQUIRED_CAPABILITIES.each do |name, required_value|
27
+ expect(persistence.capabilities[name]).to eq(required_value)
28
+ end
29
+ end
30
+
31
+ it "yields a transaction view that exposes the complete durable SPI" do
32
+ persistence.transaction do |tx|
33
+ expect(tx).to respond_to(
34
+ :contents,
35
+ :agents,
36
+ :journals,
37
+ :executions,
38
+ :workflow_states,
39
+ :assert_agent_watermark!
40
+ )
41
+ end
42
+ end
43
+
44
+ it "returns the transaction block result" do
45
+ expect(persistence.transaction { |_tx| :contract_result }).to eq(:contract_result)
46
+ end
47
+
48
+ it "commits changes across durable repositories as one transaction" do
49
+ root = backend_agent_root
50
+ execution = build_backend_execution(root)
51
+ workflow_id = "workflow-#{SecureRandom.uuid}"
52
+ content_id = nil
53
+
54
+ persistence.transaction do |tx|
55
+ content_id = tx.contents.put_text("committed")
56
+ tx.agents.create(root)
57
+ appended = tx.journals.append(
58
+ root.agent_id,
59
+ expected_position: 0,
60
+ records: [
61
+ Phronomy::Agent::JournalRecord.new(
62
+ agent_id: root.agent_id,
63
+ kind: :knowledge,
64
+ channel: :context,
65
+ role: :user,
66
+ content_ref: content_id,
67
+ context_candidate: true
68
+ )
69
+ ]
70
+ )
71
+ tx.executions.create_active(execution)
72
+ tx.workflow_states.save(
73
+ workflow_id,
74
+ expected_revision: nil,
75
+ snapshot: {fields: {value: "committed"}, phase: "pause"}
76
+ )
77
+
78
+ updated_root = root.with(
79
+ agent_revision: 1,
80
+ journal_position: appended.length,
81
+ lifecycle_status: :active
82
+ )
83
+ tx.agents.save(root.agent_id, expected_revision: 0, root: updated_root)
84
+ end
85
+
86
+ expect(persistence.contents.exist?(content_id)).to be(true)
87
+ expect(persistence.agents.load(root.agent_id).agent_revision).to eq(1)
88
+ expect(persistence.journals.head(root.agent_id)).to eq(1)
89
+ expect(persistence.executions.load(execution.execution_id).execution_id)
90
+ .to eq(execution.execution_id)
91
+ expect(persistence.workflow_states.load(workflow_id)).not_to be_nil
92
+ end
93
+
94
+ it "rolls back all durable repositories when the transaction block raises" do
95
+ root = backend_agent_root
96
+ execution = build_backend_execution(root)
97
+ workflow_id = "workflow-#{SecureRandom.uuid}"
98
+ content_id = nil
99
+
100
+ expect do
101
+ persistence.transaction do |tx|
102
+ content_id = tx.contents.put_text("temporary-#{SecureRandom.uuid}")
103
+ tx.agents.create(root)
104
+ tx.journals.append(
105
+ root.agent_id,
106
+ expected_position: 0,
107
+ records: [
108
+ Phronomy::Agent::JournalRecord.new(
109
+ agent_id: root.agent_id,
110
+ kind: :knowledge,
111
+ channel: :context,
112
+ role: :user,
113
+ content_ref: content_id,
114
+ context_candidate: true
115
+ )
116
+ ]
117
+ )
118
+ tx.executions.create_active(execution)
119
+ tx.workflow_states.save(
120
+ workflow_id,
121
+ expected_revision: nil,
122
+ snapshot: {fields: {value: "temporary"}, phase: "pause"}
123
+ )
124
+ raise "rollback-contract"
125
+ end
126
+ end.to raise_error("rollback-contract")
127
+
128
+ expect(persistence.contents.exist?(content_id)).to be(false)
129
+ expect do
130
+ persistence.agents.load(root.agent_id)
131
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
132
+ expect(persistence.journals.head(root.agent_id)).to eq(0)
133
+ expect do
134
+ persistence.executions.load(execution.execution_id)
135
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
136
+ expect(persistence.workflow_states.load(workflow_id)).to be_nil
137
+ end
138
+
139
+ it "accepts the current Agent revision and Journal position watermark" do
140
+ root = backend_agent_root
141
+ persistence.agents.create(root)
142
+
143
+ expect(
144
+ persistence.assert_agent_watermark!(
145
+ agent_id: root.agent_id,
146
+ agent_revision: root.agent_revision,
147
+ journal_position: root.journal_position
148
+ )
149
+ ).to be(true)
150
+ end
151
+
152
+ it "raises ConflictError when the durable Agent revision has advanced" do
153
+ root = backend_agent_root
154
+ persistence.agents.create(root)
155
+ advanced = root.with(agent_revision: 1)
156
+ persistence.agents.save(root.agent_id, expected_revision: 0, root: advanced)
157
+
158
+ expect do
159
+ persistence.assert_agent_watermark!(
160
+ agent_id: root.agent_id,
161
+ agent_revision: 0,
162
+ journal_position: 0
163
+ )
164
+ end.to raise_error(Phronomy::Persistence::ConflictError)
165
+ end
166
+
167
+ it "raises ConflictError when the durable Journal position has advanced" do
168
+ root = backend_agent_root
169
+ persistence.agents.create(root)
170
+ persistence.journals.append(
171
+ root.agent_id,
172
+ expected_position: 0,
173
+ records: [
174
+ Phronomy::Agent::JournalRecord.new(
175
+ agent_id: root.agent_id,
176
+ kind: :knowledge,
177
+ channel: :context,
178
+ role: :user,
179
+ context_candidate: true
180
+ )
181
+ ]
182
+ )
183
+
184
+ expect do
185
+ persistence.assert_agent_watermark!(
186
+ agent_id: root.agent_id,
187
+ agent_revision: 0,
188
+ journal_position: 0
189
+ )
190
+ end.to raise_error(Phronomy::Persistence::ConflictError)
191
+ end
192
+
193
+ it "rolls back earlier writes when a watermark precondition fails" do
194
+ root = backend_agent_root
195
+ persistence.agents.create(root)
196
+ advanced = root.with(agent_revision: 1)
197
+ persistence.agents.save(root.agent_id, expected_revision: 0, root: advanced)
198
+ temporary_content_id = nil
199
+
200
+ expect do
201
+ persistence.transaction do |tx|
202
+ temporary_content_id = tx.contents.put_text(
203
+ "watermark-rollback-#{SecureRandom.uuid}"
204
+ )
205
+ tx.assert_agent_watermark!(
206
+ agent_id: root.agent_id,
207
+ agent_revision: 0,
208
+ journal_position: 0
209
+ )
210
+ end
211
+ end.to raise_error(Phronomy::Persistence::ConflictError)
212
+
213
+ expect(persistence.contents.exist?(temporary_content_id)).to be(false)
214
+ end
215
+ end