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.
- checksums.yaml +4 -4
- data/CONTRIBUTING.md +30 -0
- data/docs/decisions/014-unified-persistence-durable-state.md +5 -0
- data/docs/features.md +21 -1
- data/docs/migrations/0.19.md +12 -6
- data/docs/persistence-backends.md +504 -0
- data/lib/phronomy/agent/agent_execution.rb +29 -0
- data/lib/phronomy/agent/llm_call_record.rb +20 -0
- data/lib/phronomy/persistence.rb +101 -7
- data/lib/phronomy/testing/persistence_contract/a_content_store.rb +50 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +164 -0
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +215 -0
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +119 -0
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +99 -0
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +202 -0
- data/lib/phronomy/testing/persistence_contract.rb +41 -0
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +6 -0
- metadata +10 -3
- data/scripts/check_private_enforcement.rb +0 -93
|
@@ -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
|
data/lib/phronomy/persistence.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
{
|
|
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
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
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
|
-
|
|
41
|
-
|
|
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
|