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.
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ RSpec.shared_examples "a workflow state repository" do
6
+ let(:repository) { persistence.workflow_states }
7
+
8
+ def workflow_contract_value(hash, key)
9
+ hash.key?(key) ? hash[key] : hash[key.to_s]
10
+ end
11
+
12
+ def workflow_contract_snapshot(record)
13
+ workflow_contract_value(record, :snapshot)
14
+ end
15
+
16
+ def workflow_contract_revision(record)
17
+ workflow_contract_value(record, :revision)
18
+ end
19
+
20
+ it "returns nil for an unknown thread_id" do
21
+ expect(repository.load("missing-#{SecureRandom.uuid}")).to be_nil
22
+ end
23
+
24
+ it "uses optimistic revisions for save" do
25
+ thread_id = "t1-#{SecureRandom.uuid}"
26
+ expect(
27
+ repository.save(
28
+ thread_id,
29
+ expected_revision: nil,
30
+ snapshot: {fields: {value: 1}, phase: "pause"}
31
+ )
32
+ ).to eq(1)
33
+
34
+ expect(
35
+ repository.save(
36
+ thread_id,
37
+ expected_revision: 1,
38
+ snapshot: {fields: {value: 2}, phase: "__end__"}
39
+ )
40
+ ).to eq(2)
41
+ end
42
+
43
+ it "rejects a stale expected_revision" do
44
+ thread_id = "t1-#{SecureRandom.uuid}"
45
+ repository.save(
46
+ thread_id,
47
+ expected_revision: nil,
48
+ snapshot: {fields: {value: 1}, phase: "pause"}
49
+ )
50
+
51
+ expect do
52
+ repository.save(
53
+ thread_id,
54
+ expected_revision: nil,
55
+ snapshot: {fields: {value: 2}, phase: "__end__"}
56
+ )
57
+ end.to raise_error(Phronomy::Persistence::ConflictError)
58
+ end
59
+
60
+ it "returns a snapshot representation accepted by WorkflowRunner" do
61
+ thread_id = "t1-#{SecureRandom.uuid}"
62
+ repository.save(
63
+ thread_id,
64
+ expected_revision: nil,
65
+ snapshot: {fields: {value: 1}, phase: "pause"}
66
+ )
67
+
68
+ record = repository.load(thread_id)
69
+ snapshot = workflow_contract_snapshot(record)
70
+ fields = workflow_contract_value(snapshot, :fields)
71
+
72
+ expect(workflow_contract_revision(record)).to eq(1)
73
+ expect(workflow_contract_value(fields, :value)).to eq(1)
74
+ expect(workflow_contract_value(snapshot, :phase)).to eq("pause")
75
+ end
76
+
77
+ it "isolates stored snapshots from caller mutation" do
78
+ thread_id = "t1-#{SecureRandom.uuid}"
79
+ repository.save(
80
+ thread_id,
81
+ expected_revision: nil,
82
+ snapshot: {fields: {values: [1]}, phase: "pause"}
83
+ )
84
+
85
+ loaded = repository.load(thread_id)
86
+ snapshot = workflow_contract_snapshot(loaded)
87
+ fields = workflow_contract_value(snapshot, :fields)
88
+ values = workflow_contract_value(fields, :values)
89
+
90
+ begin
91
+ values << 2
92
+ rescue FrozenError
93
+ nil
94
+ end
95
+
96
+ reloaded = repository.load(thread_id)
97
+ reloaded_fields = workflow_contract_value(
98
+ workflow_contract_snapshot(reloaded),
99
+ :fields
100
+ )
101
+ expect(workflow_contract_value(reloaded_fields, :values)).to eq([1])
102
+ end
103
+
104
+ it "deletes only at the expected revision" do
105
+ thread_id = "t1-#{SecureRandom.uuid}"
106
+ repository.save(
107
+ thread_id,
108
+ expected_revision: nil,
109
+ snapshot: {fields: {}, phase: "pause"}
110
+ )
111
+
112
+ expect do
113
+ repository.delete(thread_id, expected_revision: 99)
114
+ end.to raise_error(Phronomy::Persistence::ConflictError)
115
+
116
+ repository.delete(thread_id, expected_revision: 1)
117
+ expect(repository.load(thread_id)).to be_nil
118
+ end
119
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ RSpec.shared_examples "an Agent repository" do
6
+ let(:agent_repository) { persistence.agents }
7
+ let(:agent_root) do
8
+ Phronomy::Agent::AgentRoot.create(
9
+ agent_id: "contract-agent-#{SecureRandom.uuid}",
10
+ agent_definition_id: "contract-agent",
11
+ definition_version: 1
12
+ )
13
+ end
14
+
15
+ it "creates and loads an AgentRoot" do
16
+ expect(agent_repository.create(agent_root).to_h).to eq(agent_root.to_h)
17
+ expect(agent_repository.load(agent_root.agent_id).to_h).to eq(agent_root.to_h)
18
+ end
19
+
20
+ it "rejects duplicate create" do
21
+ agent_repository.create(agent_root)
22
+
23
+ expect do
24
+ agent_repository.create(agent_root)
25
+ end.to raise_error(Phronomy::Persistence::ConflictError)
26
+ end
27
+
28
+ it "raises NotFoundError for a missing Agent" do
29
+ expect do
30
+ agent_repository.load("missing-#{SecureRandom.uuid}")
31
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
32
+ end
33
+
34
+ it "saves only at the expected revision" do
35
+ agent_repository.create(agent_root)
36
+ updated = agent_root.with(agent_revision: agent_root.agent_revision + 1)
37
+
38
+ expect(
39
+ agent_repository.save(
40
+ agent_root.agent_id,
41
+ expected_revision: agent_root.agent_revision,
42
+ root: updated
43
+ ).to_h
44
+ ).to eq(updated.to_h)
45
+
46
+ expect(agent_repository.load(agent_root.agent_id).agent_revision).to eq(1)
47
+ end
48
+
49
+ it "rejects a stale expected revision" do
50
+ agent_repository.create(agent_root)
51
+ first = agent_root.with(agent_revision: 1)
52
+ agent_repository.save(agent_root.agent_id, expected_revision: 0, root: first)
53
+ stale = agent_root.with(agent_revision: 1)
54
+
55
+ expect do
56
+ agent_repository.save(agent_root.agent_id, expected_revision: 0, root: stale)
57
+ end.to raise_error(Phronomy::Persistence::ConflictError)
58
+ end
59
+
60
+ it "rejects an Agent identity mismatch" do
61
+ agent_repository.create(agent_root)
62
+ other = Phronomy::Agent::AgentRoot.create(
63
+ agent_id: "other-#{SecureRandom.uuid}",
64
+ agent_definition_id: "contract-agent",
65
+ definition_version: 1
66
+ ).with(agent_revision: 1)
67
+
68
+ expect do
69
+ agent_repository.save(
70
+ agent_root.agent_id,
71
+ expected_revision: 0,
72
+ root: other
73
+ )
74
+ end.to raise_error(Phronomy::Persistence::ConflictError)
75
+ end
76
+
77
+ it "requires revision to advance exactly once" do
78
+ agent_repository.create(agent_root)
79
+ skipped = agent_root.with(agent_revision: 2)
80
+
81
+ expect do
82
+ agent_repository.save(
83
+ agent_root.agent_id,
84
+ expected_revision: 0,
85
+ root: skipped
86
+ )
87
+ end.to raise_error(Phronomy::Persistence::ConflictError)
88
+ end
89
+
90
+ it "deletes idempotently" do
91
+ agent_repository.create(agent_root)
92
+ agent_repository.delete(agent_root.agent_id)
93
+ expect { agent_repository.delete(agent_root.agent_id) }.not_to raise_error
94
+
95
+ expect do
96
+ agent_repository.load(agent_root.agent_id)
97
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
98
+ end
99
+ end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ RSpec.shared_examples "an Execution repository" do
6
+ let(:execution_repository) { persistence.executions }
7
+ let(:execution_agent_root) do
8
+ Phronomy::Agent::AgentRoot.create(
9
+ agent_id: "execution-agent-#{SecureRandom.uuid}",
10
+ agent_definition_id: "contract-agent",
11
+ definition_version: 1
12
+ )
13
+ end
14
+
15
+ def build_contract_execution(root)
16
+ input_record = Phronomy::Agent::JournalRecord.new(
17
+ agent_id: root.agent_id,
18
+ kind: :input_received,
19
+ channel: :external,
20
+ role: :user,
21
+ context_candidate: false
22
+ )
23
+ Phronomy::Agent::AgentExecution.start(
24
+ agent_root: root,
25
+ input_record: input_record
26
+ )
27
+ end
28
+
29
+ before do
30
+ persistence.agents.create(execution_agent_root)
31
+ end
32
+
33
+ it "creates and loads an active execution" do
34
+ execution = build_contract_execution(execution_agent_root)
35
+
36
+ expect(execution_repository.create_active(execution).to_h).to eq(execution.to_h)
37
+ expect(execution_repository.load(execution.execution_id).to_h).to eq(execution.to_h)
38
+ end
39
+
40
+ it "raises NotFoundError for a missing execution" do
41
+ expect do
42
+ execution_repository.load("missing-#{SecureRandom.uuid}")
43
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
44
+ end
45
+
46
+ it "rejects a duplicate execution_id" do
47
+ execution = build_contract_execution(execution_agent_root)
48
+ execution_repository.create_active(execution)
49
+
50
+ expect do
51
+ execution_repository.create_active(execution)
52
+ end.to raise_error(Phronomy::Persistence::ConflictError)
53
+ end
54
+
55
+ it "admits at most one active or suspended execution for one Agent" do
56
+ first = build_contract_execution(execution_agent_root)
57
+ second = build_contract_execution(execution_agent_root)
58
+ execution_repository.create_active(first)
59
+
60
+ expect do
61
+ execution_repository.create_active(second)
62
+ end.to raise_error(Phronomy::AgentBusyError)
63
+ end
64
+
65
+ it "allows different Agents to have active executions" do
66
+ other_root = Phronomy::Agent::AgentRoot.create(
67
+ agent_id: "execution-agent-#{SecureRandom.uuid}",
68
+ agent_definition_id: "contract-agent",
69
+ definition_version: 1
70
+ )
71
+ persistence.agents.create(other_root)
72
+
73
+ expect do
74
+ execution_repository.create_active(build_contract_execution(execution_agent_root))
75
+ execution_repository.create_active(build_contract_execution(other_root))
76
+ end.not_to raise_error
77
+ end
78
+
79
+ it "saves only at the expected execution revision" do
80
+ execution = build_contract_execution(execution_agent_root)
81
+ execution_repository.create_active(execution)
82
+ updated = execution.with(status: :active, phase: :calling_llm)
83
+
84
+ expect(
85
+ execution_repository.save(
86
+ execution.execution_id,
87
+ expected_revision: 0,
88
+ execution: updated
89
+ ).to_h
90
+ ).to eq(updated.to_h)
91
+ expect(execution_repository.load(execution.execution_id).execution_revision).to eq(1)
92
+ end
93
+
94
+ it "rejects a stale execution revision" do
95
+ execution = build_contract_execution(execution_agent_root)
96
+ execution_repository.create_active(execution)
97
+ updated = execution.with(status: :active, phase: :calling_llm)
98
+ execution_repository.save(
99
+ execution.execution_id,
100
+ expected_revision: 0,
101
+ execution: updated
102
+ )
103
+
104
+ expect do
105
+ execution_repository.save(
106
+ execution.execution_id,
107
+ expected_revision: 0,
108
+ execution: updated
109
+ )
110
+ end.to raise_error(Phronomy::Persistence::ConflictError)
111
+ end
112
+
113
+ it "rejects an execution identity mismatch" do
114
+ execution = build_contract_execution(execution_agent_root)
115
+ execution_repository.create_active(execution)
116
+ other_root = Phronomy::Agent::AgentRoot.create(
117
+ agent_id: "execution-agent-#{SecureRandom.uuid}",
118
+ agent_definition_id: "contract-agent",
119
+ definition_version: 1
120
+ )
121
+ persistence.agents.create(other_root)
122
+ other = build_contract_execution(other_root).with(
123
+ execution_revision: 1,
124
+ status: :active,
125
+ phase: :calling_llm
126
+ )
127
+
128
+ expect do
129
+ execution_repository.save(
130
+ execution.execution_id,
131
+ expected_revision: 0,
132
+ execution: other
133
+ )
134
+ end.to raise_error(Phronomy::Persistence::ConflictError)
135
+ end
136
+
137
+ it "requires execution revision to advance exactly once" do
138
+ execution = build_contract_execution(execution_agent_root)
139
+ execution_repository.create_active(execution)
140
+ skipped = execution.with(
141
+ execution_revision: 2,
142
+ status: :active,
143
+ phase: :calling_llm
144
+ )
145
+
146
+ expect do
147
+ execution_repository.save(
148
+ execution.execution_id,
149
+ expected_revision: 0,
150
+ execution: skipped
151
+ )
152
+ end.to raise_error(Phronomy::Persistence::ConflictError)
153
+ end
154
+
155
+ it "lists active executions for one Agent" do
156
+ execution = build_contract_execution(execution_agent_root)
157
+ execution_repository.create_active(execution)
158
+
159
+ expect(execution_repository.list_active(execution_agent_root.agent_id).map(&:execution_id))
160
+ .to eq([execution.execution_id])
161
+ end
162
+
163
+ it "asserts idle state and rejects active Agents" do
164
+ expect do
165
+ execution_repository.assert_idle!(execution_agent_root.agent_id)
166
+ end.not_to raise_error
167
+
168
+ execution_repository.create_active(build_contract_execution(execution_agent_root))
169
+
170
+ expect do
171
+ execution_repository.assert_idle!(execution_agent_root.agent_id)
172
+ end.to raise_error(Phronomy::AgentBusyError)
173
+ end
174
+
175
+ it "deletes one execution" do
176
+ execution = build_contract_execution(execution_agent_root)
177
+ execution_repository.create_active(execution)
178
+ execution_repository.delete(execution.execution_id)
179
+
180
+ expect do
181
+ execution_repository.load(execution.execution_id)
182
+ end.to raise_error(Phronomy::Persistence::NotFoundError)
183
+ end
184
+
185
+ it "deletes all executions for one Agent without deleting other Agents' executions" do
186
+ other_root = Phronomy::Agent::AgentRoot.create(
187
+ agent_id: "execution-agent-#{SecureRandom.uuid}",
188
+ agent_definition_id: "contract-agent",
189
+ definition_version: 1
190
+ )
191
+ persistence.agents.create(other_root)
192
+ own = build_contract_execution(execution_agent_root)
193
+ other = build_contract_execution(other_root)
194
+ execution_repository.create_active(own)
195
+ execution_repository.create_active(other)
196
+
197
+ execution_repository.delete_for_agent(execution_agent_root.agent_id)
198
+
199
+ expect(execution_repository.list_active(execution_agent_root.agent_id)).to be_empty
200
+ expect(execution_repository.load(other.execution_id).execution_id).to eq(other.execution_id)
201
+ end
202
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phronomy"
4
+
5
+ begin
6
+ require "rspec/core"
7
+ require "rspec/expectations"
8
+ rescue LoadError => error
9
+ raise LoadError,
10
+ "Phronomy Persistence contract support requires RSpec. " \
11
+ "Add `rspec` to the backend project's development/test dependencies before " \
12
+ "requiring `phronomy/testing/persistence_contract`.",
13
+ error.backtrace
14
+ end
15
+
16
+ module Phronomy
17
+ module Testing
18
+ # Explicitly loaded RSpec shared examples for Persistence backend authors.
19
+ #
20
+ # This namespace is intentionally excluded from Phronomy's production
21
+ # Zeitwerk eager-load path. Requiring this file is the opt-in boundary that
22
+ # loads RSpec and the backend conformance suite.
23
+ module PersistenceContract
24
+ SHARED_EXAMPLES = [
25
+ "a persistence content store",
26
+ "an Agent repository",
27
+ "a Journal repository",
28
+ "an Execution repository",
29
+ "a workflow state repository",
30
+ "a Persistence backend"
31
+ ].freeze
32
+ end
33
+ end
34
+ end
35
+
36
+ require_relative "persistence_contract/a_content_store"
37
+ require_relative "persistence_contract/an_agent_repository"
38
+ require_relative "persistence_contract/a_journal_repository"
39
+ require_relative "persistence_contract/an_execution_repository"
40
+ require_relative "persistence_contract/a_workflow_state_repository"
41
+ require_relative "persistence_contract/a_persistence_backend"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.19.0"
4
+ VERSION = "0.20.0"
5
5
  end
data/lib/phronomy.rb CHANGED
@@ -21,6 +21,12 @@ loader.inflector.inflect("before_llm_input" => "BeforeLLMInput")
21
21
  loader.collapse("#{__dir__}/phronomy/engine")
22
22
  # Loaded via require_relative before loader.setup; ignore to avoid Zeitwerk constant-name mismatch.
23
23
  loader.ignore("#{__dir__}/phronomy/ruby_llm_patches.rb")
24
+ # Persistence backend conformance tests are explicit test support. Keep them out
25
+ # of production eager-load so ordinary `require "phronomy"` never requires RSpec.
26
+ loader.ignore(
27
+ "#{__dir__}/phronomy/testing/persistence_contract.rb",
28
+ "#{__dir__}/phronomy/testing/persistence_contract"
29
+ )
24
30
  loader.setup
25
31
 
26
32
  require_relative "phronomy/version"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.19.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-15 00:00:00.000000000 Z
11
+ date: 2026-08-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -126,6 +126,7 @@ files:
126
126
  - docs/migrations/0.15.md
127
127
  - docs/migrations/0.16.md
128
128
  - docs/migrations/0.19.md
129
+ - docs/persistence-backends.md
129
130
  - docs/runtime-and-concurrency.md
130
131
  - examples/workflows/agent_event_mapping.rb
131
132
  - examples/workflows/generic_task_event_mapping.rb
@@ -250,6 +251,13 @@ files:
250
251
  - lib/phronomy/testing/eval/scorer/includes_scorer.rb
251
252
  - lib/phronomy/testing/eval/scorer/llm_judge.rb
252
253
  - lib/phronomy/testing/fake_clock.rb
254
+ - lib/phronomy/testing/persistence_contract.rb
255
+ - lib/phronomy/testing/persistence_contract/a_content_store.rb
256
+ - lib/phronomy/testing/persistence_contract/a_journal_repository.rb
257
+ - lib/phronomy/testing/persistence_contract/a_persistence_backend.rb
258
+ - lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb
259
+ - lib/phronomy/testing/persistence_contract/an_agent_repository.rb
260
+ - lib/phronomy/testing/persistence_contract/an_execution_repository.rb
253
261
  - lib/phronomy/token_usage.rb
254
262
  - lib/phronomy/tools/agent.rb
255
263
  - lib/phronomy/tools/mcp.rb
@@ -283,7 +291,6 @@ files:
283
291
  - scripts/add_to_h_unnamed_doubles.rb
284
292
  - scripts/api_snapshot.rb
285
293
  - scripts/check_api_annotations.rb
286
- - scripts/check_private_enforcement.rb
287
294
  - scripts/check_readme_ruby.rb
288
295
  - scripts/check_readme_runnable.rb
289
296
  - scripts/migrate_spec_agent_definition.rb
@@ -1,93 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- # check_private_enforcement.rb
5
- #
6
- # Verifies that every instance method annotated @api private in lib/ is
7
- # actually non-public at the Ruby level (i.e., NOT in Module#public_instance_methods).
8
- #
9
- # Class methods (def self.xxx) are excluded from this check because their
10
- # visibility is managed separately on the singleton class and rarely causes
11
- # accidental public exposure to consumers.
12
- #
13
- # Usage (run from the phronomy/ repository root):
14
- # bundle exec ruby scripts/check_private_enforcement.rb
15
- #
16
- # Exit codes:
17
- # 0 — all @api private instance methods are non-public (or have no Ruby def)
18
- # 1 — one or more @api private instance methods are exposed as public
19
-
20
- require "bundler/setup"
21
- require_relative "../lib/phronomy"
22
-
23
- lib_dir = File.expand_path("../lib", __dir__)
24
-
25
- unless File.directory?(lib_dir)
26
- warn "ERROR: lib directory not found at #{lib_dir}"
27
- exit 1
28
- end
29
-
30
- # Step 1: Collect instance methods annotated @api private via static analysis.
31
- api_private_entries = []
32
-
33
- Dir.glob(File.join(lib_dir, "**", "*.rb")).sort.each do |file|
34
- lines = File.readlines(file)
35
-
36
- lines.each_with_index do |line, i|
37
- next unless line.match?(/^\s*#\s*@api\s+private\s*$/)
38
-
39
- # Advance past any further comment or blank lines to reach the def.
40
- j = i + 1
41
- j += 1 while j < lines.size && lines[j].match?(/^\s*(#|$)/)
42
- next unless j < lines.size
43
-
44
- # Skip class-level methods — they live on the singleton class, not as
45
- # public instance methods accessible to consumers.
46
- next if lines[j].match?(/def\s+self\./)
47
-
48
- # Match both plain def and "private def".
49
- m = lines[j].match(/^\s*(?:private\s+)?def\s+(\w+[!?=]?)/)
50
- next unless m
51
-
52
- rel_path = file.sub("#{lib_dir}/../", "")
53
- api_private_entries << {name: m[1].to_sym, file: rel_path, line: j + 1}
54
- end
55
- end
56
-
57
- if api_private_entries.empty?
58
- puts "No @api private instance methods found."
59
- exit 0
60
- end
61
-
62
- # Step 2: Build a map of publicly exposed instance methods across all
63
- # Phronomy-namespaced modules/classes (own methods only, no inheritance).
64
- all_phronomy_modules = ObjectSpace.each_object(Module).select do |mod|
65
- mod.name&.start_with?("Phronomy")
66
- end
67
-
68
- public_exposure_map = {}
69
- all_phronomy_modules.each do |mod|
70
- mod.public_instance_methods(false).each do |meth|
71
- (public_exposure_map[meth] ||= []) << mod.name
72
- end
73
- end
74
-
75
- # Step 3: Report violations — @api private methods that are still public.
76
- errors = []
77
-
78
- api_private_entries.each do |entry|
79
- exposing_modules = public_exposure_map[entry[:name]]
80
- next unless exposing_modules
81
-
82
- errors << "#{entry[:file]}:#{entry[:line]} def #{entry[:name]}" \
83
- " (annotated @api private but public in: #{exposing_modules.join(", ")})"
84
- end
85
-
86
- if errors.empty?
87
- puts "OK: all #{api_private_entries.size} @api private instance methods are non-public."
88
- exit 0
89
- else
90
- warn "ERROR: #{errors.size} @api private instance method(s) are exposed as public:"
91
- errors.each { |e| warn " #{e}" }
92
- exit 1
93
- end