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.
- checksums.yaml +4 -4
- data/.mutant.yml +3 -4
- data/CHANGELOG.md +200 -10
- data/CONTRIBUTING.md +81 -9
- data/README.md +15 -6
- data/VERIFY.sh +587 -0
- data/benchmark/bench_agent_invoke.rb +2 -2
- data/benchmark/bench_context_assembler.rb +39 -68
- data/benchmark/bench_regression.rb +2 -2
- data/docs/architecture/agent-context.md +174 -0
- data/docs/architecture/before-llm-input.md +78 -0
- data/docs/architecture/context-management.md +232 -0
- data/docs/architecture/knowledge-and-rag.md +130 -0
- data/docs/architecture/multi-agent-handoff.md +152 -0
- data/docs/architecture/persistence.md +175 -0
- data/docs/architecture/removed/agent-context.md +72 -0
- data/docs/architecture/security-boundaries.md +173 -0
- data/docs/architecture/tracing.md +194 -0
- data/docs/architecture.md +82 -0
- data/docs/archive/design/archived/04_api_design.md +507 -0
- data/docs/archive/design/archived/09_guardrails.md +186 -0
- data/docs/archive/design/archived/17_rails_integration.md +175 -0
- data/docs/archive/design/historical/00_design_philosophy.md +122 -0
- data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
- data/docs/archive/design/historical/06_design_decisions.md +143 -0
- data/docs/changelog/0.14-and-earlier.md +1 -1
- data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
- data/docs/decisions/002-workflow-context-immutability.md +26 -1
- data/docs/decisions/006-no-built-in-guardrails.md +2 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
- data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
- data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
- data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
- data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
- data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
- data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
- data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
- data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
- data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
- data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
- data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
- data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
- data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
- data/docs/decisions/README.md +172 -0
- data/docs/features.md +31 -11
- data/docs/getting-started.md +77 -45
- data/docs/migrations/0.19.md +14 -7
- data/docs/migrations/0.22.md +390 -0
- data/docs/persistence-backends.md +88 -38
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +19 -15
- data/lib/phronomy/agent/agent_invocation.rb +288 -93
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
- data/lib/phronomy/agent/agent_root.rb +3 -3
- data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
- data/lib/phronomy/agent/async_event_api.rb +145 -72
- data/lib/phronomy/agent/base.rb +388 -181
- data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
- data/lib/phronomy/agent/context_assembler.rb +437 -178
- data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
- data/lib/phronomy/agent/context_plan.rb +18 -13
- data/lib/phronomy/agent/context_plan_validator.rb +246 -88
- data/lib/phronomy/agent/context_policies/default.rb +123 -34
- data/lib/phronomy/agent/context_policy.rb +109 -3
- data/lib/phronomy/agent/context_policy_input.rb +244 -0
- data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
- data/lib/phronomy/agent/journal_record.rb +17 -4
- data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
- data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
- data/lib/phronomy/agent/llm_operation_result.rb +12 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
- data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
- data/lib/phronomy/agent/recovery_support.rb +512 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
- data/lib/phronomy/agent/selection/candidate.rb +53 -0
- data/lib/phronomy/agent/selection/constraint.rb +49 -0
- data/lib/phronomy/agent/shared_state.rb +38 -1
- data/lib/phronomy/agent/tool_approval_request.rb +33 -5
- data/lib/phronomy/agent/tool_definition_set.rb +49 -3
- data/lib/phronomy/agent/tool_invocation.rb +336 -102
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
- data/lib/phronomy/agent.rb +20 -2
- data/lib/phronomy/agent_already_exists_error.rb +5 -0
- data/lib/phronomy/agent_purged_error.rb +5 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
- data/lib/phronomy/engine/event_loop.rb +622 -63
- data/lib/phronomy/engine/fsm_session.rb +194 -21
- data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
- data/lib/phronomy/engine/runtime.rb +77 -20
- data/lib/phronomy/generator_verifier.rb +12 -14
- data/lib/phronomy/invocation_context.rb +9 -29
- data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
- data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
- data/lib/phronomy/multi_agent/coordinator.rb +154 -0
- data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
- data/lib/phronomy/multi_agent/handoff.rb +24 -45
- data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
- data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
- data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
- data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
- data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
- data/lib/phronomy/multi_agent/runner.rb +98 -0
- data/lib/phronomy/persistence/durable_codec.rb +646 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +210 -134
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +316 -0
- data/lib/phronomy/persistence.rb +81 -41
- data/lib/phronomy/recovery.rb +186 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
- data/lib/phronomy/tracing/automatic.rb +176 -0
- data/lib/phronomy/tracing/base.rb +11 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +3 -6
- data/lib/phronomy/workflow_context.rb +14 -5
- data/lib/phronomy/workflow_recovery.rb +123 -0
- data/lib/phronomy/workflow_runner.rb +468 -256
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/sig/phronomy/agent.rbs +209 -7
- data/sig/phronomy/multi_agent.rbs +39 -0
- data/sig/phronomy/persistence.rbs +62 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +65 -17
- data/examples/workflows/agent_event_mapping.rb +0 -101
- data/examples/workflows/generic_task_event_mapping.rb +0 -66
- data/lib/phronomy/agent/activation_registry.rb +0 -28
- data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
- data/lib/phronomy/agent/context_candidate.rb +0 -47
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
- data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
- data/lib/phronomy/agent/context_policy_registry.rb +0 -46
- data/lib/phronomy/agent/context_request.rb +0 -35
- data/lib/phronomy/agent/context_selection_unit.rb +0 -38
- data/lib/phronomy/agent/derived_content_spec.rb +0 -34
- 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
|