phronomy 0.22.0 → 0.24.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 +215 -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 +147 -0
- data/docs/architecture/persistence.md +186 -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 +89 -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 +114 -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/028-preparing-recovery-replay-contract.md +106 -0
- data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
- data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
- data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
- data/docs/decisions/README.md +176 -0
- data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
- data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
- data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
- data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
- data/docs/features.md +65 -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/migrations/durable-semantic-coordination-v2.md +65 -0
- data/docs/persistence-backends.md +129 -40
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +21 -17
- 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 +162 -72
- data/lib/phronomy/agent/base.rb +416 -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/exact_execution.rb +153 -0
- data/lib/phronomy/agent/execution_cancellation.rb +25 -0
- data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
- data/lib/phronomy/agent/handoff.rb +43 -0
- data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
- data/lib/phronomy/agent/handoff_context.rb +120 -0
- data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
- data/lib/phronomy/agent/handoff_policy.rb +143 -0
- data/lib/phronomy/agent/handoff_projection.rb +207 -0
- data/lib/phronomy/agent/handoff_request.rb +45 -0
- data/lib/phronomy/agent/handoff_runner.rb +178 -0
- data/lib/phronomy/agent/handoff_state.rb +43 -0
- 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 +174 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
- data/lib/phronomy/agent/recovery_support.rb +504 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -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 +339 -103
- 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/team_ownership_registry.rb +77 -0
- data/lib/phronomy/engine/runtime.rb +92 -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/durable_subagent_coordinator.rb +134 -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/orchestrator.rb +71 -26
- data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
- data/lib/phronomy/multi_agent/team_execution.rb +44 -0
- data/lib/phronomy/multi_agent/team_root.rb +41 -0
- data/lib/phronomy/persistence/durable_codec.rb +706 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +471 -133
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +535 -0
- data/lib/phronomy/persistence.rb +174 -40
- 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 +2 -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/testing/persistence_contract/coordination_repositories.rb +137 -0
- data/lib/phronomy/testing/persistence_contract.rb +5 -0
- data/lib/phronomy/tools/agent.rb +1 -1
- 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/handoff.rbs +41 -0
- data/sig/phronomy/multi_agent.rbs +35 -0
- data/sig/phronomy/persistence.rbs +123 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +84 -18
- 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
- data/lib/phronomy/multi_agent/handoff.rb +0 -64
|
@@ -0,0 +1,535 @@
|
|
|
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 list(agent_id, after: nil, limit: 100)
|
|
208
|
+
raise ArgumentError, "limit must be a positive Integer" unless limit.is_a?(Integer) && limit.positive?
|
|
209
|
+
Array(@backend_repository.list(agent_id.to_s, after: after&.to_s, limit: limit)).map do |record|
|
|
210
|
+
decode_for_execution(record, nil, agent_id: agent_id)
|
|
211
|
+
end.freeze
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def delete(execution_id)
|
|
215
|
+
@backend_repository.delete(execution_id.to_s)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def delete_for_agent(agent_id)
|
|
219
|
+
@backend_repository.delete_for_agent(agent_id.to_s)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def assert_idle!(agent_id)
|
|
223
|
+
@backend_repository.assert_idle!(agent_id.to_s)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def decode_for_execution(record, execution_id, agent_id: nil, revision: nil, active: nil)
|
|
229
|
+
execution = DurableCodec.decode_agent_execution(record)
|
|
230
|
+
if execution_id && execution.execution_id != execution_id.to_s
|
|
231
|
+
raise Phronomy::Persistence::SerializationError,
|
|
232
|
+
"backend returned Execution #{execution.execution_id.inspect}; expected #{execution_id.to_s.inspect}"
|
|
233
|
+
end
|
|
234
|
+
if agent_id && execution.agent_id != agent_id.to_s
|
|
235
|
+
raise Phronomy::Persistence::SerializationError,
|
|
236
|
+
"backend returned Execution for Agent #{execution.agent_id.inspect}; expected #{agent_id.to_s.inspect}"
|
|
237
|
+
end
|
|
238
|
+
if revision && execution.execution_revision != revision
|
|
239
|
+
raise Phronomy::Persistence::SerializationError,
|
|
240
|
+
"backend returned Execution revision #{execution.execution_revision}; expected #{revision}"
|
|
241
|
+
end
|
|
242
|
+
if !active.nil? && execution.active? != active
|
|
243
|
+
raise Phronomy::Persistence::SerializationError,
|
|
244
|
+
"backend returned Execution active=#{execution.active?}; expected #{active}"
|
|
245
|
+
end
|
|
246
|
+
execution
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
class Teams
|
|
251
|
+
def initialize(backend_repository)
|
|
252
|
+
@backend_repository = backend_repository
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def create(root)
|
|
256
|
+
record = DurableCodec.encode_team_root(root)
|
|
257
|
+
stored = @backend_repository.create(
|
|
258
|
+
team_id: root.team_id.to_s,
|
|
259
|
+
team_revision: Integer(root.team_revision),
|
|
260
|
+
record: record
|
|
261
|
+
)
|
|
262
|
+
decode_for_team(stored, root.team_id, revision: root.team_revision)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def load(team_id)
|
|
266
|
+
decode_for_team(@backend_repository.load(team_id.to_s), team_id)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def save(team_id, expected_revision:, root:)
|
|
270
|
+
expected = Integer(expected_revision)
|
|
271
|
+
next_revision = Integer(root.team_revision)
|
|
272
|
+
unless next_revision == expected + 1
|
|
273
|
+
raise Phronomy::Persistence::ConflictError,
|
|
274
|
+
"agent save must advance revision exactly once: " \
|
|
275
|
+
"expected #{expected + 1}, got #{next_revision}"
|
|
276
|
+
end
|
|
277
|
+
unless root.team_id.to_s == team_id.to_s
|
|
278
|
+
raise Phronomy::Persistence::SerializationError,
|
|
279
|
+
"Team root identity mismatch: #{root.team_id} != #{team_id}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
record = DurableCodec.encode_team_root(root)
|
|
283
|
+
stored = @backend_repository.save(
|
|
284
|
+
team_id.to_s,
|
|
285
|
+
expected_revision: expected,
|
|
286
|
+
next_revision: next_revision,
|
|
287
|
+
record: record
|
|
288
|
+
)
|
|
289
|
+
decode_for_team(stored, team_id, revision: next_revision)
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def delete(team_id)
|
|
293
|
+
@backend_repository.delete(team_id.to_s)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
private
|
|
297
|
+
|
|
298
|
+
def decode_for_team(record, team_id, revision: nil)
|
|
299
|
+
root = DurableCodec.decode_team_root(record)
|
|
300
|
+
unless root.team_id == team_id.to_s
|
|
301
|
+
raise Phronomy::Persistence::SerializationError,
|
|
302
|
+
"backend returned Team root for #{root.team_id.inspect}; expected #{team_id.to_s.inspect}"
|
|
303
|
+
end
|
|
304
|
+
if revision && root.team_revision != revision
|
|
305
|
+
raise Phronomy::Persistence::SerializationError,
|
|
306
|
+
"backend returned Team revision #{root.team_revision}; expected #{revision}"
|
|
307
|
+
end
|
|
308
|
+
root
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
class TeamExecutions
|
|
313
|
+
def initialize(backend_repository)
|
|
314
|
+
@backend_repository = backend_repository
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def create_active(execution)
|
|
318
|
+
unless execution.active?
|
|
319
|
+
raise Phronomy::Persistence::SerializationError,
|
|
320
|
+
"create_active requires an active TeamExecution"
|
|
321
|
+
end
|
|
322
|
+
record = DurableCodec.encode_team_execution(execution)
|
|
323
|
+
stored = @backend_repository.create_active(
|
|
324
|
+
team_execution_id: execution.team_execution_id.to_s,
|
|
325
|
+
team_id: execution.team_id.to_s,
|
|
326
|
+
execution_revision: Integer(execution.execution_revision),
|
|
327
|
+
record: record
|
|
328
|
+
)
|
|
329
|
+
decode_for_execution(
|
|
330
|
+
stored,
|
|
331
|
+
execution.team_execution_id,
|
|
332
|
+
team_id: execution.team_id,
|
|
333
|
+
revision: execution.execution_revision,
|
|
334
|
+
active: true
|
|
335
|
+
)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def load(team_execution_id)
|
|
339
|
+
decode_for_execution(@backend_repository.load(team_execution_id.to_s), team_execution_id)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def save(team_execution_id, expected_revision:, execution:)
|
|
343
|
+
expected = Integer(expected_revision)
|
|
344
|
+
next_revision = Integer(execution.execution_revision)
|
|
345
|
+
unless next_revision == expected + 1
|
|
346
|
+
raise Phronomy::Persistence::ConflictError,
|
|
347
|
+
"execution save must advance revision exactly once: " \
|
|
348
|
+
"expected #{expected + 1}, got #{next_revision}"
|
|
349
|
+
end
|
|
350
|
+
unless execution.team_execution_id.to_s == team_execution_id.to_s
|
|
351
|
+
raise Phronomy::Persistence::SerializationError,
|
|
352
|
+
"Execution identity mismatch: #{execution.team_execution_id} != #{team_execution_id}"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
record = DurableCodec.encode_team_execution(execution)
|
|
356
|
+
stored = @backend_repository.save(
|
|
357
|
+
team_execution_id.to_s,
|
|
358
|
+
expected_revision: expected,
|
|
359
|
+
next_revision: next_revision,
|
|
360
|
+
team_id: execution.team_id.to_s,
|
|
361
|
+
active: execution.active?,
|
|
362
|
+
record: record
|
|
363
|
+
)
|
|
364
|
+
decode_for_execution(
|
|
365
|
+
stored,
|
|
366
|
+
team_execution_id,
|
|
367
|
+
team_id: execution.team_id,
|
|
368
|
+
revision: next_revision,
|
|
369
|
+
active: execution.active?
|
|
370
|
+
)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def list_active(team_id)
|
|
374
|
+
Array(@backend_repository.list_active(team_id.to_s)).map do |record|
|
|
375
|
+
decode_for_execution(record, nil, team_id: team_id, active: true)
|
|
376
|
+
end.freeze
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def list(team_id, after: nil, limit: 100)
|
|
380
|
+
raise ArgumentError, "limit must be a positive Integer" unless limit.is_a?(Integer) && limit.positive?
|
|
381
|
+
Array(@backend_repository.list(team_id.to_s, after: after&.to_s, limit: limit)).map do |record|
|
|
382
|
+
decode_for_execution(record, nil, team_id: team_id)
|
|
383
|
+
end.freeze
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def delete(team_execution_id)
|
|
387
|
+
@backend_repository.delete(team_execution_id.to_s)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def delete_for_team(team_id)
|
|
391
|
+
@backend_repository.delete_for_team(team_id.to_s)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def assert_idle!(team_id)
|
|
395
|
+
@backend_repository.assert_idle!(team_id.to_s)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
private
|
|
399
|
+
|
|
400
|
+
def decode_for_execution(record, team_execution_id, team_id: nil, revision: nil, active: nil)
|
|
401
|
+
execution = DurableCodec.decode_team_execution(record)
|
|
402
|
+
if team_execution_id && execution.team_execution_id != team_execution_id.to_s
|
|
403
|
+
raise Phronomy::Persistence::SerializationError,
|
|
404
|
+
"backend returned Execution #{execution.team_execution_id.inspect}; expected #{team_execution_id.to_s.inspect}"
|
|
405
|
+
end
|
|
406
|
+
if team_id && execution.team_id != team_id.to_s
|
|
407
|
+
raise Phronomy::Persistence::SerializationError,
|
|
408
|
+
"backend returned Execution for Agent #{execution.team_id.inspect}; expected #{team_id.to_s.inspect}"
|
|
409
|
+
end
|
|
410
|
+
if revision && execution.execution_revision != revision
|
|
411
|
+
raise Phronomy::Persistence::SerializationError,
|
|
412
|
+
"backend returned Execution revision #{execution.execution_revision}; expected #{revision}"
|
|
413
|
+
end
|
|
414
|
+
if !active.nil? && execution.active? != active
|
|
415
|
+
raise Phronomy::Persistence::SerializationError,
|
|
416
|
+
"backend returned Execution active=#{execution.active?}; expected #{active}"
|
|
417
|
+
end
|
|
418
|
+
execution
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
class HandoffStates
|
|
423
|
+
def initialize(backend_repository) = @backend_repository = backend_repository
|
|
424
|
+
|
|
425
|
+
def load(main_agent_id)
|
|
426
|
+
record = @backend_repository.load(main_agent_id.to_s)
|
|
427
|
+
record && decode(record, main_agent_id)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def save(main_agent_id, expected_revision:, state:)
|
|
431
|
+
expected = expected_revision.nil? ? nil : Integer(expected_revision)
|
|
432
|
+
next_revision = expected.nil? ? 1 : expected + 1
|
|
433
|
+
unless state.main_agent_id == main_agent_id.to_s && state.handoff_revision == next_revision
|
|
434
|
+
raise Phronomy::Persistence::SerializationError, "Handoff identity/revision mismatch"
|
|
435
|
+
end
|
|
436
|
+
record = @backend_repository.save(main_agent_id.to_s,
|
|
437
|
+
expected_revision: expected, next_revision: next_revision,
|
|
438
|
+
active_agent_id: state.active_agent_id, record: DurableCodec.encode_handoff_state(state))
|
|
439
|
+
decode(record, main_agent_id, revision: next_revision)
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def delete(main_agent_id, expected_revision:)
|
|
443
|
+
@backend_repository.delete(main_agent_id.to_s, expected_revision: Integer(expected_revision))
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
private
|
|
447
|
+
|
|
448
|
+
def decode(record, main_agent_id, revision: nil)
|
|
449
|
+
state = DurableCodec.decode_handoff_state(record)
|
|
450
|
+
identity_matches = state.main_agent_id == main_agent_id.to_s
|
|
451
|
+
revision_matches = revision.nil? || state.handoff_revision == revision
|
|
452
|
+
unless identity_matches && revision_matches
|
|
453
|
+
raise Phronomy::Persistence::SerializationError, "Backend returned another Handoff identity/revision"
|
|
454
|
+
end
|
|
455
|
+
state
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
class WorkflowStates
|
|
460
|
+
def initialize(backend_repository)
|
|
461
|
+
@backend_repository = backend_repository
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def load(workflow_instance_id)
|
|
465
|
+
record = @backend_repository.load(workflow_instance_id.to_s)
|
|
466
|
+
return nil unless record
|
|
467
|
+
|
|
468
|
+
DurableCodec.decode_workflow_state(
|
|
469
|
+
record,
|
|
470
|
+
expected_workflow_instance_id: workflow_instance_id
|
|
471
|
+
)
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def save(workflow_instance_id, expected_revision:, snapshot:)
|
|
475
|
+
expected = expected_revision.nil? ? nil : Integer(expected_revision)
|
|
476
|
+
next_revision = expected.nil? ? 1 : expected + 1
|
|
477
|
+
record = DurableCodec.encode_workflow_state(
|
|
478
|
+
workflow_instance_id: workflow_instance_id,
|
|
479
|
+
workflow_revision: next_revision,
|
|
480
|
+
snapshot: snapshot
|
|
481
|
+
)
|
|
482
|
+
stored = @backend_repository.save(
|
|
483
|
+
workflow_instance_id.to_s,
|
|
484
|
+
expected_revision: expected,
|
|
485
|
+
next_revision: next_revision,
|
|
486
|
+
record: record
|
|
487
|
+
)
|
|
488
|
+
decoded = DurableCodec.decode_workflow_state(
|
|
489
|
+
stored,
|
|
490
|
+
expected_workflow_instance_id: workflow_instance_id
|
|
491
|
+
)
|
|
492
|
+
unless decoded.fetch(:revision) == next_revision
|
|
493
|
+
raise Phronomy::Persistence::SerializationError,
|
|
494
|
+
"backend returned Workflow revision #{decoded.fetch(:revision)}; expected #{next_revision}"
|
|
495
|
+
end
|
|
496
|
+
decoded.fetch(:revision)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def delete(workflow_instance_id, expected_revision:)
|
|
500
|
+
@backend_repository.delete(
|
|
501
|
+
workflow_instance_id.to_s,
|
|
502
|
+
expected_revision: Integer(expected_revision)
|
|
503
|
+
)
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
# Transaction-scoped domain-facing Persistence view built from raw backend
|
|
508
|
+
# repositories. Backend implementations can use this through
|
|
509
|
+
# Persistence#build_transaction_view without duplicating facade logic.
|
|
510
|
+
class View
|
|
511
|
+
attr_reader :contents, :agents, :journals, :executions, :workflow_states, :handoff_states, :teams, :team_executions
|
|
512
|
+
|
|
513
|
+
def initialize(contents:, agents:, journals:, executions:, workflow_states:, handoff_states:, teams:, team_executions:, watermark:)
|
|
514
|
+
@contents = contents
|
|
515
|
+
@agents = Agents.new(agents)
|
|
516
|
+
@journals = Journals.new(journals)
|
|
517
|
+
@executions = Executions.new(executions)
|
|
518
|
+
@workflow_states = WorkflowStates.new(workflow_states)
|
|
519
|
+
@handoff_states = HandoffStates.new(handoff_states)
|
|
520
|
+
@teams = Teams.new(teams)
|
|
521
|
+
@team_executions = TeamExecutions.new(team_executions)
|
|
522
|
+
@watermark = watermark
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
|
|
526
|
+
@watermark.assert_agent_watermark!(
|
|
527
|
+
agent_id: agent_id.to_s,
|
|
528
|
+
agent_revision: Integer(agent_revision),
|
|
529
|
+
journal_position: Integer(journal_position)
|
|
530
|
+
)
|
|
531
|
+
end
|
|
532
|
+
end
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
end
|