omnibase_infra 0.2.8__py3-none-any.whl → 0.2.9__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. omnibase_infra/__init__.py +1 -1
  2. omnibase_infra/enums/__init__.py +4 -0
  3. omnibase_infra/enums/enum_declarative_node_violation.py +102 -0
  4. omnibase_infra/event_bus/adapters/__init__.py +31 -0
  5. omnibase_infra/event_bus/adapters/adapter_protocol_event_publisher_kafka.py +517 -0
  6. omnibase_infra/mixins/mixin_async_circuit_breaker.py +113 -1
  7. omnibase_infra/models/__init__.py +9 -0
  8. omnibase_infra/models/event_bus/__init__.py +22 -0
  9. omnibase_infra/models/event_bus/model_consumer_retry_config.py +367 -0
  10. omnibase_infra/models/event_bus/model_dlq_config.py +177 -0
  11. omnibase_infra/models/event_bus/model_idempotency_config.py +131 -0
  12. omnibase_infra/models/event_bus/model_offset_policy_config.py +107 -0
  13. omnibase_infra/models/resilience/model_circuit_breaker_config.py +15 -0
  14. omnibase_infra/models/validation/__init__.py +8 -0
  15. omnibase_infra/models/validation/model_declarative_node_validation_result.py +139 -0
  16. omnibase_infra/models/validation/model_declarative_node_violation.py +169 -0
  17. omnibase_infra/nodes/architecture_validator/__init__.py +28 -7
  18. omnibase_infra/nodes/architecture_validator/constants.py +36 -0
  19. omnibase_infra/nodes/architecture_validator/handlers/__init__.py +28 -0
  20. omnibase_infra/nodes/architecture_validator/handlers/contract.yaml +120 -0
  21. omnibase_infra/nodes/architecture_validator/handlers/handler_architecture_validation.py +359 -0
  22. omnibase_infra/nodes/architecture_validator/node.py +1 -0
  23. omnibase_infra/nodes/architecture_validator/node_architecture_validator.py +48 -336
  24. omnibase_infra/nodes/node_ledger_projection_compute/__init__.py +16 -2
  25. omnibase_infra/nodes/node_ledger_projection_compute/contract.yaml +14 -4
  26. omnibase_infra/nodes/node_ledger_projection_compute/handlers/__init__.py +18 -0
  27. omnibase_infra/nodes/node_ledger_projection_compute/handlers/contract.yaml +53 -0
  28. omnibase_infra/nodes/node_ledger_projection_compute/handlers/handler_ledger_projection.py +354 -0
  29. omnibase_infra/nodes/node_ledger_projection_compute/node.py +20 -256
  30. omnibase_infra/nodes/node_registry_effect/node.py +20 -73
  31. omnibase_infra/protocols/protocol_dispatch_engine.py +90 -0
  32. omnibase_infra/runtime/__init__.py +11 -0
  33. omnibase_infra/runtime/baseline_subscriptions.py +150 -0
  34. omnibase_infra/runtime/event_bus_subcontract_wiring.py +455 -24
  35. omnibase_infra/runtime/kafka_contract_source.py +13 -5
  36. omnibase_infra/runtime/service_message_dispatch_engine.py +112 -0
  37. omnibase_infra/runtime/service_runtime_host_process.py +6 -11
  38. omnibase_infra/services/__init__.py +36 -0
  39. omnibase_infra/services/contract_publisher/__init__.py +95 -0
  40. omnibase_infra/services/contract_publisher/config.py +199 -0
  41. omnibase_infra/services/contract_publisher/errors.py +243 -0
  42. omnibase_infra/services/contract_publisher/models/__init__.py +28 -0
  43. omnibase_infra/services/contract_publisher/models/model_contract_error.py +67 -0
  44. omnibase_infra/services/contract_publisher/models/model_infra_error.py +62 -0
  45. omnibase_infra/services/contract_publisher/models/model_publish_result.py +112 -0
  46. omnibase_infra/services/contract_publisher/models/model_publish_stats.py +79 -0
  47. omnibase_infra/services/contract_publisher/service.py +617 -0
  48. omnibase_infra/services/contract_publisher/sources/__init__.py +52 -0
  49. omnibase_infra/services/contract_publisher/sources/model_discovered.py +155 -0
  50. omnibase_infra/services/contract_publisher/sources/protocol.py +101 -0
  51. omnibase_infra/services/contract_publisher/sources/source_composite.py +309 -0
  52. omnibase_infra/services/contract_publisher/sources/source_filesystem.py +174 -0
  53. omnibase_infra/services/contract_publisher/sources/source_package.py +221 -0
  54. omnibase_infra/services/observability/__init__.py +40 -0
  55. omnibase_infra/services/observability/agent_actions/__init__.py +64 -0
  56. omnibase_infra/services/observability/agent_actions/config.py +209 -0
  57. omnibase_infra/services/observability/agent_actions/consumer.py +1320 -0
  58. omnibase_infra/services/observability/agent_actions/models/__init__.py +87 -0
  59. omnibase_infra/services/observability/agent_actions/models/model_agent_action.py +142 -0
  60. omnibase_infra/services/observability/agent_actions/models/model_detection_failure.py +125 -0
  61. omnibase_infra/services/observability/agent_actions/models/model_envelope.py +85 -0
  62. omnibase_infra/services/observability/agent_actions/models/model_execution_log.py +159 -0
  63. omnibase_infra/services/observability/agent_actions/models/model_performance_metric.py +130 -0
  64. omnibase_infra/services/observability/agent_actions/models/model_routing_decision.py +138 -0
  65. omnibase_infra/services/observability/agent_actions/models/model_transformation_event.py +124 -0
  66. omnibase_infra/services/observability/agent_actions/tests/__init__.py +20 -0
  67. omnibase_infra/services/observability/agent_actions/tests/test_consumer.py +1154 -0
  68. omnibase_infra/services/observability/agent_actions/tests/test_models.py +645 -0
  69. omnibase_infra/services/observability/agent_actions/tests/test_writer.py +709 -0
  70. omnibase_infra/services/observability/agent_actions/writer_postgres.py +926 -0
  71. omnibase_infra/validation/__init__.py +12 -0
  72. omnibase_infra/validation/contracts/declarative_node.validation.yaml +143 -0
  73. omnibase_infra/validation/validation_exemptions.yaml +93 -0
  74. omnibase_infra/validation/validator_declarative_node.py +850 -0
  75. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.2.9.dist-info}/METADATA +2 -2
  76. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.2.9.dist-info}/RECORD +79 -27
  77. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.2.9.dist-info}/WHEEL +0 -0
  78. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.2.9.dist-info}/entry_points.txt +0 -0
  79. {omnibase_infra-0.2.8.dist-info → omnibase_infra-0.2.9.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,926 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2025 OmniNode Team
3
+ """PostgreSQL Writer for Agent Actions Observability.
4
+
5
+ This module provides a PostgreSQL writer for persisting agent observability
6
+ events consumed from Kafka. It handles batch inserts with idempotency
7
+ guarantees and circuit breaker resilience.
8
+
9
+ Design Decisions:
10
+ - Pool injection: asyncpg.Pool is injected, not created/managed
11
+ - Batch inserts: Uses executemany for efficient batch processing
12
+ - Idempotency: ON CONFLICT DO NOTHING/UPDATE per table contract
13
+ - Circuit breaker: MixinAsyncCircuitBreaker for resilience
14
+ - JSONB serialization: dict fields serialized to JSON strings
15
+
16
+ Idempotency Contract:
17
+ | Table | Unique Key | Conflict Action |
18
+ |-------------------------------|------------------|-----------------|
19
+ | agent_actions | id | DO NOTHING |
20
+ | agent_routing_decisions | id | DO NOTHING |
21
+ | agent_transformation_events | id | DO NOTHING |
22
+ | router_performance_metrics | id | DO NOTHING |
23
+ | agent_detection_failures | correlation_id | DO NOTHING |
24
+ | agent_execution_logs | execution_id | DO UPDATE |
25
+
26
+ Example:
27
+ >>> import asyncpg
28
+ >>> from omnibase_infra.services.observability.agent_actions.writer_postgres import (
29
+ ... WriterAgentActionsPostgres,
30
+ ... )
31
+ >>>
32
+ >>> pool = await asyncpg.create_pool(dsn="postgresql://...")
33
+ >>> writer = WriterAgentActionsPostgres(pool)
34
+ >>>
35
+ >>> # Write batch of agent actions
36
+ >>> count = await writer.write_agent_actions(actions)
37
+ >>> print(f"Wrote {count} agent actions")
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import json
43
+ import logging
44
+ from collections.abc import Mapping
45
+ from uuid import UUID, uuid4
46
+
47
+ import asyncpg
48
+
49
+ from omnibase_core.types import JsonType
50
+ from omnibase_infra.enums import EnumInfraTransportType
51
+ from omnibase_infra.errors import (
52
+ InfraConnectionError,
53
+ InfraTimeoutError,
54
+ ModelInfraErrorContext,
55
+ ModelTimeoutErrorContext,
56
+ RuntimeHostError,
57
+ )
58
+ from omnibase_infra.mixins import MixinAsyncCircuitBreaker
59
+ from omnibase_infra.services.observability.agent_actions.models import (
60
+ ModelAgentAction,
61
+ ModelDetectionFailure,
62
+ ModelExecutionLog,
63
+ ModelPerformanceMetric,
64
+ ModelRoutingDecision,
65
+ ModelTransformationEvent,
66
+ )
67
+
68
+ logger = logging.getLogger(__name__)
69
+
70
+
71
+ class WriterAgentActionsPostgres(MixinAsyncCircuitBreaker):
72
+ """PostgreSQL writer for agent observability events.
73
+
74
+ Provides batch write methods for each observability table with idempotency
75
+ guarantees and circuit breaker resilience. The asyncpg.Pool is injected
76
+ and its lifecycle is managed externally.
77
+
78
+ Features:
79
+ - Batch inserts via executemany for efficiency
80
+ - Idempotent writes via ON CONFLICT clauses
81
+ - Circuit breaker for database resilience
82
+ - JSONB field serialization
83
+ - Correlation ID propagation for tracing
84
+
85
+ Attributes:
86
+ _pool: Injected asyncpg connection pool.
87
+ circuit_breaker_threshold: Failure threshold before opening circuit.
88
+ circuit_breaker_reset_timeout: Seconds before auto-reset.
89
+ DEFAULT_QUERY_TIMEOUT_SECONDS: Default timeout for database queries.
90
+
91
+ Example:
92
+ >>> pool = await asyncpg.create_pool(dsn="postgresql://...")
93
+ >>> writer = WriterAgentActionsPostgres(
94
+ ... pool,
95
+ ... circuit_breaker_threshold=5,
96
+ ... circuit_breaker_reset_timeout=60.0,
97
+ ... circuit_breaker_half_open_successes=2,
98
+ ... query_timeout=30.0,
99
+ ... )
100
+ >>>
101
+ >>> # Write batch of routing decisions
102
+ >>> count = await writer.write_routing_decisions(decisions)
103
+ """
104
+
105
+ DEFAULT_QUERY_TIMEOUT_SECONDS: float = 30.0
106
+
107
+ def __init__(
108
+ self,
109
+ pool: asyncpg.Pool,
110
+ circuit_breaker_threshold: int = 5,
111
+ circuit_breaker_reset_timeout: float = 60.0,
112
+ circuit_breaker_half_open_successes: int = 1,
113
+ query_timeout: float | None = None,
114
+ ) -> None:
115
+ """Initialize the PostgreSQL writer with an injected pool.
116
+
117
+ Args:
118
+ pool: asyncpg connection pool (lifecycle managed externally).
119
+ circuit_breaker_threshold: Failures before opening circuit (default: 5).
120
+ circuit_breaker_reset_timeout: Seconds before auto-reset (default: 60.0).
121
+ circuit_breaker_half_open_successes: Successful requests required to close
122
+ circuit from half-open state (default: 1).
123
+ query_timeout: Timeout in seconds for database queries. Used in error
124
+ context for timeout diagnostics (default: DEFAULT_QUERY_TIMEOUT_SECONDS).
125
+
126
+ Raises:
127
+ ProtocolConfigurationError: If circuit breaker parameters are invalid.
128
+ """
129
+ self._pool = pool
130
+ self._query_timeout = query_timeout or self.DEFAULT_QUERY_TIMEOUT_SECONDS
131
+
132
+ # Initialize circuit breaker mixin
133
+ self._init_circuit_breaker(
134
+ threshold=circuit_breaker_threshold,
135
+ reset_timeout=circuit_breaker_reset_timeout,
136
+ service_name="agent-actions-postgres-writer",
137
+ transport_type=EnumInfraTransportType.DATABASE,
138
+ half_open_successes=circuit_breaker_half_open_successes,
139
+ )
140
+
141
+ logger.info(
142
+ "WriterAgentActionsPostgres initialized",
143
+ extra={
144
+ "circuit_breaker_threshold": circuit_breaker_threshold,
145
+ "circuit_breaker_reset_timeout": circuit_breaker_reset_timeout,
146
+ "circuit_breaker_half_open_successes": circuit_breaker_half_open_successes,
147
+ "query_timeout": self._query_timeout,
148
+ },
149
+ )
150
+
151
+ @staticmethod
152
+ def _serialize_json(value: Mapping[str, object] | None) -> str | None:
153
+ """Serialize a mapping to JSON string for JSONB columns.
154
+
155
+ Args:
156
+ value: Mapping to serialize, or None.
157
+
158
+ Returns:
159
+ JSON string if value is not None, otherwise None.
160
+ """
161
+ if value is None:
162
+ return None
163
+ return json.dumps(dict(value))
164
+
165
+ @staticmethod
166
+ def _serialize_list(value: list[str] | None) -> str | None:
167
+ """Serialize a list to JSON string for JSONB/array columns.
168
+
169
+ Args:
170
+ value: List to serialize, or None.
171
+
172
+ Returns:
173
+ JSON string if value is not None, otherwise None.
174
+ """
175
+ if value is None:
176
+ return None
177
+ return json.dumps(value)
178
+
179
+ async def write_agent_actions(
180
+ self,
181
+ events: list[ModelAgentAction],
182
+ correlation_id: UUID | None = None,
183
+ ) -> int:
184
+ """Write batch of agent action events to PostgreSQL.
185
+
186
+ Uses INSERT ... ON CONFLICT (id) DO NOTHING for idempotency.
187
+ Append-only audit log - duplicates are silently ignored.
188
+
189
+ Args:
190
+ events: List of agent action events to write.
191
+ correlation_id: Optional correlation ID for tracing.
192
+
193
+ Returns:
194
+ Count of events in the batch (executemany doesn't return affected rows).
195
+
196
+ Raises:
197
+ InfraConnectionError: If database connection fails.
198
+ InfraTimeoutError: If operation times out.
199
+ InfraUnavailableError: If circuit breaker is open.
200
+ """
201
+ if not events:
202
+ return 0
203
+
204
+ op_correlation_id = correlation_id or uuid4()
205
+
206
+ # Check circuit breaker
207
+ async with self._circuit_breaker_lock:
208
+ await self._check_circuit_breaker(
209
+ operation="write_agent_actions",
210
+ correlation_id=op_correlation_id,
211
+ )
212
+
213
+ context = ModelInfraErrorContext(
214
+ transport_type=EnumInfraTransportType.DATABASE,
215
+ operation="write_agent_actions",
216
+ target_name="agent_actions",
217
+ correlation_id=op_correlation_id,
218
+ )
219
+
220
+ sql = """
221
+ INSERT INTO agent_actions (
222
+ id, correlation_id, agent_name, action_type, action_name,
223
+ created_at, status, duration_ms, result, error_message, metadata
224
+ )
225
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
226
+ ON CONFLICT (id) DO NOTHING
227
+ """
228
+
229
+ try:
230
+ async with self._pool.acquire() as conn:
231
+ await conn.executemany(
232
+ sql,
233
+ [
234
+ (
235
+ e.id,
236
+ e.correlation_id,
237
+ e.agent_name,
238
+ e.action_type,
239
+ e.action_name,
240
+ e.created_at,
241
+ e.status,
242
+ e.duration_ms,
243
+ e.result,
244
+ e.error_message,
245
+ self._serialize_json(e.metadata),
246
+ )
247
+ for e in events
248
+ ],
249
+ )
250
+
251
+ # Record success
252
+ async with self._circuit_breaker_lock:
253
+ await self._reset_circuit_breaker()
254
+
255
+ logger.debug(
256
+ "Wrote agent actions batch",
257
+ extra={
258
+ "count": len(events),
259
+ "correlation_id": str(op_correlation_id),
260
+ },
261
+ )
262
+ return len(events)
263
+
264
+ except asyncpg.QueryCanceledError as e:
265
+ async with self._circuit_breaker_lock:
266
+ await self._record_circuit_failure(
267
+ operation="write_agent_actions",
268
+ correlation_id=op_correlation_id,
269
+ )
270
+ raise InfraTimeoutError(
271
+ "Write agent actions timed out",
272
+ context=ModelTimeoutErrorContext(
273
+ transport_type=context.transport_type,
274
+ operation=context.operation,
275
+ target_name=context.target_name,
276
+ correlation_id=context.correlation_id,
277
+ timeout_seconds=self._query_timeout,
278
+ ),
279
+ ) from e
280
+ except asyncpg.PostgresConnectionError as e:
281
+ async with self._circuit_breaker_lock:
282
+ await self._record_circuit_failure(
283
+ operation="write_agent_actions",
284
+ correlation_id=op_correlation_id,
285
+ )
286
+ raise InfraConnectionError(
287
+ "Database connection failed during write_agent_actions",
288
+ context=context,
289
+ ) from e
290
+ except asyncpg.PostgresError as e:
291
+ async with self._circuit_breaker_lock:
292
+ await self._record_circuit_failure(
293
+ operation="write_agent_actions",
294
+ correlation_id=op_correlation_id,
295
+ )
296
+ raise RuntimeHostError(
297
+ f"Database error during write_agent_actions: {type(e).__name__}",
298
+ context=context,
299
+ ) from e
300
+
301
+ async def write_routing_decisions(
302
+ self,
303
+ events: list[ModelRoutingDecision],
304
+ correlation_id: UUID | None = None,
305
+ ) -> int:
306
+ """Write batch of routing decision events to PostgreSQL.
307
+
308
+ Uses INSERT ... ON CONFLICT (id) DO NOTHING for idempotency.
309
+ Append-only audit log - duplicates are silently ignored.
310
+
311
+ Args:
312
+ events: List of routing decision events to write.
313
+ correlation_id: Optional correlation ID for tracing.
314
+
315
+ Returns:
316
+ Count of events in the batch.
317
+
318
+ Raises:
319
+ InfraConnectionError: If database connection fails.
320
+ InfraTimeoutError: If operation times out.
321
+ InfraUnavailableError: If circuit breaker is open.
322
+ """
323
+ if not events:
324
+ return 0
325
+
326
+ op_correlation_id = correlation_id or uuid4()
327
+
328
+ # Check circuit breaker
329
+ async with self._circuit_breaker_lock:
330
+ await self._check_circuit_breaker(
331
+ operation="write_routing_decisions",
332
+ correlation_id=op_correlation_id,
333
+ )
334
+
335
+ context = ModelInfraErrorContext(
336
+ transport_type=EnumInfraTransportType.DATABASE,
337
+ operation="write_routing_decisions",
338
+ target_name="agent_routing_decisions",
339
+ correlation_id=op_correlation_id,
340
+ )
341
+
342
+ sql = """
343
+ INSERT INTO agent_routing_decisions (
344
+ id, correlation_id, selected_agent, confidence_score, created_at,
345
+ request_type, alternatives, routing_reason, domain, metadata
346
+ )
347
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
348
+ ON CONFLICT (id) DO NOTHING
349
+ """
350
+
351
+ try:
352
+ async with self._pool.acquire() as conn:
353
+ await conn.executemany(
354
+ sql,
355
+ [
356
+ (
357
+ e.id,
358
+ e.correlation_id,
359
+ e.selected_agent,
360
+ e.confidence_score,
361
+ e.created_at,
362
+ e.request_type,
363
+ self._serialize_list(e.alternatives),
364
+ e.routing_reason,
365
+ e.domain,
366
+ self._serialize_json(e.metadata),
367
+ )
368
+ for e in events
369
+ ],
370
+ )
371
+
372
+ # Record success
373
+ async with self._circuit_breaker_lock:
374
+ await self._reset_circuit_breaker()
375
+
376
+ logger.debug(
377
+ "Wrote routing decisions batch",
378
+ extra={
379
+ "count": len(events),
380
+ "correlation_id": str(op_correlation_id),
381
+ },
382
+ )
383
+ return len(events)
384
+
385
+ except asyncpg.QueryCanceledError as e:
386
+ async with self._circuit_breaker_lock:
387
+ await self._record_circuit_failure(
388
+ operation="write_routing_decisions",
389
+ correlation_id=op_correlation_id,
390
+ )
391
+ raise InfraTimeoutError(
392
+ "Write routing decisions timed out",
393
+ context=ModelTimeoutErrorContext(
394
+ transport_type=context.transport_type,
395
+ operation=context.operation,
396
+ target_name=context.target_name,
397
+ correlation_id=context.correlation_id,
398
+ timeout_seconds=self._query_timeout,
399
+ ),
400
+ ) from e
401
+ except asyncpg.PostgresConnectionError as e:
402
+ async with self._circuit_breaker_lock:
403
+ await self._record_circuit_failure(
404
+ operation="write_routing_decisions",
405
+ correlation_id=op_correlation_id,
406
+ )
407
+ raise InfraConnectionError(
408
+ "Database connection failed during write_routing_decisions",
409
+ context=context,
410
+ ) from e
411
+ except asyncpg.PostgresError as e:
412
+ async with self._circuit_breaker_lock:
413
+ await self._record_circuit_failure(
414
+ operation="write_routing_decisions",
415
+ correlation_id=op_correlation_id,
416
+ )
417
+ raise RuntimeHostError(
418
+ f"Database error during write_routing_decisions: {type(e).__name__}",
419
+ context=context,
420
+ ) from e
421
+
422
+ async def write_transformation_events(
423
+ self,
424
+ events: list[ModelTransformationEvent],
425
+ correlation_id: UUID | None = None,
426
+ ) -> int:
427
+ """Write batch of transformation events to PostgreSQL.
428
+
429
+ Uses INSERT ... ON CONFLICT (id) DO NOTHING for idempotency.
430
+ Append-only audit log - duplicates are silently ignored.
431
+
432
+ Args:
433
+ events: List of transformation events to write.
434
+ correlation_id: Optional correlation ID for tracing.
435
+
436
+ Returns:
437
+ Count of events in the batch.
438
+
439
+ Raises:
440
+ InfraConnectionError: If database connection fails.
441
+ InfraTimeoutError: If operation times out.
442
+ InfraUnavailableError: If circuit breaker is open.
443
+ """
444
+ if not events:
445
+ return 0
446
+
447
+ op_correlation_id = correlation_id or uuid4()
448
+
449
+ # Check circuit breaker
450
+ async with self._circuit_breaker_lock:
451
+ await self._check_circuit_breaker(
452
+ operation="write_transformation_events",
453
+ correlation_id=op_correlation_id,
454
+ )
455
+
456
+ context = ModelInfraErrorContext(
457
+ transport_type=EnumInfraTransportType.DATABASE,
458
+ operation="write_transformation_events",
459
+ target_name="agent_transformation_events",
460
+ correlation_id=op_correlation_id,
461
+ )
462
+
463
+ sql = """
464
+ INSERT INTO agent_transformation_events (
465
+ id, correlation_id, source_agent, target_agent, created_at,
466
+ trigger, context, metadata
467
+ )
468
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
469
+ ON CONFLICT (id) DO NOTHING
470
+ """
471
+
472
+ try:
473
+ async with self._pool.acquire() as conn:
474
+ await conn.executemany(
475
+ sql,
476
+ [
477
+ (
478
+ e.id,
479
+ e.correlation_id,
480
+ e.source_agent,
481
+ e.target_agent,
482
+ e.created_at,
483
+ e.trigger,
484
+ e.context,
485
+ self._serialize_json(e.metadata),
486
+ )
487
+ for e in events
488
+ ],
489
+ )
490
+
491
+ # Record success
492
+ async with self._circuit_breaker_lock:
493
+ await self._reset_circuit_breaker()
494
+
495
+ logger.debug(
496
+ "Wrote transformation events batch",
497
+ extra={
498
+ "count": len(events),
499
+ "correlation_id": str(op_correlation_id),
500
+ },
501
+ )
502
+ return len(events)
503
+
504
+ except asyncpg.QueryCanceledError as e:
505
+ async with self._circuit_breaker_lock:
506
+ await self._record_circuit_failure(
507
+ operation="write_transformation_events",
508
+ correlation_id=op_correlation_id,
509
+ )
510
+ raise InfraTimeoutError(
511
+ "Write transformation events timed out",
512
+ context=ModelTimeoutErrorContext(
513
+ transport_type=context.transport_type,
514
+ operation=context.operation,
515
+ target_name=context.target_name,
516
+ correlation_id=context.correlation_id,
517
+ timeout_seconds=self._query_timeout,
518
+ ),
519
+ ) from e
520
+ except asyncpg.PostgresConnectionError as e:
521
+ async with self._circuit_breaker_lock:
522
+ await self._record_circuit_failure(
523
+ operation="write_transformation_events",
524
+ correlation_id=op_correlation_id,
525
+ )
526
+ raise InfraConnectionError(
527
+ "Database connection failed during write_transformation_events",
528
+ context=context,
529
+ ) from e
530
+ except asyncpg.PostgresError as e:
531
+ async with self._circuit_breaker_lock:
532
+ await self._record_circuit_failure(
533
+ operation="write_transformation_events",
534
+ correlation_id=op_correlation_id,
535
+ )
536
+ raise RuntimeHostError(
537
+ f"Database error during write_transformation_events: {type(e).__name__}",
538
+ context=context,
539
+ ) from e
540
+
541
+ async def write_performance_metrics(
542
+ self,
543
+ events: list[ModelPerformanceMetric],
544
+ correlation_id: UUID | None = None,
545
+ ) -> int:
546
+ """Write batch of performance metrics to PostgreSQL.
547
+
548
+ Uses INSERT ... ON CONFLICT (id) DO NOTHING for idempotency.
549
+ Append-only time-series - duplicates are silently ignored.
550
+
551
+ Args:
552
+ events: List of performance metric events to write.
553
+ correlation_id: Optional correlation ID for tracing.
554
+
555
+ Returns:
556
+ Count of events in the batch.
557
+
558
+ Raises:
559
+ InfraConnectionError: If database connection fails.
560
+ InfraTimeoutError: If operation times out.
561
+ InfraUnavailableError: If circuit breaker is open.
562
+ """
563
+ if not events:
564
+ return 0
565
+
566
+ op_correlation_id = correlation_id or uuid4()
567
+
568
+ # Check circuit breaker
569
+ async with self._circuit_breaker_lock:
570
+ await self._check_circuit_breaker(
571
+ operation="write_performance_metrics",
572
+ correlation_id=op_correlation_id,
573
+ )
574
+
575
+ context = ModelInfraErrorContext(
576
+ transport_type=EnumInfraTransportType.DATABASE,
577
+ operation="write_performance_metrics",
578
+ target_name="router_performance_metrics",
579
+ correlation_id=op_correlation_id,
580
+ )
581
+
582
+ sql = """
583
+ INSERT INTO router_performance_metrics (
584
+ id, metric_name, metric_value, created_at,
585
+ correlation_id, unit, agent_name, labels, metadata
586
+ )
587
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
588
+ ON CONFLICT (id) DO NOTHING
589
+ """
590
+
591
+ try:
592
+ async with self._pool.acquire() as conn:
593
+ await conn.executemany(
594
+ sql,
595
+ [
596
+ (
597
+ e.id,
598
+ e.metric_name,
599
+ e.metric_value,
600
+ e.created_at,
601
+ e.correlation_id,
602
+ e.unit,
603
+ e.agent_name,
604
+ self._serialize_json(e.labels),
605
+ self._serialize_json(e.metadata),
606
+ )
607
+ for e in events
608
+ ],
609
+ )
610
+
611
+ # Record success
612
+ async with self._circuit_breaker_lock:
613
+ await self._reset_circuit_breaker()
614
+
615
+ logger.debug(
616
+ "Wrote performance metrics batch",
617
+ extra={
618
+ "count": len(events),
619
+ "correlation_id": str(op_correlation_id),
620
+ },
621
+ )
622
+ return len(events)
623
+
624
+ except asyncpg.QueryCanceledError as e:
625
+ async with self._circuit_breaker_lock:
626
+ await self._record_circuit_failure(
627
+ operation="write_performance_metrics",
628
+ correlation_id=op_correlation_id,
629
+ )
630
+ raise InfraTimeoutError(
631
+ "Write performance metrics timed out",
632
+ context=ModelTimeoutErrorContext(
633
+ transport_type=context.transport_type,
634
+ operation=context.operation,
635
+ target_name=context.target_name,
636
+ correlation_id=context.correlation_id,
637
+ timeout_seconds=self._query_timeout,
638
+ ),
639
+ ) from e
640
+ except asyncpg.PostgresConnectionError as e:
641
+ async with self._circuit_breaker_lock:
642
+ await self._record_circuit_failure(
643
+ operation="write_performance_metrics",
644
+ correlation_id=op_correlation_id,
645
+ )
646
+ raise InfraConnectionError(
647
+ "Database connection failed during write_performance_metrics",
648
+ context=context,
649
+ ) from e
650
+ except asyncpg.PostgresError as e:
651
+ async with self._circuit_breaker_lock:
652
+ await self._record_circuit_failure(
653
+ operation="write_performance_metrics",
654
+ correlation_id=op_correlation_id,
655
+ )
656
+ raise RuntimeHostError(
657
+ f"Database error during write_performance_metrics: {type(e).__name__}",
658
+ context=context,
659
+ ) from e
660
+
661
+ async def write_detection_failures(
662
+ self,
663
+ events: list[ModelDetectionFailure],
664
+ correlation_id: UUID | None = None,
665
+ ) -> int:
666
+ """Write batch of detection failure events to PostgreSQL.
667
+
668
+ Uses INSERT ... ON CONFLICT (correlation_id) DO NOTHING for idempotency.
669
+ One failure per correlation - duplicates are silently ignored.
670
+
671
+ Args:
672
+ events: List of detection failure events to write.
673
+ correlation_id: Optional correlation ID for tracing.
674
+
675
+ Returns:
676
+ Count of events in the batch.
677
+
678
+ Raises:
679
+ InfraConnectionError: If database connection fails.
680
+ InfraTimeoutError: If operation times out.
681
+ InfraUnavailableError: If circuit breaker is open.
682
+ """
683
+ if not events:
684
+ return 0
685
+
686
+ op_correlation_id = correlation_id or uuid4()
687
+
688
+ # Check circuit breaker
689
+ async with self._circuit_breaker_lock:
690
+ await self._check_circuit_breaker(
691
+ operation="write_detection_failures",
692
+ correlation_id=op_correlation_id,
693
+ )
694
+
695
+ context = ModelInfraErrorContext(
696
+ transport_type=EnumInfraTransportType.DATABASE,
697
+ operation="write_detection_failures",
698
+ target_name="agent_detection_failures",
699
+ correlation_id=op_correlation_id,
700
+ )
701
+
702
+ sql = """
703
+ INSERT INTO agent_detection_failures (
704
+ correlation_id, failure_reason, created_at,
705
+ request_summary, attempted_patterns, fallback_used, error_code, metadata
706
+ )
707
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
708
+ ON CONFLICT (correlation_id) DO NOTHING
709
+ """
710
+
711
+ try:
712
+ async with self._pool.acquire() as conn:
713
+ await conn.executemany(
714
+ sql,
715
+ [
716
+ (
717
+ e.correlation_id,
718
+ e.failure_reason,
719
+ e.created_at,
720
+ e.request_summary,
721
+ self._serialize_list(e.attempted_patterns),
722
+ e.fallback_used,
723
+ e.error_code,
724
+ self._serialize_json(e.metadata),
725
+ )
726
+ for e in events
727
+ ],
728
+ )
729
+
730
+ # Record success
731
+ async with self._circuit_breaker_lock:
732
+ await self._reset_circuit_breaker()
733
+
734
+ logger.debug(
735
+ "Wrote detection failures batch",
736
+ extra={
737
+ "count": len(events),
738
+ "correlation_id": str(op_correlation_id),
739
+ },
740
+ )
741
+ return len(events)
742
+
743
+ except asyncpg.QueryCanceledError as e:
744
+ async with self._circuit_breaker_lock:
745
+ await self._record_circuit_failure(
746
+ operation="write_detection_failures",
747
+ correlation_id=op_correlation_id,
748
+ )
749
+ raise InfraTimeoutError(
750
+ "Write detection failures timed out",
751
+ context=ModelTimeoutErrorContext(
752
+ transport_type=context.transport_type,
753
+ operation=context.operation,
754
+ target_name=context.target_name,
755
+ correlation_id=context.correlation_id,
756
+ timeout_seconds=self._query_timeout,
757
+ ),
758
+ ) from e
759
+ except asyncpg.PostgresConnectionError as e:
760
+ async with self._circuit_breaker_lock:
761
+ await self._record_circuit_failure(
762
+ operation="write_detection_failures",
763
+ correlation_id=op_correlation_id,
764
+ )
765
+ raise InfraConnectionError(
766
+ "Database connection failed during write_detection_failures",
767
+ context=context,
768
+ ) from e
769
+ except asyncpg.PostgresError as e:
770
+ async with self._circuit_breaker_lock:
771
+ await self._record_circuit_failure(
772
+ operation="write_detection_failures",
773
+ correlation_id=op_correlation_id,
774
+ )
775
+ raise RuntimeHostError(
776
+ f"Database error during write_detection_failures: {type(e).__name__}",
777
+ context=context,
778
+ ) from e
779
+
780
+ async def write_execution_logs(
781
+ self,
782
+ events: list[ModelExecutionLog],
783
+ correlation_id: UUID | None = None,
784
+ ) -> int:
785
+ """Write batch of execution log events to PostgreSQL.
786
+
787
+ Uses INSERT ... ON CONFLICT (execution_id) DO UPDATE for lifecycle tracking.
788
+ Supports status transitions (started -> running -> completed/failed).
789
+ Updates status, completed_at, duration_ms, quality_score, error_message,
790
+ error_type, metadata, and updated_at on conflict.
791
+
792
+ Args:
793
+ events: List of execution log events to write.
794
+ correlation_id: Optional correlation ID for tracing.
795
+
796
+ Returns:
797
+ Count of events in the batch.
798
+
799
+ Raises:
800
+ InfraConnectionError: If database connection fails.
801
+ InfraTimeoutError: If operation times out.
802
+ InfraUnavailableError: If circuit breaker is open.
803
+ """
804
+ if not events:
805
+ return 0
806
+
807
+ op_correlation_id = correlation_id or uuid4()
808
+
809
+ # Check circuit breaker
810
+ async with self._circuit_breaker_lock:
811
+ await self._check_circuit_breaker(
812
+ operation="write_execution_logs",
813
+ correlation_id=op_correlation_id,
814
+ )
815
+
816
+ context = ModelInfraErrorContext(
817
+ transport_type=EnumInfraTransportType.DATABASE,
818
+ operation="write_execution_logs",
819
+ target_name="agent_execution_logs",
820
+ correlation_id=op_correlation_id,
821
+ )
822
+
823
+ sql = """
824
+ INSERT INTO agent_execution_logs (
825
+ execution_id, correlation_id, agent_name, status,
826
+ created_at, updated_at, started_at, completed_at,
827
+ duration_ms, exit_code, error_message, input_summary,
828
+ output_summary, metadata
829
+ )
830
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
831
+ ON CONFLICT (execution_id) DO UPDATE SET
832
+ status = EXCLUDED.status,
833
+ completed_at = EXCLUDED.completed_at,
834
+ duration_ms = EXCLUDED.duration_ms,
835
+ exit_code = EXCLUDED.exit_code,
836
+ error_message = EXCLUDED.error_message,
837
+ output_summary = EXCLUDED.output_summary,
838
+ metadata = EXCLUDED.metadata,
839
+ updated_at = NOW()
840
+ """
841
+
842
+ try:
843
+ async with self._pool.acquire() as conn:
844
+ await conn.executemany(
845
+ sql,
846
+ [
847
+ (
848
+ e.execution_id,
849
+ e.correlation_id,
850
+ e.agent_name,
851
+ e.status,
852
+ e.created_at,
853
+ e.updated_at,
854
+ e.started_at,
855
+ e.completed_at,
856
+ e.duration_ms,
857
+ e.exit_code,
858
+ e.error_message,
859
+ e.input_summary,
860
+ e.output_summary,
861
+ self._serialize_json(e.metadata),
862
+ )
863
+ for e in events
864
+ ],
865
+ )
866
+
867
+ # Record success
868
+ async with self._circuit_breaker_lock:
869
+ await self._reset_circuit_breaker()
870
+
871
+ logger.debug(
872
+ "Wrote execution logs batch",
873
+ extra={
874
+ "count": len(events),
875
+ "correlation_id": str(op_correlation_id),
876
+ },
877
+ )
878
+ return len(events)
879
+
880
+ except asyncpg.QueryCanceledError as e:
881
+ async with self._circuit_breaker_lock:
882
+ await self._record_circuit_failure(
883
+ operation="write_execution_logs",
884
+ correlation_id=op_correlation_id,
885
+ )
886
+ raise InfraTimeoutError(
887
+ "Write execution logs timed out",
888
+ context=ModelTimeoutErrorContext(
889
+ transport_type=context.transport_type,
890
+ operation=context.operation,
891
+ target_name=context.target_name,
892
+ correlation_id=context.correlation_id,
893
+ timeout_seconds=self._query_timeout,
894
+ ),
895
+ ) from e
896
+ except asyncpg.PostgresConnectionError as e:
897
+ async with self._circuit_breaker_lock:
898
+ await self._record_circuit_failure(
899
+ operation="write_execution_logs",
900
+ correlation_id=op_correlation_id,
901
+ )
902
+ raise InfraConnectionError(
903
+ "Database connection failed during write_execution_logs",
904
+ context=context,
905
+ ) from e
906
+ except asyncpg.PostgresError as e:
907
+ async with self._circuit_breaker_lock:
908
+ await self._record_circuit_failure(
909
+ operation="write_execution_logs",
910
+ correlation_id=op_correlation_id,
911
+ )
912
+ raise RuntimeHostError(
913
+ f"Database error during write_execution_logs: {type(e).__name__}",
914
+ context=context,
915
+ ) from e
916
+
917
+ def get_circuit_breaker_state(self) -> dict[str, JsonType]:
918
+ """Return current circuit breaker state for health checks.
919
+
920
+ Returns:
921
+ Dict containing circuit breaker state information.
922
+ """
923
+ return self._get_circuit_breaker_state()
924
+
925
+
926
+ __all__ = ["WriterAgentActionsPostgres"]