phronomy 0.22.0 → 0.23.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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +200 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +152 -0
  15. data/docs/architecture/persistence.md +175 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +82 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/README.md +172 -0
  45. data/docs/features.md +31 -11
  46. data/docs/getting-started.md +77 -45
  47. data/docs/migrations/0.19.md +14 -7
  48. data/docs/migrations/0.22.md +390 -0
  49. data/docs/persistence-backends.md +88 -38
  50. data/docs/runtime-and-concurrency.md +227 -33
  51. data/examples/README.md +13 -0
  52. data/lib/phronomy/agent/agent_execution.rb +19 -15
  53. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  54. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  55. data/lib/phronomy/agent/agent_root.rb +3 -3
  56. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  57. data/lib/phronomy/agent/async_event_api.rb +145 -72
  58. data/lib/phronomy/agent/base.rb +388 -181
  59. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  60. data/lib/phronomy/agent/context_assembler.rb +437 -178
  61. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  62. data/lib/phronomy/agent/context_plan.rb +18 -13
  63. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  64. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  65. data/lib/phronomy/agent/context_policy.rb +109 -3
  66. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  67. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  68. data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
  69. data/lib/phronomy/agent/journal_record.rb +17 -4
  70. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  71. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  72. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  73. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  74. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  75. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
  76. data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
  77. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
  78. data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
  79. data/lib/phronomy/agent/recovery_support.rb +512 -0
  80. data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
  81. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  82. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  83. data/lib/phronomy/agent/shared_state.rb +38 -1
  84. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  85. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  86. data/lib/phronomy/agent/tool_invocation.rb +336 -102
  87. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  88. data/lib/phronomy/agent.rb +20 -2
  89. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  90. data/lib/phronomy/agent_purged_error.rb +5 -0
  91. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  92. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  93. data/lib/phronomy/engine/event_loop.rb +622 -63
  94. data/lib/phronomy/engine/fsm_session.rb +194 -21
  95. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  96. data/lib/phronomy/engine/runtime.rb +77 -20
  97. data/lib/phronomy/generator_verifier.rb +12 -14
  98. data/lib/phronomy/invocation_context.rb +9 -29
  99. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  100. data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
  101. data/lib/phronomy/multi_agent/coordinator.rb +154 -0
  102. data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
  103. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  104. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  105. data/lib/phronomy/multi_agent/handoff.rb +24 -45
  106. data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
  107. data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
  108. data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
  109. data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
  110. data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
  111. data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
  112. data/lib/phronomy/multi_agent/runner.rb +98 -0
  113. data/lib/phronomy/persistence/durable_codec.rb +646 -0
  114. data/lib/phronomy/persistence/durable_record.rb +117 -0
  115. data/lib/phronomy/persistence/in_memory.rb +210 -134
  116. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  117. data/lib/phronomy/persistence/repository_facades.rb +316 -0
  118. data/lib/phronomy/persistence.rb +81 -41
  119. data/lib/phronomy/recovery.rb +186 -0
  120. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  121. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
  122. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  123. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  124. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  125. data/lib/phronomy/tracing/automatic.rb +176 -0
  126. data/lib/phronomy/tracing/base.rb +11 -2
  127. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  128. data/lib/phronomy/version.rb +1 -1
  129. data/lib/phronomy/workflow.rb +3 -6
  130. data/lib/phronomy/workflow_context.rb +14 -5
  131. data/lib/phronomy/workflow_recovery.rb +123 -0
  132. data/lib/phronomy/workflow_runner.rb +468 -256
  133. data/lib/phronomy.rb +6 -0
  134. data/scripts/api_snapshot.rb +12 -0
  135. data/sig/phronomy/agent.rbs +209 -7
  136. data/sig/phronomy/multi_agent.rbs +39 -0
  137. data/sig/phronomy/persistence.rbs +62 -4
  138. data/sig/phronomy/runtime.rbs +1 -4
  139. data/sig/phronomy/workflow.rbs +2 -2
  140. data/sig/phronomy.rbs +10 -0
  141. metadata +65 -17
  142. data/examples/workflows/agent_event_mapping.rb +0 -101
  143. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  144. data/lib/phronomy/agent/activation_registry.rb +0 -28
  145. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  146. data/lib/phronomy/agent/context_candidate.rb +0 -47
  147. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  148. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  149. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  150. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  151. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  152. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  153. data/lib/phronomy/agent/context_request.rb +0 -35
  154. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  155. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  156. data/lib/phronomy/agent/runner.rb +0 -97
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Persistence
5
+ module Migration
6
+ # Explicit logical conversion from the pre-S3 unversioned representations
7
+ # present at the ACS-06 fixed base to the initial 0.1 durable formats.
8
+ #
9
+ # This module does not scan a database or write records. A backend-specific
10
+ # migration runner is responsible for physical enumeration, transactions,
11
+ # and replacement. Normal Runtime load never calls these methods.
12
+ #
13
+ # @api public
14
+ module InitialFormatMigration
15
+ PRE_S3_AGENT_ROOT_KEYS = Phronomy::Agent::AgentRoot::ATTRIBUTES.map(&:to_s).freeze
16
+ PRE_S3_AGENT_EXECUTION_KEYS = Phronomy::Agent::AgentExecution::ATTRIBUTES.map(&:to_s).freeze
17
+ PRE_S3_JOURNAL_KEYS = Phronomy::Agent::JournalRecord::ATTRIBUTES.map(&:to_s).freeze
18
+ PRE_S3_LLM_CALL_KEYS = Phronomy::Agent::LLMCallRecord::ATTRIBUTES.map(&:to_s).freeze
19
+ PRE_S3_APPROVAL_REQUIRED_KEYS = %w[id items created_at].freeze
20
+ PRE_S3_APPROVAL_PARENT_KEYS = %w[execution_id agent_invocation_id].freeze
21
+ PRE_S3_APPROVAL_OPTIONAL_KEYS = %w[approved].freeze
22
+ PRE_S3_APPROVAL_ITEM_KEYS = %w[
23
+ tool_invocation_id tool_call_id tool_name arguments facts reason origin metadata
24
+ ].freeze
25
+
26
+ module_function
27
+
28
+ def agent_root(hash)
29
+ source = stringify_keys(hash)
30
+ validate_exact_keys!(
31
+ source,
32
+ PRE_S3_AGENT_ROOT_KEYS,
33
+ label: "pre-S3 AgentRoot"
34
+ )
35
+ DurableCodec.encode_agent_root(
36
+ Phronomy::Agent::AgentRoot.from_h(source)
37
+ )
38
+ rescue Phronomy::Persistence::SerializationError
39
+ raise
40
+ rescue => error
41
+ migration_error("AgentRoot", error)
42
+ end
43
+
44
+ def agent_execution(hash)
45
+ source = stringify_keys(hash)
46
+ validate_exact_keys!(
47
+ source,
48
+ PRE_S3_AGENT_EXECUTION_KEYS,
49
+ label: "pre-S3 AgentExecution"
50
+ )
51
+ source["working_records"] = Array(source.fetch("working_records")).map.with_index do |record, index|
52
+ normalize_legacy_journal_record(
53
+ record,
54
+ label: "pre-S3 AgentExecution working_records[#{index}]"
55
+ )
56
+ end
57
+ source["llm_calls"] = Array(source.fetch("llm_calls")).map.with_index do |call, index|
58
+ normalized = stringify_keys(call)
59
+ validate_exact_keys!(
60
+ normalized,
61
+ PRE_S3_LLM_CALL_KEYS,
62
+ label: "pre-S3 AgentExecution llm_calls[#{index}]"
63
+ )
64
+ normalized
65
+ end
66
+ source["approval_request"] = normalize_legacy_approval_request(
67
+ source["approval_request"],
68
+ execution_id: source.fetch("execution_id")
69
+ )
70
+ execution = Phronomy::Agent::AgentExecution.from_h(source)
71
+ DurableCodec.encode_agent_execution(execution)
72
+ rescue Phronomy::Persistence::SerializationError
73
+ raise
74
+ rescue => error
75
+ migration_error("AgentExecution", error)
76
+ end
77
+
78
+ def journal_record(hash)
79
+ source = normalize_legacy_journal_record(hash, label: "pre-S3 JournalRecord")
80
+ DurableCodec.encode_journal_record(
81
+ Phronomy::Agent::JournalRecord.from_h(source)
82
+ )
83
+ rescue Phronomy::Persistence::SerializationError
84
+ raise
85
+ rescue => error
86
+ migration_error("JournalRecord", error)
87
+ end
88
+
89
+ def workflow_state(workflow_instance_id:, revision:, snapshot:)
90
+ DurableCodec.encode_workflow_state(
91
+ workflow_instance_id: workflow_instance_id,
92
+ workflow_revision: Integer(revision),
93
+ snapshot: snapshot
94
+ )
95
+ rescue Phronomy::Persistence::SerializationError
96
+ raise
97
+ rescue => error
98
+ migration_error("Workflow state", error)
99
+ end
100
+
101
+ # LLMInputManifest is a ContentStore codec boundary, not a
102
+ # Persistence::DurableRecord. Its pre-CG-07 integer version 1 is migrated
103
+ # explicitly to the pre-1.0 string version "0.1".
104
+ def llm_input_manifest(hash)
105
+ source = stringify_keys(hash)
106
+ old_version = source.fetch("version")
107
+ unless old_version == 1 || old_version == "1"
108
+ raise Phronomy::Persistence::SerializationError,
109
+ "unsupported pre-S3 LLMInputManifest version: #{old_version.inspect}"
110
+ end
111
+ source["version"] = Phronomy::Agent::LLMInputManifest::VERSION
112
+ Phronomy::Agent::LLMInputManifest.from_h(source).to_h
113
+ rescue Phronomy::Persistence::SerializationError
114
+ raise
115
+ rescue => error
116
+ migration_error("LLMInputManifest", error)
117
+ end
118
+
119
+ def normalize_legacy_journal_record(hash, label:)
120
+ source = stringify_keys(hash)
121
+ validate_allowed_keys!(
122
+ source,
123
+ required_keys: PRE_S3_JOURNAL_KEYS,
124
+ optional_keys: ["correlation_id"],
125
+ label: label
126
+ )
127
+ source.delete("correlation_id")
128
+ source
129
+ end
130
+ private_class_method :normalize_legacy_journal_record
131
+
132
+ def normalize_legacy_approval_request(request, execution_id:)
133
+ return nil unless request
134
+
135
+ source = stringify_keys(request)
136
+ validate_allowed_keys!(
137
+ source,
138
+ required_keys: PRE_S3_APPROVAL_REQUIRED_KEYS,
139
+ optional_keys: PRE_S3_APPROVAL_PARENT_KEYS + PRE_S3_APPROVAL_OPTIONAL_KEYS,
140
+ label: "pre-S3 approval_request"
141
+ )
142
+ parents = PRE_S3_APPROVAL_PARENT_KEYS.select { |key| source.key?(key) }
143
+ unless parents.length == 1
144
+ raise Phronomy::Persistence::SerializationError,
145
+ "pre-S3 approval_request must contain exactly one parent identity, got #{parents.inspect}"
146
+ end
147
+
148
+ items = source.fetch("items")
149
+ unless items.is_a?(Array) && !items.empty?
150
+ raise Phronomy::Persistence::SerializationError,
151
+ "pre-S3 approval_request items must be a non-empty Array"
152
+ end
153
+ source["items"] = items.map.with_index do |item, index|
154
+ normalized = stringify_keys(item)
155
+ validate_exact_keys!(
156
+ normalized,
157
+ PRE_S3_APPROVAL_ITEM_KEYS,
158
+ label: "pre-S3 approval_request items[#{index}]"
159
+ )
160
+ normalized
161
+ end
162
+
163
+ if source.key?("agent_invocation_id")
164
+ source.delete("agent_invocation_id")
165
+ source["execution_id"] = execution_id.to_s
166
+ elsif source.fetch("execution_id").to_s != execution_id.to_s
167
+ raise Phronomy::Persistence::SerializationError,
168
+ "pre-S3 approval_request execution_id does not match AgentExecution"
169
+ end
170
+ source
171
+ end
172
+ private_class_method :normalize_legacy_approval_request
173
+
174
+ def validate_allowed_keys!(hash, required_keys:, optional_keys:, label:)
175
+ actual = hash.keys.sort
176
+ missing = required_keys.sort - actual
177
+ unknown = actual - (required_keys + optional_keys).sort
178
+ return hash if missing.empty? && unknown.empty?
179
+
180
+ details = []
181
+ details << "missing=#{missing.inspect}" unless missing.empty?
182
+ details << "unknown=#{unknown.inspect}" unless unknown.empty?
183
+ raise Phronomy::Persistence::SerializationError,
184
+ "#{label} schema mismatch (#{details.join(", ")})"
185
+ end
186
+ private_class_method :validate_allowed_keys!
187
+
188
+ def validate_exact_keys!(hash, expected_keys, label:)
189
+ validate_allowed_keys!(
190
+ hash,
191
+ required_keys: expected_keys,
192
+ optional_keys: [],
193
+ label: label
194
+ )
195
+ end
196
+ private_class_method :validate_exact_keys!
197
+
198
+ def stringify_keys(hash)
199
+ unless hash.is_a?(Hash)
200
+ raise Phronomy::Persistence::SerializationError,
201
+ "migration input must be a Hash"
202
+ end
203
+ hash.each_with_object({}) do |(key, value), result|
204
+ unless key.is_a?(String) || key.is_a?(Symbol)
205
+ raise Phronomy::Persistence::SerializationError,
206
+ "migration key must be String or Symbol, got #{key.class}"
207
+ end
208
+ string_key = key.to_s
209
+ if result.key?(string_key)
210
+ raise Phronomy::Persistence::SerializationError,
211
+ "duplicate migration key after normalization: #{string_key.inspect}"
212
+ end
213
+ result[string_key] = value
214
+ end
215
+ end
216
+ private_class_method :stringify_keys
217
+
218
+ def migration_error(label, error)
219
+ raise Phronomy::Persistence::SerializationError,
220
+ "cannot migrate pre-S3 #{label}: #{error.class}: #{error.message}"
221
+ end
222
+ private_class_method :migration_error
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,316 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Persistence
5
+ # Runtime/domain-facing repositories layered over record-oriented Backend SPI
6
+ # repositories. Backend implementations never receive AgentRoot,
7
+ # AgentExecution, JournalRecord, or Workflow snapshot domain values directly.
8
+ #
9
+ # Backend repositories receive DurableRecord plus explicit identity/revision/
10
+ # admission metadata needed for indexing and compare-and-swap. They must not
11
+ # inspect DurableRecord#payload to rediscover those semantics.
12
+ #
13
+ # @api private
14
+ module RepositoryFacades
15
+ class Agents
16
+ def initialize(backend_repository)
17
+ @backend_repository = backend_repository
18
+ end
19
+
20
+ def create(root)
21
+ record = DurableCodec.encode_agent_root(root)
22
+ stored = @backend_repository.create(
23
+ agent_id: root.agent_id.to_s,
24
+ agent_revision: Integer(root.agent_revision),
25
+ record: record
26
+ )
27
+ decode_for_agent(stored, root.agent_id, revision: root.agent_revision)
28
+ end
29
+
30
+ def load(agent_id)
31
+ decode_for_agent(@backend_repository.load(agent_id.to_s), agent_id)
32
+ end
33
+
34
+ def save(agent_id, expected_revision:, root:)
35
+ expected = Integer(expected_revision)
36
+ next_revision = Integer(root.agent_revision)
37
+ unless next_revision == expected + 1
38
+ raise Phronomy::Persistence::ConflictError,
39
+ "agent save must advance revision exactly once: " \
40
+ "expected #{expected + 1}, got #{next_revision}"
41
+ end
42
+ unless root.agent_id.to_s == agent_id.to_s
43
+ raise Phronomy::Persistence::SerializationError,
44
+ "Agent root identity mismatch: #{root.agent_id} != #{agent_id}"
45
+ end
46
+
47
+ record = DurableCodec.encode_agent_root(root)
48
+ stored = @backend_repository.save(
49
+ agent_id.to_s,
50
+ expected_revision: expected,
51
+ next_revision: next_revision,
52
+ record: record
53
+ )
54
+ decode_for_agent(stored, agent_id, revision: next_revision)
55
+ end
56
+
57
+ def delete(agent_id)
58
+ @backend_repository.delete(agent_id.to_s)
59
+ end
60
+
61
+ private
62
+
63
+ def decode_for_agent(record, agent_id, revision: nil)
64
+ root = DurableCodec.decode_agent_root(record)
65
+ unless root.agent_id == agent_id.to_s
66
+ raise Phronomy::Persistence::SerializationError,
67
+ "backend returned Agent root for #{root.agent_id.inspect}; expected #{agent_id.to_s.inspect}"
68
+ end
69
+ if revision && root.agent_revision != revision
70
+ raise Phronomy::Persistence::SerializationError,
71
+ "backend returned Agent revision #{root.agent_revision}; expected #{revision}"
72
+ end
73
+ root
74
+ end
75
+ end
76
+
77
+ class Journals
78
+ def initialize(backend_repository)
79
+ @backend_repository = backend_repository
80
+ end
81
+
82
+ def append(agent_id, expected_position:, records:)
83
+ expected = Integer(expected_position)
84
+ sequenced = Array(records).each_with_index.map do |record, index|
85
+ unless record.agent_id.to_s == agent_id.to_s
86
+ raise Phronomy::Persistence::SerializationError,
87
+ "Journal record Agent mismatch: #{record.agent_id} != #{agent_id}"
88
+ end
89
+ record.with_sequence(expected + index + 1)
90
+ end
91
+ encoded = sequenced.map { |record| DurableCodec.encode_journal_record(record) }
92
+ stored = @backend_repository.append(
93
+ agent_id.to_s,
94
+ expected_position: expected,
95
+ records: encoded,
96
+ record_ids: sequenced.map { |record| record.record_id.to_s }.freeze
97
+ )
98
+ decoded = Array(stored).map { |record| DurableCodec.decode_journal_record(record) }
99
+ validate_read!(decoded, agent_id, start_sequence: expected + 1)
100
+ end
101
+
102
+ def read(agent_id, after: nil, limit: nil)
103
+ after_value = after.nil? ? nil : Integer(after)
104
+ limit_value = limit.nil? ? nil : Integer(limit)
105
+ stored = @backend_repository.read(
106
+ agent_id.to_s,
107
+ after: after_value,
108
+ limit: limit_value
109
+ )
110
+ decoded = Array(stored).map { |record| DurableCodec.decode_journal_record(record) }
111
+ validate_read!(decoded, agent_id, start_sequence: (after_value || 0) + 1)
112
+ end
113
+
114
+ def head(agent_id)
115
+ Integer(@backend_repository.head(agent_id.to_s))
116
+ end
117
+
118
+ def delete(agent_id)
119
+ @backend_repository.delete(agent_id.to_s)
120
+ end
121
+
122
+ private
123
+
124
+ def validate_read!(records, agent_id, start_sequence:)
125
+ records.each_with_index do |record, index|
126
+ unless record.agent_id == agent_id.to_s
127
+ raise Phronomy::Persistence::SerializationError,
128
+ "backend returned Journal record for #{record.agent_id.inspect}; expected #{agent_id.to_s.inspect}"
129
+ end
130
+ expected_sequence = start_sequence + index
131
+ unless record.sequence == expected_sequence
132
+ raise Phronomy::Persistence::SerializationError,
133
+ "backend returned Journal sequence #{record.sequence.inspect}; expected #{expected_sequence}"
134
+ end
135
+ end
136
+ records.freeze
137
+ end
138
+ end
139
+
140
+ class Executions
141
+ def initialize(backend_repository)
142
+ @backend_repository = backend_repository
143
+ end
144
+
145
+ def create_active(execution)
146
+ unless execution.active?
147
+ raise Phronomy::Persistence::SerializationError,
148
+ "create_active requires an active AgentExecution"
149
+ end
150
+ record = DurableCodec.encode_agent_execution(execution)
151
+ stored = @backend_repository.create_active(
152
+ execution_id: execution.execution_id.to_s,
153
+ agent_id: execution.agent_id.to_s,
154
+ execution_revision: Integer(execution.execution_revision),
155
+ record: record
156
+ )
157
+ decode_for_execution(
158
+ stored,
159
+ execution.execution_id,
160
+ agent_id: execution.agent_id,
161
+ revision: execution.execution_revision,
162
+ active: true
163
+ )
164
+ end
165
+
166
+ def load(execution_id)
167
+ decode_for_execution(@backend_repository.load(execution_id.to_s), execution_id)
168
+ end
169
+
170
+ def save(execution_id, expected_revision:, execution:)
171
+ expected = Integer(expected_revision)
172
+ next_revision = Integer(execution.execution_revision)
173
+ unless next_revision == expected + 1
174
+ raise Phronomy::Persistence::ConflictError,
175
+ "execution save must advance revision exactly once: " \
176
+ "expected #{expected + 1}, got #{next_revision}"
177
+ end
178
+ unless execution.execution_id.to_s == execution_id.to_s
179
+ raise Phronomy::Persistence::SerializationError,
180
+ "Execution identity mismatch: #{execution.execution_id} != #{execution_id}"
181
+ end
182
+
183
+ record = DurableCodec.encode_agent_execution(execution)
184
+ stored = @backend_repository.save(
185
+ execution_id.to_s,
186
+ expected_revision: expected,
187
+ next_revision: next_revision,
188
+ agent_id: execution.agent_id.to_s,
189
+ active: execution.active?,
190
+ record: record
191
+ )
192
+ decode_for_execution(
193
+ stored,
194
+ execution_id,
195
+ agent_id: execution.agent_id,
196
+ revision: next_revision,
197
+ active: execution.active?
198
+ )
199
+ end
200
+
201
+ def list_active(agent_id)
202
+ Array(@backend_repository.list_active(agent_id.to_s)).map do |record|
203
+ decode_for_execution(record, nil, agent_id: agent_id, active: true)
204
+ end.freeze
205
+ end
206
+
207
+ def delete(execution_id)
208
+ @backend_repository.delete(execution_id.to_s)
209
+ end
210
+
211
+ def delete_for_agent(agent_id)
212
+ @backend_repository.delete_for_agent(agent_id.to_s)
213
+ end
214
+
215
+ def assert_idle!(agent_id)
216
+ @backend_repository.assert_idle!(agent_id.to_s)
217
+ end
218
+
219
+ private
220
+
221
+ def decode_for_execution(record, execution_id, agent_id: nil, revision: nil, active: nil)
222
+ execution = DurableCodec.decode_agent_execution(record)
223
+ if execution_id && execution.execution_id != execution_id.to_s
224
+ raise Phronomy::Persistence::SerializationError,
225
+ "backend returned Execution #{execution.execution_id.inspect}; expected #{execution_id.to_s.inspect}"
226
+ end
227
+ if agent_id && execution.agent_id != agent_id.to_s
228
+ raise Phronomy::Persistence::SerializationError,
229
+ "backend returned Execution for Agent #{execution.agent_id.inspect}; expected #{agent_id.to_s.inspect}"
230
+ end
231
+ if revision && execution.execution_revision != revision
232
+ raise Phronomy::Persistence::SerializationError,
233
+ "backend returned Execution revision #{execution.execution_revision}; expected #{revision}"
234
+ end
235
+ if !active.nil? && execution.active? != active
236
+ raise Phronomy::Persistence::SerializationError,
237
+ "backend returned Execution active=#{execution.active?}; expected #{active}"
238
+ end
239
+ execution
240
+ end
241
+ end
242
+
243
+ class WorkflowStates
244
+ def initialize(backend_repository)
245
+ @backend_repository = backend_repository
246
+ end
247
+
248
+ def load(workflow_instance_id)
249
+ record = @backend_repository.load(workflow_instance_id.to_s)
250
+ return nil unless record
251
+
252
+ DurableCodec.decode_workflow_state(
253
+ record,
254
+ expected_workflow_instance_id: workflow_instance_id
255
+ )
256
+ end
257
+
258
+ def save(workflow_instance_id, expected_revision:, snapshot:)
259
+ expected = expected_revision.nil? ? nil : Integer(expected_revision)
260
+ next_revision = expected.nil? ? 1 : expected + 1
261
+ record = DurableCodec.encode_workflow_state(
262
+ workflow_instance_id: workflow_instance_id,
263
+ workflow_revision: next_revision,
264
+ snapshot: snapshot
265
+ )
266
+ stored = @backend_repository.save(
267
+ workflow_instance_id.to_s,
268
+ expected_revision: expected,
269
+ next_revision: next_revision,
270
+ record: record
271
+ )
272
+ decoded = DurableCodec.decode_workflow_state(
273
+ stored,
274
+ expected_workflow_instance_id: workflow_instance_id
275
+ )
276
+ unless decoded.fetch(:revision) == next_revision
277
+ raise Phronomy::Persistence::SerializationError,
278
+ "backend returned Workflow revision #{decoded.fetch(:revision)}; expected #{next_revision}"
279
+ end
280
+ decoded.fetch(:revision)
281
+ end
282
+
283
+ def delete(workflow_instance_id, expected_revision:)
284
+ @backend_repository.delete(
285
+ workflow_instance_id.to_s,
286
+ expected_revision: Integer(expected_revision)
287
+ )
288
+ end
289
+ end
290
+
291
+ # Transaction-scoped domain-facing Persistence view built from raw backend
292
+ # repositories. Backend implementations can use this through
293
+ # Persistence#build_transaction_view without duplicating facade logic.
294
+ class View
295
+ attr_reader :contents, :agents, :journals, :executions, :workflow_states
296
+
297
+ def initialize(contents:, agents:, journals:, executions:, workflow_states:, watermark:)
298
+ @contents = contents
299
+ @agents = Agents.new(agents)
300
+ @journals = Journals.new(journals)
301
+ @executions = Executions.new(executions)
302
+ @workflow_states = WorkflowStates.new(workflow_states)
303
+ @watermark = watermark
304
+ end
305
+
306
+ def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
307
+ @watermark.assert_agent_watermark!(
308
+ agent_id: agent_id.to_s,
309
+ agent_revision: Integer(agent_revision),
310
+ journal_position: Integer(journal_position)
311
+ )
312
+ end
313
+ end
314
+ end
315
+ end
316
+ end