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,706 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Persistence
|
|
5
|
+
# Current-format codec for Phronomy-owned structured Persistence records.
|
|
6
|
+
#
|
|
7
|
+
# Normal Runtime load is current-format-only. Historical conversion belongs
|
|
8
|
+
# to explicit migration code and must never be attempted here.
|
|
9
|
+
#
|
|
10
|
+
# The codec owns durable schema meaning. Backends receive DurableRecord plus
|
|
11
|
+
# explicit index/CAS metadata from RepositoryFacades; they must not inspect
|
|
12
|
+
# payload fields to rediscover Phronomy semantics.
|
|
13
|
+
#
|
|
14
|
+
# @api private
|
|
15
|
+
module DurableCodec
|
|
16
|
+
AGENT_ROOT_RECORD_TYPE = "phronomy.agent_root"
|
|
17
|
+
AGENT_ROOT_FORMAT_VERSION = "0.1"
|
|
18
|
+
AGENT_EXECUTION_RECORD_TYPE = "phronomy.agent_execution"
|
|
19
|
+
AGENT_EXECUTION_FORMAT_VERSION = "0.1"
|
|
20
|
+
JOURNAL_RECORD_TYPE = "phronomy.journal_record"
|
|
21
|
+
JOURNAL_FORMAT_VERSION = "0.1"
|
|
22
|
+
WORKFLOW_STATE_RECORD_TYPE = "phronomy.workflow_state"
|
|
23
|
+
WORKFLOW_STATE_FORMAT_VERSION = "0.1"
|
|
24
|
+
|
|
25
|
+
AGENT_ROOT_KEYS = %w[
|
|
26
|
+
agent_id agent_definition_id agent_definition_version agent_revision
|
|
27
|
+
context_revision journal_position lifecycle_status transcript_generation
|
|
28
|
+
created_at updated_at metadata
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
AGENT_EXECUTION_KEYS = %w[
|
|
32
|
+
execution_id agent_id execution_revision status phase
|
|
33
|
+
base_agent_revision base_context_revision base_journal_position
|
|
34
|
+
working_records llm_calls approval_request result_ref error_ref
|
|
35
|
+
created_at updated_at terminal_reason metadata
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
JOURNAL_RECORD_KEYS = %w[
|
|
39
|
+
record_id agent_id sequence execution_id llm_call_id kind channel role
|
|
40
|
+
content_ref parent_id causation_id visibility context_generation
|
|
41
|
+
context_candidate occurred_at metadata
|
|
42
|
+
].freeze
|
|
43
|
+
|
|
44
|
+
LLM_CALL_RECORD_KEYS = %w[
|
|
45
|
+
llm_call_id execution_id sequence status manifest_ref output_ref
|
|
46
|
+
error_ref usage_ref started_at completed_at metadata
|
|
47
|
+
].freeze
|
|
48
|
+
|
|
49
|
+
APPROVAL_REQUEST_KEYS = %w[id execution_id items created_at].freeze
|
|
50
|
+
APPROVAL_REQUEST_OPTIONAL_KEYS = %w[approved].freeze
|
|
51
|
+
APPROVAL_ITEM_KEYS = %w[
|
|
52
|
+
tool_invocation_id tool_call_id tool_name arguments facts reason origin metadata
|
|
53
|
+
].freeze
|
|
54
|
+
WORKFLOW_STATE_KEYS = %w[
|
|
55
|
+
workflow_instance_id workflow_revision snapshot
|
|
56
|
+
].freeze
|
|
57
|
+
WORKFLOW_SNAPSHOT_KEYS = %w[fields phase].freeze
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
def encode_handoff_state(value)
|
|
62
|
+
payload = value.to_h
|
|
63
|
+
Phronomy::Agent::HandoffState.from_h(payload)
|
|
64
|
+
build_record("phronomy.handoff_state", "0.1", payload)
|
|
65
|
+
rescue Phronomy::Persistence::SerializationError
|
|
66
|
+
raise
|
|
67
|
+
rescue => error
|
|
68
|
+
serialization_error("cannot encode HandoffState", error)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def decode_handoff_state(record)
|
|
72
|
+
payload = current_payload!(record, record_type: "phronomy.handoff_state",
|
|
73
|
+
format_version: "0.1", keys: Phronomy::Agent::HandoffState::ATTRIBUTES, label: "HandoffState")
|
|
74
|
+
Phronomy::Agent::HandoffState.from_h(payload)
|
|
75
|
+
rescue Phronomy::Persistence::SerializationError
|
|
76
|
+
raise
|
|
77
|
+
rescue => error
|
|
78
|
+
serialization_error("cannot decode HandoffState", error)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def encode_team_root(value)
|
|
82
|
+
payload = value.to_h
|
|
83
|
+
Phronomy::MultiAgent::TeamRoot.from_h(payload)
|
|
84
|
+
build_record("phronomy.team_root", "0.1", payload)
|
|
85
|
+
rescue Phronomy::Persistence::SerializationError
|
|
86
|
+
raise
|
|
87
|
+
rescue => error
|
|
88
|
+
serialization_error("cannot encode TeamRoot", error)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def decode_team_root(record)
|
|
92
|
+
payload = current_payload!(record, record_type: "phronomy.team_root",
|
|
93
|
+
format_version: "0.1", keys: Phronomy::MultiAgent::TeamRoot::ATTRIBUTES, label: "TeamRoot")
|
|
94
|
+
Phronomy::MultiAgent::TeamRoot.from_h(payload)
|
|
95
|
+
rescue Phronomy::Persistence::SerializationError
|
|
96
|
+
raise
|
|
97
|
+
rescue => error
|
|
98
|
+
serialization_error("cannot decode TeamRoot", error)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def encode_team_execution(value)
|
|
102
|
+
payload = value.to_h
|
|
103
|
+
Phronomy::MultiAgent::TeamExecution.from_h(payload)
|
|
104
|
+
build_record("phronomy.team_execution", "0.1", payload)
|
|
105
|
+
rescue Phronomy::Persistence::SerializationError
|
|
106
|
+
raise
|
|
107
|
+
rescue => error
|
|
108
|
+
serialization_error("cannot encode TeamExecution", error)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def decode_team_execution(record)
|
|
112
|
+
payload = current_payload!(record, record_type: "phronomy.team_execution",
|
|
113
|
+
format_version: "0.1", keys: Phronomy::MultiAgent::TeamExecution::ATTRIBUTES, label: "TeamExecution")
|
|
114
|
+
Phronomy::MultiAgent::TeamExecution.from_h(payload)
|
|
115
|
+
rescue Phronomy::Persistence::SerializationError
|
|
116
|
+
raise
|
|
117
|
+
rescue => error
|
|
118
|
+
serialization_error("cannot decode TeamExecution", error)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def encode_agent_root(root)
|
|
122
|
+
payload = top_level_string_keys(root.to_h, label: "AgentRoot payload")
|
|
123
|
+
payload["lifecycle_status"] = root.lifecycle_status.to_s
|
|
124
|
+
validate_agent_root_payload!(payload)
|
|
125
|
+
build_record(AGENT_ROOT_RECORD_TYPE, AGENT_ROOT_FORMAT_VERSION, payload)
|
|
126
|
+
rescue Phronomy::Persistence::SerializationError
|
|
127
|
+
raise
|
|
128
|
+
rescue => error
|
|
129
|
+
serialization_error("cannot encode AgentRoot", error)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def decode_agent_root(record)
|
|
133
|
+
payload = current_payload!(
|
|
134
|
+
record,
|
|
135
|
+
record_type: AGENT_ROOT_RECORD_TYPE,
|
|
136
|
+
format_version: AGENT_ROOT_FORMAT_VERSION,
|
|
137
|
+
keys: AGENT_ROOT_KEYS,
|
|
138
|
+
label: "AgentRoot payload"
|
|
139
|
+
)
|
|
140
|
+
validate_agent_root_payload!(payload)
|
|
141
|
+
Phronomy::Agent::AgentRoot.from_h(payload)
|
|
142
|
+
rescue Phronomy::Persistence::SerializationError
|
|
143
|
+
raise
|
|
144
|
+
rescue => error
|
|
145
|
+
serialization_error("cannot decode AgentRoot", error)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def encode_agent_execution(execution)
|
|
149
|
+
payload = top_level_string_keys(execution.to_h, label: "AgentExecution payload")
|
|
150
|
+
payload["status"] = execution.status.to_s
|
|
151
|
+
payload["phase"] = execution.phase.to_s
|
|
152
|
+
payload["working_records"] = execution.working_records.map do |record|
|
|
153
|
+
journal_payload(record, require_sequence: false)
|
|
154
|
+
end
|
|
155
|
+
payload["llm_calls"] = execution.llm_calls.map do |call|
|
|
156
|
+
llm_call_payload(call)
|
|
157
|
+
end
|
|
158
|
+
validate_agent_execution_payload!(payload)
|
|
159
|
+
build_record(
|
|
160
|
+
AGENT_EXECUTION_RECORD_TYPE,
|
|
161
|
+
AGENT_EXECUTION_FORMAT_VERSION,
|
|
162
|
+
payload
|
|
163
|
+
)
|
|
164
|
+
rescue Phronomy::Persistence::SerializationError
|
|
165
|
+
raise
|
|
166
|
+
rescue => error
|
|
167
|
+
serialization_error("cannot encode AgentExecution", error)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def decode_agent_execution(record)
|
|
171
|
+
payload = current_payload!(
|
|
172
|
+
record,
|
|
173
|
+
record_type: AGENT_EXECUTION_RECORD_TYPE,
|
|
174
|
+
format_version: AGENT_EXECUTION_FORMAT_VERSION,
|
|
175
|
+
keys: AGENT_EXECUTION_KEYS,
|
|
176
|
+
label: "AgentExecution payload"
|
|
177
|
+
)
|
|
178
|
+
validate_agent_execution_payload!(payload)
|
|
179
|
+
Phronomy::Agent::AgentExecution.from_h(payload)
|
|
180
|
+
rescue Phronomy::Persistence::SerializationError
|
|
181
|
+
raise
|
|
182
|
+
rescue => error
|
|
183
|
+
serialization_error("cannot decode AgentExecution", error)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def encode_journal_record(journal_record)
|
|
187
|
+
payload = journal_payload(journal_record, require_sequence: true)
|
|
188
|
+
build_record(JOURNAL_RECORD_TYPE, JOURNAL_FORMAT_VERSION, payload)
|
|
189
|
+
rescue Phronomy::Persistence::SerializationError
|
|
190
|
+
raise
|
|
191
|
+
rescue => error
|
|
192
|
+
serialization_error("cannot encode JournalRecord", error)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def decode_journal_record(record)
|
|
196
|
+
payload = current_payload!(
|
|
197
|
+
record,
|
|
198
|
+
record_type: JOURNAL_RECORD_TYPE,
|
|
199
|
+
format_version: JOURNAL_FORMAT_VERSION,
|
|
200
|
+
keys: JOURNAL_RECORD_KEYS,
|
|
201
|
+
label: "JournalRecord payload"
|
|
202
|
+
)
|
|
203
|
+
validate_journal_payload!(payload, label: "JournalRecord payload", require_sequence: true)
|
|
204
|
+
Phronomy::Agent::JournalRecord.from_h(payload)
|
|
205
|
+
rescue Phronomy::Persistence::SerializationError
|
|
206
|
+
raise
|
|
207
|
+
rescue => error
|
|
208
|
+
serialization_error("cannot decode JournalRecord", error)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def encode_workflow_state(workflow_instance_id:, workflow_revision:, snapshot:)
|
|
212
|
+
normalized_snapshot = canonicalize_workflow_snapshot(snapshot)
|
|
213
|
+
validate_workflow_snapshot!(normalized_snapshot)
|
|
214
|
+
revision = Integer(workflow_revision)
|
|
215
|
+
unless revision.positive?
|
|
216
|
+
raise Phronomy::Persistence::SerializationError,
|
|
217
|
+
"Workflow durable revision must be positive"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
payload = {
|
|
221
|
+
"workflow_instance_id" => String(workflow_instance_id),
|
|
222
|
+
"workflow_revision" => revision,
|
|
223
|
+
"snapshot" => normalized_snapshot
|
|
224
|
+
}
|
|
225
|
+
validate_workflow_state_payload!(payload)
|
|
226
|
+
build_record(WORKFLOW_STATE_RECORD_TYPE, WORKFLOW_STATE_FORMAT_VERSION, payload)
|
|
227
|
+
rescue Phronomy::Persistence::SerializationError
|
|
228
|
+
raise
|
|
229
|
+
rescue => error
|
|
230
|
+
serialization_error("cannot encode Workflow state", error)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def decode_workflow_state(record, expected_workflow_instance_id: nil)
|
|
234
|
+
payload = current_payload!(
|
|
235
|
+
record,
|
|
236
|
+
record_type: WORKFLOW_STATE_RECORD_TYPE,
|
|
237
|
+
format_version: WORKFLOW_STATE_FORMAT_VERSION,
|
|
238
|
+
keys: WORKFLOW_STATE_KEYS,
|
|
239
|
+
label: "Workflow state payload"
|
|
240
|
+
)
|
|
241
|
+
validate_workflow_state_payload!(payload)
|
|
242
|
+
workflow_instance_id = payload.fetch("workflow_instance_id")
|
|
243
|
+
if expected_workflow_instance_id &&
|
|
244
|
+
workflow_instance_id != expected_workflow_instance_id.to_s
|
|
245
|
+
raise Phronomy::Persistence::SerializationError,
|
|
246
|
+
"Workflow state identity mismatch: #{workflow_instance_id.inspect} != " \
|
|
247
|
+
"#{expected_workflow_instance_id.to_s.inspect}"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
{
|
|
251
|
+
snapshot: immutable_copy(payload.fetch("snapshot")),
|
|
252
|
+
revision: payload.fetch("workflow_revision")
|
|
253
|
+
}.freeze
|
|
254
|
+
rescue Phronomy::Persistence::SerializationError
|
|
255
|
+
raise
|
|
256
|
+
rescue => error
|
|
257
|
+
serialization_error("cannot decode Workflow state", error)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def validate_agent_root_payload!(payload)
|
|
261
|
+
validate_exact_keys!(payload, AGENT_ROOT_KEYS, label: "AgentRoot payload")
|
|
262
|
+
require_nonempty_string!(payload, "agent_id", label: "AgentRoot payload")
|
|
263
|
+
require_nonempty_string!(payload, "agent_definition_id", label: "AgentRoot payload")
|
|
264
|
+
require_integer!(payload, "agent_definition_version", label: "AgentRoot payload")
|
|
265
|
+
require_nonnegative_integer!(payload, "agent_revision", label: "AgentRoot payload")
|
|
266
|
+
require_nonnegative_integer!(payload, "context_revision", label: "AgentRoot payload")
|
|
267
|
+
require_nonnegative_integer!(payload, "journal_position", label: "AgentRoot payload")
|
|
268
|
+
require_enum_string!(
|
|
269
|
+
payload,
|
|
270
|
+
"lifecycle_status",
|
|
271
|
+
Phronomy::Agent::AgentRoot::LIFECYCLE_STATUSES.map(&:to_s),
|
|
272
|
+
label: "AgentRoot payload"
|
|
273
|
+
)
|
|
274
|
+
require_nonnegative_integer!(payload, "transcript_generation", label: "AgentRoot payload")
|
|
275
|
+
require_nonempty_string!(payload, "created_at", label: "AgentRoot payload")
|
|
276
|
+
require_nonempty_string!(payload, "updated_at", label: "AgentRoot payload")
|
|
277
|
+
require_canonical_hash!(payload, "metadata", label: "AgentRoot payload")
|
|
278
|
+
payload
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def validate_agent_execution_payload!(payload)
|
|
282
|
+
validate_exact_keys!(payload, AGENT_EXECUTION_KEYS, label: "AgentExecution payload")
|
|
283
|
+
require_nonempty_string!(payload, "execution_id", label: "AgentExecution payload")
|
|
284
|
+
require_nonempty_string!(payload, "agent_id", label: "AgentExecution payload")
|
|
285
|
+
require_nonnegative_integer!(payload, "execution_revision", label: "AgentExecution payload")
|
|
286
|
+
require_enum_string!(
|
|
287
|
+
payload,
|
|
288
|
+
"status",
|
|
289
|
+
Phronomy::Agent::AgentExecution::TRANSITIONS.keys.map(&:to_s),
|
|
290
|
+
label: "AgentExecution payload"
|
|
291
|
+
)
|
|
292
|
+
require_nonempty_string!(payload, "phase", label: "AgentExecution payload")
|
|
293
|
+
require_nonnegative_integer!(payload, "base_agent_revision", label: "AgentExecution payload")
|
|
294
|
+
require_nonnegative_integer!(payload, "base_context_revision", label: "AgentExecution payload")
|
|
295
|
+
require_nonnegative_integer!(payload, "base_journal_position", label: "AgentExecution payload")
|
|
296
|
+
require_optional_string!(payload, "result_ref", label: "AgentExecution payload")
|
|
297
|
+
require_optional_string!(payload, "error_ref", label: "AgentExecution payload")
|
|
298
|
+
require_nonempty_string!(payload, "created_at", label: "AgentExecution payload")
|
|
299
|
+
require_nonempty_string!(payload, "updated_at", label: "AgentExecution payload")
|
|
300
|
+
require_optional_string!(payload, "terminal_reason", label: "AgentExecution payload")
|
|
301
|
+
require_canonical_hash!(payload, "metadata", label: "AgentExecution payload")
|
|
302
|
+
|
|
303
|
+
working_records = payload.fetch("working_records")
|
|
304
|
+
unless working_records.is_a?(Array)
|
|
305
|
+
raise Phronomy::Persistence::SerializationError,
|
|
306
|
+
"AgentExecution payload working_records must be an Array"
|
|
307
|
+
end
|
|
308
|
+
working_records.each_with_index do |record, index|
|
|
309
|
+
validate_journal_payload!(
|
|
310
|
+
record,
|
|
311
|
+
label: "AgentExecution working_records[#{index}]",
|
|
312
|
+
require_sequence: false
|
|
313
|
+
)
|
|
314
|
+
record_agent_id = record.fetch("agent_id")
|
|
315
|
+
unless record_agent_id == payload.fetch("agent_id")
|
|
316
|
+
raise Phronomy::Persistence::SerializationError,
|
|
317
|
+
"AgentExecution working_records[#{index}] agent_id mismatch"
|
|
318
|
+
end
|
|
319
|
+
record_execution_id = record.fetch("execution_id")
|
|
320
|
+
if record_execution_id && record_execution_id != payload.fetch("execution_id")
|
|
321
|
+
raise Phronomy::Persistence::SerializationError,
|
|
322
|
+
"AgentExecution working_records[#{index}] execution_id mismatch"
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
llm_calls = payload.fetch("llm_calls")
|
|
327
|
+
unless llm_calls.is_a?(Array)
|
|
328
|
+
raise Phronomy::Persistence::SerializationError,
|
|
329
|
+
"AgentExecution payload llm_calls must be an Array"
|
|
330
|
+
end
|
|
331
|
+
llm_calls.each_with_index do |call, index|
|
|
332
|
+
validate_llm_call_payload!(call, label: "AgentExecution llm_calls[#{index}]")
|
|
333
|
+
unless call.fetch("execution_id") == payload.fetch("execution_id")
|
|
334
|
+
raise Phronomy::Persistence::SerializationError,
|
|
335
|
+
"AgentExecution llm_calls[#{index}] execution_id mismatch"
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
validate_approval_request!(
|
|
340
|
+
payload.fetch("approval_request"),
|
|
341
|
+
execution_id: payload.fetch("execution_id")
|
|
342
|
+
)
|
|
343
|
+
payload
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def validate_journal_payload!(payload, label:, require_sequence:)
|
|
347
|
+
validate_exact_keys!(payload, JOURNAL_RECORD_KEYS, label: label)
|
|
348
|
+
require_nonempty_string!(payload, "record_id", label: label)
|
|
349
|
+
require_nonempty_string!(payload, "agent_id", label: label)
|
|
350
|
+
sequence = payload.fetch("sequence")
|
|
351
|
+
if require_sequence
|
|
352
|
+
unless sequence.is_a?(Integer) && sequence.positive?
|
|
353
|
+
raise Phronomy::Persistence::SerializationError,
|
|
354
|
+
"#{label} sequence must be a positive Integer"
|
|
355
|
+
end
|
|
356
|
+
elsif !(sequence.nil? || (sequence.is_a?(Integer) && sequence.positive?))
|
|
357
|
+
raise Phronomy::Persistence::SerializationError,
|
|
358
|
+
"#{label} sequence must be nil or a positive Integer"
|
|
359
|
+
end
|
|
360
|
+
require_optional_string!(payload, "execution_id", label: label)
|
|
361
|
+
require_optional_string!(payload, "llm_call_id", label: label)
|
|
362
|
+
require_nonempty_string!(payload, "kind", label: label)
|
|
363
|
+
require_nonempty_string!(payload, "channel", label: label)
|
|
364
|
+
require_optional_string!(payload, "role", label: label)
|
|
365
|
+
require_optional_string!(payload, "content_ref", label: label)
|
|
366
|
+
require_optional_string!(payload, "parent_id", label: label)
|
|
367
|
+
require_optional_string!(payload, "causation_id", label: label)
|
|
368
|
+
require_nonempty_string!(payload, "visibility", label: label)
|
|
369
|
+
require_nonnegative_integer!(payload, "context_generation", label: label)
|
|
370
|
+
require_boolean!(payload, "context_candidate", label: label)
|
|
371
|
+
require_nonempty_string!(payload, "occurred_at", label: label)
|
|
372
|
+
require_canonical_hash!(payload, "metadata", label: label)
|
|
373
|
+
payload
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def validate_llm_call_payload!(payload, label:)
|
|
377
|
+
validate_exact_keys!(payload, LLM_CALL_RECORD_KEYS, label: label)
|
|
378
|
+
require_nonempty_string!(payload, "llm_call_id", label: label)
|
|
379
|
+
require_nonempty_string!(payload, "execution_id", label: label)
|
|
380
|
+
require_positive_integer!(payload, "sequence", label: label)
|
|
381
|
+
require_enum_string!(
|
|
382
|
+
payload,
|
|
383
|
+
"status",
|
|
384
|
+
Phronomy::Agent::LLMCallRecord::STATUSES.map(&:to_s),
|
|
385
|
+
label: label
|
|
386
|
+
)
|
|
387
|
+
require_nonempty_string!(payload, "manifest_ref", label: label)
|
|
388
|
+
require_optional_string!(payload, "output_ref", label: label)
|
|
389
|
+
require_optional_string!(payload, "error_ref", label: label)
|
|
390
|
+
require_optional_string!(payload, "usage_ref", label: label)
|
|
391
|
+
require_nonempty_string!(payload, "started_at", label: label)
|
|
392
|
+
require_optional_string!(payload, "completed_at", label: label)
|
|
393
|
+
require_canonical_hash!(payload, "metadata", label: label)
|
|
394
|
+
payload
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def validate_approval_request!(request, execution_id:)
|
|
398
|
+
return if request.nil?
|
|
399
|
+
|
|
400
|
+
validate_allowed_keys!(
|
|
401
|
+
request,
|
|
402
|
+
required_keys: APPROVAL_REQUEST_KEYS,
|
|
403
|
+
optional_keys: APPROVAL_REQUEST_OPTIONAL_KEYS,
|
|
404
|
+
label: "approval_request"
|
|
405
|
+
)
|
|
406
|
+
require_nonempty_string!(request, "id", label: "approval_request")
|
|
407
|
+
require_nonempty_string!(request, "execution_id", label: "approval_request")
|
|
408
|
+
unless request.fetch("execution_id") == execution_id
|
|
409
|
+
raise Phronomy::Persistence::SerializationError,
|
|
410
|
+
"approval_request execution_id mismatch"
|
|
411
|
+
end
|
|
412
|
+
require_nonempty_string!(request, "created_at", label: "approval_request")
|
|
413
|
+
if request.key?("approved") && !boolean?(request.fetch("approved"))
|
|
414
|
+
raise Phronomy::Persistence::SerializationError,
|
|
415
|
+
"approval_request approved must be true or false"
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
items = request.fetch("items")
|
|
419
|
+
unless items.is_a?(Array) && !items.empty?
|
|
420
|
+
raise Phronomy::Persistence::SerializationError,
|
|
421
|
+
"approval_request items must be a non-empty Array"
|
|
422
|
+
end
|
|
423
|
+
items.each_with_index do |item, index|
|
|
424
|
+
item_label = "approval_request items[#{index}]"
|
|
425
|
+
validate_exact_keys!(item, APPROVAL_ITEM_KEYS, label: item_label)
|
|
426
|
+
require_nonempty_string!(item, "tool_invocation_id", label: item_label)
|
|
427
|
+
require_optional_string!(item, "tool_call_id", label: item_label)
|
|
428
|
+
require_nonempty_string!(item, "tool_name", label: item_label)
|
|
429
|
+
require_canonical_hash!(item, "arguments", label: item_label)
|
|
430
|
+
require_canonical_hash!(item, "facts", label: item_label)
|
|
431
|
+
require_optional_string!(item, "reason", label: item_label)
|
|
432
|
+
require_nonempty_string!(item, "origin", label: item_label)
|
|
433
|
+
require_canonical_hash!(item, "metadata", label: item_label)
|
|
434
|
+
end
|
|
435
|
+
request
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
def validate_workflow_state_payload!(payload)
|
|
439
|
+
validate_exact_keys!(payload, WORKFLOW_STATE_KEYS, label: "Workflow state payload")
|
|
440
|
+
require_nonempty_string!(payload, "workflow_instance_id", label: "Workflow state payload")
|
|
441
|
+
require_positive_integer!(payload, "workflow_revision", label: "Workflow state payload")
|
|
442
|
+
validate_workflow_snapshot!(payload.fetch("snapshot"))
|
|
443
|
+
payload
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def validate_workflow_snapshot!(snapshot)
|
|
447
|
+
validate_exact_keys!(snapshot, WORKFLOW_SNAPSHOT_KEYS, label: "Workflow snapshot")
|
|
448
|
+
unless snapshot.fetch("fields").is_a?(Hash)
|
|
449
|
+
raise Phronomy::Persistence::SerializationError,
|
|
450
|
+
"Workflow snapshot fields must be a Hash"
|
|
451
|
+
end
|
|
452
|
+
phase = snapshot.fetch("phase")
|
|
453
|
+
unless phase.nil? || phase.is_a?(String)
|
|
454
|
+
raise Phronomy::Persistence::SerializationError,
|
|
455
|
+
"Workflow snapshot phase must be a String or nil"
|
|
456
|
+
end
|
|
457
|
+
Phronomy::CanonicalJSON.dump(snapshot)
|
|
458
|
+
snapshot
|
|
459
|
+
rescue ArgumentError => error
|
|
460
|
+
raise Phronomy::Persistence::SerializationError,
|
|
461
|
+
"Workflow snapshot is not canonical JSON compatible: #{error.message}"
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def current_payload!(record, record_type:, format_version:, keys:, label:)
|
|
465
|
+
unless record.is_a?(Phronomy::Persistence::DurableRecord)
|
|
466
|
+
raise Phronomy::Persistence::SerializationError,
|
|
467
|
+
"backend returned #{record.class}; expected Persistence::DurableRecord"
|
|
468
|
+
end
|
|
469
|
+
unless record.record_type == record_type
|
|
470
|
+
raise Phronomy::Persistence::SerializationError,
|
|
471
|
+
"durable record type mismatch: expected #{record_type.inspect}, " \
|
|
472
|
+
"got #{record.record_type.inspect}"
|
|
473
|
+
end
|
|
474
|
+
unless record.format_version == format_version
|
|
475
|
+
raise Phronomy::Persistence::SerializationError,
|
|
476
|
+
"unsupported #{record_type} format version: #{record.format_version.inspect}; " \
|
|
477
|
+
"current version is #{format_version.inspect}"
|
|
478
|
+
end
|
|
479
|
+
validate_exact_keys!(record.payload, keys, label: label)
|
|
480
|
+
record.payload
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def validate_allowed_keys!(hash, required_keys:, optional_keys:, label:)
|
|
484
|
+
unless hash.is_a?(Hash)
|
|
485
|
+
raise Phronomy::Persistence::SerializationError, "#{label} must be a Hash"
|
|
486
|
+
end
|
|
487
|
+
unless hash.keys.all? { |key| key.is_a?(String) }
|
|
488
|
+
raise Phronomy::Persistence::SerializationError,
|
|
489
|
+
"#{label} keys must all be String"
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
actual = hash.keys.sort
|
|
493
|
+
missing = required_keys.sort - actual
|
|
494
|
+
unknown = actual - (required_keys + optional_keys).sort
|
|
495
|
+
return hash if missing.empty? && unknown.empty?
|
|
496
|
+
|
|
497
|
+
details = []
|
|
498
|
+
details << "missing=#{missing.inspect}" unless missing.empty?
|
|
499
|
+
details << "unknown=#{unknown.inspect}" unless unknown.empty?
|
|
500
|
+
raise Phronomy::Persistence::SerializationError,
|
|
501
|
+
"#{label} schema mismatch (#{details.join(", ")})"
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def validate_exact_keys!(hash, expected_keys, label:)
|
|
505
|
+
unless hash.is_a?(Hash)
|
|
506
|
+
raise Phronomy::Persistence::SerializationError, "#{label} must be a Hash"
|
|
507
|
+
end
|
|
508
|
+
unless hash.keys.all? { |key| key.is_a?(String) }
|
|
509
|
+
raise Phronomy::Persistence::SerializationError,
|
|
510
|
+
"#{label} keys must all be String"
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
actual = hash.keys.sort
|
|
514
|
+
expected = expected_keys.sort
|
|
515
|
+
return hash if actual == expected
|
|
516
|
+
|
|
517
|
+
missing = expected - actual
|
|
518
|
+
unknown = actual - expected
|
|
519
|
+
details = []
|
|
520
|
+
details << "missing=#{missing.inspect}" unless missing.empty?
|
|
521
|
+
details << "unknown=#{unknown.inspect}" unless unknown.empty?
|
|
522
|
+
raise Phronomy::Persistence::SerializationError,
|
|
523
|
+
"#{label} schema mismatch (#{details.join(", ")})"
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def top_level_string_keys(value, label:)
|
|
527
|
+
unless value.is_a?(Hash)
|
|
528
|
+
raise Phronomy::Persistence::SerializationError, "#{label} must be a Hash"
|
|
529
|
+
end
|
|
530
|
+
value.each_with_object({}) do |(key, child), result|
|
|
531
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
532
|
+
raise Phronomy::Persistence::SerializationError,
|
|
533
|
+
"#{label} key must be String or Symbol, got #{key.class}"
|
|
534
|
+
end
|
|
535
|
+
string_key = key.to_s
|
|
536
|
+
if result.key?(string_key)
|
|
537
|
+
raise Phronomy::Persistence::SerializationError,
|
|
538
|
+
"#{label} contains duplicate key after normalization: #{string_key.inspect}"
|
|
539
|
+
end
|
|
540
|
+
result[string_key] = child
|
|
541
|
+
end
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# Workflow fields historically normalize Ruby structural keys and Symbol
|
|
545
|
+
# values to strings before durable comparison. Keep that rule explicit and
|
|
546
|
+
# isolated here instead of applying Symbol#to_s generically to every codec.
|
|
547
|
+
def canonicalize_workflow_snapshot(snapshot)
|
|
548
|
+
source = top_level_string_keys(snapshot, label: "Workflow snapshot")
|
|
549
|
+
fields = source.fetch("fields")
|
|
550
|
+
unless fields.is_a?(Hash)
|
|
551
|
+
raise Phronomy::Persistence::SerializationError,
|
|
552
|
+
"Workflow snapshot fields must be a Hash"
|
|
553
|
+
end
|
|
554
|
+
{
|
|
555
|
+
"fields" => canonicalize_workflow_value(fields),
|
|
556
|
+
"phase" => source["phase"]&.to_s
|
|
557
|
+
}
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def canonicalize_workflow_value(value)
|
|
561
|
+
case value
|
|
562
|
+
when Hash
|
|
563
|
+
value.each_with_object({}) do |(key, child), result|
|
|
564
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
|
565
|
+
raise Phronomy::Persistence::SerializationError,
|
|
566
|
+
"Workflow field key must be String or Symbol, got #{key.class}"
|
|
567
|
+
end
|
|
568
|
+
string_key = key.to_s
|
|
569
|
+
if result.key?(string_key)
|
|
570
|
+
raise Phronomy::Persistence::SerializationError,
|
|
571
|
+
"duplicate Workflow field key after normalization: #{string_key.inspect}"
|
|
572
|
+
end
|
|
573
|
+
result[string_key] = canonicalize_workflow_value(child)
|
|
574
|
+
end
|
|
575
|
+
when Array
|
|
576
|
+
value.map { |child| canonicalize_workflow_value(child) }
|
|
577
|
+
when Symbol
|
|
578
|
+
value.to_s
|
|
579
|
+
when String, Integer, Float, TrueClass, FalseClass, NilClass
|
|
580
|
+
value
|
|
581
|
+
else
|
|
582
|
+
raise Phronomy::Persistence::SerializationError,
|
|
583
|
+
"unsupported Workflow durable value: #{value.class}"
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
def journal_payload(record, require_sequence:)
|
|
588
|
+
payload = top_level_string_keys(record.to_h, label: "JournalRecord payload")
|
|
589
|
+
%w[kind channel role visibility].each do |key|
|
|
590
|
+
value = payload[key]
|
|
591
|
+
payload[key] = value.to_s if value
|
|
592
|
+
end
|
|
593
|
+
validate_journal_payload!(payload, label: "JournalRecord payload", require_sequence: require_sequence)
|
|
594
|
+
payload
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
def llm_call_payload(call)
|
|
598
|
+
payload = top_level_string_keys(call.to_h, label: "LLMCallRecord payload")
|
|
599
|
+
payload["status"] = call.status.to_s
|
|
600
|
+
validate_llm_call_payload!(payload, label: "LLMCallRecord payload")
|
|
601
|
+
payload
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
def require_nonempty_string!(hash, key, label:)
|
|
605
|
+
value = hash.fetch(key)
|
|
606
|
+
return value if value.is_a?(String) && !value.empty?
|
|
607
|
+
|
|
608
|
+
raise Phronomy::Persistence::SerializationError,
|
|
609
|
+
"#{label} #{key} must be a non-empty String"
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def require_optional_string!(hash, key, label:)
|
|
613
|
+
value = hash.fetch(key)
|
|
614
|
+
return value if value.nil? || value.is_a?(String)
|
|
615
|
+
|
|
616
|
+
raise Phronomy::Persistence::SerializationError,
|
|
617
|
+
"#{label} #{key} must be a String or nil"
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def require_integer!(hash, key, label:)
|
|
621
|
+
value = hash.fetch(key)
|
|
622
|
+
return value if value.is_a?(Integer)
|
|
623
|
+
|
|
624
|
+
raise Phronomy::Persistence::SerializationError,
|
|
625
|
+
"#{label} #{key} must be an Integer"
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def require_positive_integer!(hash, key, label:)
|
|
629
|
+
value = hash.fetch(key)
|
|
630
|
+
return value if value.is_a?(Integer) && value.positive?
|
|
631
|
+
|
|
632
|
+
raise Phronomy::Persistence::SerializationError,
|
|
633
|
+
"#{label} #{key} must be a positive Integer"
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
def require_nonnegative_integer!(hash, key, label:)
|
|
637
|
+
value = hash.fetch(key)
|
|
638
|
+
return value if value.is_a?(Integer) && value >= 0
|
|
639
|
+
|
|
640
|
+
raise Phronomy::Persistence::SerializationError,
|
|
641
|
+
"#{label} #{key} must be a non-negative Integer"
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
def require_boolean!(hash, key, label:)
|
|
645
|
+
value = hash.fetch(key)
|
|
646
|
+
return value if boolean?(value)
|
|
647
|
+
|
|
648
|
+
raise Phronomy::Persistence::SerializationError,
|
|
649
|
+
"#{label} #{key} must be true or false"
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
def require_enum_string!(hash, key, allowed, label:)
|
|
653
|
+
value = hash.fetch(key)
|
|
654
|
+
return value if value.is_a?(String) && allowed.include?(value)
|
|
655
|
+
|
|
656
|
+
raise Phronomy::Persistence::SerializationError,
|
|
657
|
+
"#{label} #{key} must be one of #{allowed.inspect}"
|
|
658
|
+
end
|
|
659
|
+
|
|
660
|
+
def require_canonical_hash!(hash, key, label:)
|
|
661
|
+
value = hash.fetch(key)
|
|
662
|
+
unless value.is_a?(Hash)
|
|
663
|
+
raise Phronomy::Persistence::SerializationError,
|
|
664
|
+
"#{label} #{key} must be a Hash"
|
|
665
|
+
end
|
|
666
|
+
Phronomy::CanonicalJSON.dump(value)
|
|
667
|
+
value
|
|
668
|
+
rescue ArgumentError => error
|
|
669
|
+
raise Phronomy::Persistence::SerializationError,
|
|
670
|
+
"#{label} #{key} is not canonical JSON compatible: #{error.message}"
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
def boolean?(value)
|
|
674
|
+
value.equal?(true) || value.equal?(false)
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def build_record(record_type, format_version, payload)
|
|
678
|
+
Phronomy::Persistence::DurableRecord.new(
|
|
679
|
+
record_type: record_type,
|
|
680
|
+
format_version: format_version,
|
|
681
|
+
payload: payload
|
|
682
|
+
)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def immutable_copy(value)
|
|
686
|
+
case value
|
|
687
|
+
when Hash
|
|
688
|
+
value.each_with_object({}) do |(key, child), result|
|
|
689
|
+
result[key.dup.freeze] = immutable_copy(child)
|
|
690
|
+
end.freeze
|
|
691
|
+
when Array
|
|
692
|
+
value.map { |child| immutable_copy(child) }.freeze
|
|
693
|
+
when String
|
|
694
|
+
value.dup.freeze
|
|
695
|
+
else
|
|
696
|
+
value
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def serialization_error(prefix, error)
|
|
701
|
+
raise Phronomy::Persistence::SerializationError,
|
|
702
|
+
"#{prefix}: #{error.class}: #{error.message}"
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
end
|
|
706
|
+
end
|