cloud-dog-api-kit 0.13.0__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.
- cloud_dog_api_kit/__init__.py +170 -0
- cloud_dog_api_kit/a2a/__init__.py +53 -0
- cloud_dog_api_kit/a2a/card.py +138 -0
- cloud_dog_api_kit/a2a/events.py +1123 -0
- cloud_dog_api_kit/a2a/gateway.py +105 -0
- cloud_dog_api_kit/a2a/skill_audit.py +107 -0
- cloud_dog_api_kit/auth/__init__.py +35 -0
- cloud_dog_api_kit/auth/dependency.py +121 -0
- cloud_dog_api_kit/auth/rbac.py +107 -0
- cloud_dog_api_kit/auth/service_auth.py +54 -0
- cloud_dog_api_kit/clients/__init__.py +29 -0
- cloud_dog_api_kit/clients/circuit_breaker.py +39 -0
- cloud_dog_api_kit/clients/http_client.py +127 -0
- cloud_dog_api_kit/clients/retry.py +83 -0
- cloud_dog_api_kit/compat/__init__.py +37 -0
- cloud_dog_api_kit/compat/envelope.py +120 -0
- cloud_dog_api_kit/compat/profile.py +102 -0
- cloud_dog_api_kit/compat/routes.py +90 -0
- cloud_dog_api_kit/config.py +54 -0
- cloud_dog_api_kit/correlation/__init__.py +50 -0
- cloud_dog_api_kit/correlation/context.py +118 -0
- cloud_dog_api_kit/correlation/middleware.py +133 -0
- cloud_dog_api_kit/envelopes/__init__.py +37 -0
- cloud_dog_api_kit/envelopes/error.py +87 -0
- cloud_dog_api_kit/envelopes/success.py +84 -0
- cloud_dog_api_kit/errors/__init__.py +51 -0
- cloud_dog_api_kit/errors/exceptions.py +184 -0
- cloud_dog_api_kit/errors/handler.py +102 -0
- cloud_dog_api_kit/errors/taxonomy.py +62 -0
- cloud_dog_api_kit/factory.py +157 -0
- cloud_dog_api_kit/idempotency/__init__.py +28 -0
- cloud_dog_api_kit/idempotency/middleware.py +118 -0
- cloud_dog_api_kit/idempotency/store.py +100 -0
- cloud_dog_api_kit/lifecycle/__init__.py +39 -0
- cloud_dog_api_kit/lifecycle/hooks.py +75 -0
- cloud_dog_api_kit/lifecycle/shutdown.py +178 -0
- cloud_dog_api_kit/mcp/__init__.py +122 -0
- cloud_dog_api_kit/mcp/async_jobs.py +126 -0
- cloud_dog_api_kit/mcp/client_sdk.py +235 -0
- cloud_dog_api_kit/mcp/client_transport/__init__.py +47 -0
- cloud_dog_api_kit/mcp/client_transport/base.py +98 -0
- cloud_dog_api_kit/mcp/client_transport/exceptions.py +37 -0
- cloud_dog_api_kit/mcp/client_transport/http_jsonrpc.py +405 -0
- cloud_dog_api_kit/mcp/client_transport/legacy_sse.py +320 -0
- cloud_dog_api_kit/mcp/client_transport/stdio.py +322 -0
- cloud_dog_api_kit/mcp/client_transport/streamable_http.py +748 -0
- cloud_dog_api_kit/mcp/contract.py +113 -0
- cloud_dog_api_kit/mcp/error_mapper.py +84 -0
- cloud_dog_api_kit/mcp/gateway.py +117 -0
- cloud_dog_api_kit/mcp/legacy_sse.py +129 -0
- cloud_dog_api_kit/mcp/session.py +96 -0
- cloud_dog_api_kit/mcp/sync_handler.py +269 -0
- cloud_dog_api_kit/mcp/tool_audit.py +136 -0
- cloud_dog_api_kit/mcp/tool_router.py +180 -0
- cloud_dog_api_kit/mcp/transport.py +1041 -0
- cloud_dog_api_kit/middleware/__init__.py +39 -0
- cloud_dog_api_kit/middleware/cors.py +74 -0
- cloud_dog_api_kit/middleware/logging.py +98 -0
- cloud_dog_api_kit/middleware/request_size_limit.py +86 -0
- cloud_dog_api_kit/middleware/timeout.py +78 -0
- cloud_dog_api_kit/middleware/timing.py +52 -0
- cloud_dog_api_kit/openapi/__init__.py +30 -0
- cloud_dog_api_kit/openapi/customise.py +69 -0
- cloud_dog_api_kit/openapi/route.py +46 -0
- cloud_dog_api_kit/routers/__init__.py +41 -0
- cloud_dog_api_kit/routers/crud.py +173 -0
- cloud_dog_api_kit/routers/health.py +160 -0
- cloud_dog_api_kit/routers/jobs.py +69 -0
- cloud_dog_api_kit/routers/version.py +46 -0
- cloud_dog_api_kit/schemas/__init__.py +36 -0
- cloud_dog_api_kit/schemas/envelopes.py +37 -0
- cloud_dog_api_kit/schemas/filters.py +103 -0
- cloud_dog_api_kit/schemas/pagination.py +148 -0
- cloud_dog_api_kit/streaming/__init__.py +28 -0
- cloud_dog_api_kit/streaming/events.py +47 -0
- cloud_dog_api_kit/streaming/jsonl.py +68 -0
- cloud_dog_api_kit/streaming/sse.py +102 -0
- cloud_dog_api_kit/testing/__init__.py +46 -0
- cloud_dog_api_kit/testing/conformance.py +156 -0
- cloud_dog_api_kit/testing/fixtures.py +90 -0
- cloud_dog_api_kit/testing/flows/__init__.py +32 -0
- cloud_dog_api_kit/testing/flows/auth_flow.py +41 -0
- cloud_dog_api_kit/testing/flows/crud_flow.py +50 -0
- cloud_dog_api_kit/testing/flows/job_flow.py +42 -0
- cloud_dog_api_kit/testing/flows/streaming_flow.py +42 -0
- cloud_dog_api_kit/traceability_ids.py +84 -0
- cloud_dog_api_kit/versioning/__init__.py +30 -0
- cloud_dog_api_kit/versioning/header.py +52 -0
- cloud_dog_api_kit/web/__init__.py +7 -0
- cloud_dog_api_kit/web/proxy.py +222 -0
- cloud_dog_api_kit/webhook/__init__.py +29 -0
- cloud_dog_api_kit/webhook/signature.py +149 -0
- cloud_dog_api_kit-0.13.0.dist-info/METADATA +27 -0
- cloud_dog_api_kit-0.13.0.dist-info/RECORD +98 -0
- cloud_dog_api_kit-0.13.0.dist-info/WHEEL +4 -0
- cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENCE +190 -0
- cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENSE +176 -0
- cloud_dog_api_kit-0.13.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
# Copyright 2026 Cloud-Dog, Viewdeck Engineering Limited
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# cloud_dog_api_kit — A2A change-event broadcast primitive
|
|
16
|
+
#
|
|
17
|
+
# Licence: Apache-2.0 — Cloud-Dog AI Platform
|
|
18
|
+
# Owner: Cloud-Dog, Viewdeck Engineering Limited
|
|
19
|
+
# Description: Platform primitive for broadcasting service-side configuration
|
|
20
|
+
# change events to downstream subscribers via SSE. Satisfies the shared
|
|
21
|
+
# CFG-06 (A2A broadcast) template requirement across services. Closes
|
|
22
|
+
# W28A-1002-INVESTIGATE finding that the platform previously provided no
|
|
23
|
+
# shared primitive, forcing each service to implement bespoke SSE/websocket
|
|
24
|
+
# pipelines.
|
|
25
|
+
# Related requirements: CFG-06 (service-template), FR17.1
|
|
26
|
+
# Related architecture: SA1
|
|
27
|
+
# Related tests: UT_A2AEvents
|
|
28
|
+
# Recent Change History:
|
|
29
|
+
# - 2026-04-23: W28A-1002-APPLY-A — new submodule added (v0.10.0).
|
|
30
|
+
# - 2026-04-24: W28A-1002-EXTEND-R1 — common-package additions (v0.11.0):
|
|
31
|
+
# PersistentEventBroadcaster (file-backed JSONL + WAL + replay),
|
|
32
|
+
# HTTPIngestAdapter (cross-process publisher REST ingress),
|
|
33
|
+
# WebSocketAdapter (WS subscriber surface alongside SSE),
|
|
34
|
+
# RESTPollAdapter (cursor-paginated JSON poll surface).
|
|
35
|
+
# 0.10.0 API (ConfigChangeEvent + EventBroadcaster + InMemoryEventBroadcaster
|
|
36
|
+
# + create_a2a_events_router) preserved unchanged — file-mcp 1938743 adopt
|
|
37
|
+
# must continue to work without modification.
|
|
38
|
+
# - 2026-04-23: W28A-1002-EXTEND-R1b — configurable presentation-layer
|
|
39
|
+
# transforms (v0.12.0). ALL ADDITIONS ARE OPTIONAL KWARGS WITH DEFAULTS
|
|
40
|
+
# THAT PRESERVE 0.11.0 BEHAVIOUR EXACTLY (full backward-compat):
|
|
41
|
+
# * ConfigChangeEvent.to_dict(alias_map=None) — rename fields for
|
|
42
|
+
# services with legacy external contracts (e.g., index-retriever's
|
|
43
|
+
# entity_type/entity_id/created_at).
|
|
44
|
+
# * RESTPollAdapter(envelope_shape="with_cursor"|"events_only",
|
|
45
|
+
# field_mapping=None, order="oldest_first"|"newest_first",
|
|
46
|
+
# event_id_format="int"|"uuid_string") — presentation-layer
|
|
47
|
+
# transforms over the canonical envelope.
|
|
48
|
+
# * WebSocketAdapter(field_mapping=None) — rename fields in WS frames.
|
|
49
|
+
# * HTTPIngestAdapter(accept_legacy_fields=False) — accept legacy
|
|
50
|
+
# body-field aliases on ingest.
|
|
51
|
+
# The canonical envelope (PS-72 §A2A-change-events) remains AUTHORITATIVE;
|
|
52
|
+
# configurable mappings are PRESENTATION-LAYER transforms, not alternate
|
|
53
|
+
# envelopes. Services may preserve external contracts without diverging
|
|
54
|
+
# from the canonical platform representation internally.
|
|
55
|
+
|
|
56
|
+
"""A2A configuration-change-event broadcast primitive.
|
|
57
|
+
|
|
58
|
+
Usage in any service's A2A server::
|
|
59
|
+
|
|
60
|
+
from cloud_dog_api_kit.a2a.events import (
|
|
61
|
+
ConfigChangeEvent,
|
|
62
|
+
InMemoryEventBroadcaster,
|
|
63
|
+
create_a2a_events_router,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
broadcaster = InMemoryEventBroadcaster()
|
|
67
|
+
app.include_router(create_a2a_events_router(broadcaster))
|
|
68
|
+
|
|
69
|
+
# On a CRUD operation:
|
|
70
|
+
await broadcaster.publish(
|
|
71
|
+
ConfigChangeEvent(
|
|
72
|
+
service="file-mcp-server",
|
|
73
|
+
resource="user",
|
|
74
|
+
action="create",
|
|
75
|
+
identifier=user_id,
|
|
76
|
+
actor=principal_id,
|
|
77
|
+
after=user_mapping,
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
The router exposes:
|
|
82
|
+
|
|
83
|
+
- ``GET {base_path}`` — SSE stream of live events. Accepts ``?after_id=N`` to
|
|
84
|
+
replay recent history before streaming live.
|
|
85
|
+
- ``GET {base_path}/history`` — JSON list of recent events.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
from __future__ import annotations
|
|
89
|
+
|
|
90
|
+
import asyncio
|
|
91
|
+
import contextlib
|
|
92
|
+
import json
|
|
93
|
+
import os
|
|
94
|
+
import tempfile
|
|
95
|
+
from collections import deque
|
|
96
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
97
|
+
from dataclasses import asdict, dataclass, field
|
|
98
|
+
from datetime import datetime, timezone
|
|
99
|
+
from pathlib import Path
|
|
100
|
+
from typing import Any, Awaitable, Literal, Optional, Protocol, Union, runtime_checkable
|
|
101
|
+
from uuid import uuid4
|
|
102
|
+
|
|
103
|
+
from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Query, Request, status
|
|
104
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
105
|
+
from starlette.websockets import WebSocket, WebSocketDisconnect
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_UTC = timezone.utc
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _utc_now() -> datetime:
|
|
112
|
+
"""Return a timezone-aware UTC timestamp (frozen dataclasses need a callable default)."""
|
|
113
|
+
return datetime.now(_UTC)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True)
|
|
117
|
+
class ConfigChangeEvent:
|
|
118
|
+
"""A configuration change event emitted by a service CRUD surface.
|
|
119
|
+
|
|
120
|
+
Fields:
|
|
121
|
+
service: Emitting service identifier (e.g. ``"file-mcp-server"``).
|
|
122
|
+
resource: Entity type (e.g. ``"user"``, ``"group"``, ``"api_key"``, ``"profile"``).
|
|
123
|
+
action: Verb (``"create"``, ``"update"``, ``"delete"``, ``"revoke"``).
|
|
124
|
+
identifier: Entity identifier (user_id, group_id, profile_name, ...).
|
|
125
|
+
actor: Principal id who initiated the change (None = system / unknown).
|
|
126
|
+
correlation_id: Request-id / trace-id for cross-service propagation.
|
|
127
|
+
before: Previous entity state (None for create actions).
|
|
128
|
+
after: New entity state (None for delete/revoke actions).
|
|
129
|
+
outcome: ``"success"`` or ``"error"``.
|
|
130
|
+
timestamp: UTC timestamp when the event was constructed.
|
|
131
|
+
event_id: Monotonic id assigned by the broadcaster on publish (0 until published).
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
service: str
|
|
135
|
+
resource: str
|
|
136
|
+
action: str
|
|
137
|
+
identifier: str
|
|
138
|
+
actor: Optional[str] = None
|
|
139
|
+
correlation_id: Optional[str] = None
|
|
140
|
+
before: Optional[Mapping[str, Any]] = None
|
|
141
|
+
after: Optional[Mapping[str, Any]] = None
|
|
142
|
+
outcome: str = "success"
|
|
143
|
+
timestamp: datetime = field(default_factory=_utc_now)
|
|
144
|
+
event_id: int = 0
|
|
145
|
+
|
|
146
|
+
def to_dict(self, alias_map: Optional[Mapping[str, str]] = None) -> dict[str, Any]:
|
|
147
|
+
"""Render as a JSON-serialisable mapping for SSE / REST output.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
alias_map: Optional mapping of canonical-field-name → legacy-alias
|
|
151
|
+
(e.g. ``{"resource": "entity_type", "identifier": "entity_id",
|
|
152
|
+
"timestamp": "created_at"}``). When provided, each canonical
|
|
153
|
+
key is renamed to its alias in the output dict. Keys not
|
|
154
|
+
present in ``alias_map`` are emitted unchanged. The dataclass
|
|
155
|
+
itself is NOT mutated — the rename is presentation-layer only.
|
|
156
|
+
|
|
157
|
+
Added in 0.12.0 (W28A-1002-EXTEND-R1b) to support services
|
|
158
|
+
with legacy external contracts. See PS-72 §A2A-change-events:
|
|
159
|
+
the canonical envelope is authoritative; alias_map is a
|
|
160
|
+
presentation-layer transform.
|
|
161
|
+
|
|
162
|
+
When ``alias_map`` is ``None`` (default), behaviour is
|
|
163
|
+
identical to 0.11.0 (backward-compat invariant).
|
|
164
|
+
"""
|
|
165
|
+
payload = asdict(self)
|
|
166
|
+
# asdict converts datetime to datetime; normalise to ISO-8601 UTC.
|
|
167
|
+
ts = self.timestamp
|
|
168
|
+
if ts.tzinfo is None:
|
|
169
|
+
ts = ts.replace(tzinfo=_UTC)
|
|
170
|
+
payload["timestamp"] = ts.isoformat().replace("+00:00", "Z")
|
|
171
|
+
# Mapping (before/after) may contain non-JSON types — coerce opaque values to str via default below.
|
|
172
|
+
if alias_map:
|
|
173
|
+
return _apply_field_mapping(payload, alias_map)
|
|
174
|
+
return payload
|
|
175
|
+
|
|
176
|
+
def with_event_id(self, event_id: int) -> "ConfigChangeEvent":
|
|
177
|
+
"""Return a copy with a stamped event id (frozen dataclass → replace)."""
|
|
178
|
+
return ConfigChangeEvent(
|
|
179
|
+
service=self.service,
|
|
180
|
+
resource=self.resource,
|
|
181
|
+
action=self.action,
|
|
182
|
+
identifier=self.identifier,
|
|
183
|
+
actor=self.actor,
|
|
184
|
+
correlation_id=self.correlation_id,
|
|
185
|
+
before=self.before,
|
|
186
|
+
after=self.after,
|
|
187
|
+
outcome=self.outcome,
|
|
188
|
+
timestamp=self.timestamp,
|
|
189
|
+
event_id=event_id,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@runtime_checkable
|
|
194
|
+
class EventBroadcaster(Protocol):
|
|
195
|
+
"""Protocol for a service-local A2A change-event broadcaster."""
|
|
196
|
+
|
|
197
|
+
async def publish(self, event: ConfigChangeEvent) -> ConfigChangeEvent:
|
|
198
|
+
"""Assign an event id, fan out to live subscribers, retain in history.
|
|
199
|
+
|
|
200
|
+
Returns the stamped event so callers can log the id.
|
|
201
|
+
"""
|
|
202
|
+
...
|
|
203
|
+
|
|
204
|
+
def subscribe(self) -> AsyncIterator[ConfigChangeEvent]:
|
|
205
|
+
"""Return an async iterator of live events.
|
|
206
|
+
|
|
207
|
+
The caller is responsible for cancelling the iterator (e.g. via
|
|
208
|
+
``aclose()``); the broadcaster removes the subscriber queue on exit.
|
|
209
|
+
"""
|
|
210
|
+
...
|
|
211
|
+
|
|
212
|
+
def history(self, after_id: int = 0, limit: int = 100) -> list[ConfigChangeEvent]:
|
|
213
|
+
"""Return the recent history window, ids strictly greater than ``after_id``."""
|
|
214
|
+
...
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class InMemoryEventBroadcaster:
|
|
218
|
+
"""Async in-memory fan-out broadcaster with bounded history.
|
|
219
|
+
|
|
220
|
+
- History: ``collections.deque(maxlen=history_size)`` — bounded FIFO.
|
|
221
|
+
- Monotonic event-id counter (starts at 1).
|
|
222
|
+
- Subscribers: each ``subscribe()`` registers an ``asyncio.Queue``
|
|
223
|
+
with ``subscriber_queue_size`` slots; slow subscribers are coped with
|
|
224
|
+
via ``put_nowait`` drops (history retains the event for later replay).
|
|
225
|
+
|
|
226
|
+
Suitable for a single-process service. For multi-process deployments or
|
|
227
|
+
replay-across-restart, consider a persistent backend (future scope —
|
|
228
|
+
W28A-1002-APPLY-A-CONVERGE).
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
def __init__(
|
|
232
|
+
self,
|
|
233
|
+
*,
|
|
234
|
+
history_size: int = 1000,
|
|
235
|
+
subscriber_queue_size: int = 256,
|
|
236
|
+
) -> None:
|
|
237
|
+
if history_size <= 0:
|
|
238
|
+
raise ValueError("history_size must be positive")
|
|
239
|
+
if subscriber_queue_size <= 0:
|
|
240
|
+
raise ValueError("subscriber_queue_size must be positive")
|
|
241
|
+
self._history: deque[ConfigChangeEvent] = deque(maxlen=history_size)
|
|
242
|
+
self._history_size = history_size
|
|
243
|
+
self._queue_size = subscriber_queue_size
|
|
244
|
+
self._subscribers: set[asyncio.Queue[ConfigChangeEvent]] = set()
|
|
245
|
+
self._next_id = 1
|
|
246
|
+
self._lock = asyncio.Lock()
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def history_size(self) -> int:
|
|
250
|
+
"""Maximum number of events retained in memory."""
|
|
251
|
+
return self._history_size
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def subscriber_count(self) -> int:
|
|
255
|
+
"""Number of active subscribers (for observability / tests)."""
|
|
256
|
+
return len(self._subscribers)
|
|
257
|
+
|
|
258
|
+
async def publish(self, event: ConfigChangeEvent) -> ConfigChangeEvent:
|
|
259
|
+
"""Stamp, store, fan out."""
|
|
260
|
+
async with self._lock:
|
|
261
|
+
stamped = event.with_event_id(self._next_id)
|
|
262
|
+
self._next_id += 1
|
|
263
|
+
self._history.append(stamped)
|
|
264
|
+
subscribers = list(self._subscribers)
|
|
265
|
+
for queue in subscribers:
|
|
266
|
+
try:
|
|
267
|
+
queue.put_nowait(stamped)
|
|
268
|
+
except asyncio.QueueFull:
|
|
269
|
+
# Slow subscriber; drop this event for them. History is still
|
|
270
|
+
# authoritative — on reconnect they pull via /history?after_id=.
|
|
271
|
+
continue
|
|
272
|
+
return stamped
|
|
273
|
+
|
|
274
|
+
async def subscribe(self) -> AsyncIterator[ConfigChangeEvent]:
|
|
275
|
+
"""Async iterator yielding live events until cancelled."""
|
|
276
|
+
queue: asyncio.Queue[ConfigChangeEvent] = asyncio.Queue(maxsize=self._queue_size)
|
|
277
|
+
async with self._lock:
|
|
278
|
+
self._subscribers.add(queue)
|
|
279
|
+
try:
|
|
280
|
+
while True:
|
|
281
|
+
event = await queue.get()
|
|
282
|
+
yield event
|
|
283
|
+
finally:
|
|
284
|
+
async with self._lock:
|
|
285
|
+
self._subscribers.discard(queue)
|
|
286
|
+
|
|
287
|
+
def history(self, after_id: int = 0, limit: int = 100) -> list[ConfigChangeEvent]:
|
|
288
|
+
"""Return recent events with ``event_id > after_id``, newest last, capped at ``limit``."""
|
|
289
|
+
if limit <= 0:
|
|
290
|
+
return []
|
|
291
|
+
if limit > self._history_size:
|
|
292
|
+
limit = self._history_size
|
|
293
|
+
# deque is iterated in insertion order (oldest first).
|
|
294
|
+
out: list[ConfigChangeEvent] = [
|
|
295
|
+
evt for evt in self._history if evt.event_id > int(after_id or 0)
|
|
296
|
+
]
|
|
297
|
+
# Return the *latest* N when more than limit match, preserving order.
|
|
298
|
+
if len(out) > limit:
|
|
299
|
+
out = out[-limit:]
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _apply_field_mapping(
|
|
304
|
+
payload: dict[str, Any], field_mapping: Mapping[str, str]
|
|
305
|
+
) -> dict[str, Any]:
|
|
306
|
+
"""Rename canonical keys in ``payload`` to their aliases per ``field_mapping``.
|
|
307
|
+
|
|
308
|
+
Preserves insertion order of the input dict. Keys not present in
|
|
309
|
+
``field_mapping`` are emitted unchanged. If a target alias collides with an
|
|
310
|
+
existing key in the payload that is not itself being renamed away, the
|
|
311
|
+
alias overwrites (caller responsibility to choose non-conflicting aliases).
|
|
312
|
+
|
|
313
|
+
This is a presentation-layer helper — it never mutates the source object.
|
|
314
|
+
W28A-1002-EXTEND-R1b (0.12.0).
|
|
315
|
+
"""
|
|
316
|
+
if not field_mapping:
|
|
317
|
+
return dict(payload)
|
|
318
|
+
out: dict[str, Any] = {}
|
|
319
|
+
for key, value in payload.items():
|
|
320
|
+
out[field_mapping.get(key, key)] = value
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _int_event_id_to_uuid(event_id: int) -> str:
|
|
325
|
+
"""Deterministically map an integer event_id to a stable UUID string.
|
|
326
|
+
|
|
327
|
+
Used by ``RESTPollAdapter(event_id_format="uuid_string")`` to preserve
|
|
328
|
+
legacy UUID-string contracts without requiring the broadcaster to change
|
|
329
|
+
its internal monotonic int counter. The mapping is deterministic so the
|
|
330
|
+
same event_id always yields the same UUID across process restarts.
|
|
331
|
+
|
|
332
|
+
Uses uuid5 over a fixed platform namespace derived from the DNS namespace.
|
|
333
|
+
W28A-1002-EXTEND-R1b (0.12.0).
|
|
334
|
+
"""
|
|
335
|
+
from uuid import NAMESPACE_DNS, uuid5
|
|
336
|
+
|
|
337
|
+
return str(uuid5(NAMESPACE_DNS, f"cloud-dog-a2a-event:{int(event_id)}"))
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _sse_frame(event: ConfigChangeEvent) -> str:
|
|
341
|
+
"""Render a single ConfigChangeEvent as an SSE frame."""
|
|
342
|
+
data = json.dumps(event.to_dict(), default=str, separators=(",", ":"))
|
|
343
|
+
return f"id: {event.event_id}\nevent: config_change\ndata: {data}\n\n"
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def create_a2a_events_router(
|
|
347
|
+
broadcaster: EventBroadcaster,
|
|
348
|
+
*,
|
|
349
|
+
base_path: str = "/a2a/events",
|
|
350
|
+
keepalive_seconds: float = 15.0,
|
|
351
|
+
history_limit_cap: int = 1000,
|
|
352
|
+
) -> APIRouter:
|
|
353
|
+
"""Create a FastAPI router exposing SSE stream + history endpoints.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
broadcaster: An ``EventBroadcaster`` implementation (usually
|
|
357
|
+
``InMemoryEventBroadcaster`` held on ``app.state``).
|
|
358
|
+
base_path: Mount path for the SSE endpoint. ``/history`` is appended
|
|
359
|
+
for the JSON history endpoint. Defaults to ``"/a2a/events"``.
|
|
360
|
+
keepalive_seconds: Interval between SSE keepalive comment frames.
|
|
361
|
+
Needed so reverse-proxies (Traefik, nginx) with an idle-read
|
|
362
|
+
timeout don't tear down a quiet stream.
|
|
363
|
+
history_limit_cap: Maximum ``limit`` value accepted by ``/history``.
|
|
364
|
+
"""
|
|
365
|
+
if not base_path.startswith("/"):
|
|
366
|
+
raise ValueError("base_path must start with '/'")
|
|
367
|
+
base_path = base_path.rstrip("/") or "/a2a/events"
|
|
368
|
+
history_path = f"{base_path}/history"
|
|
369
|
+
|
|
370
|
+
router = APIRouter()
|
|
371
|
+
|
|
372
|
+
@router.get(base_path, include_in_schema=True)
|
|
373
|
+
async def stream_events( # type: ignore[no-untyped-def]
|
|
374
|
+
request: Request,
|
|
375
|
+
after_id: int = Query(default=0, ge=0),
|
|
376
|
+
) -> StreamingResponse:
|
|
377
|
+
"""Stream configuration change events as a server-sent-events feed."""
|
|
378
|
+
|
|
379
|
+
async def _gen() -> AsyncIterator[bytes]:
|
|
380
|
+
# 1) Replay history strictly greater than after_id (bounded).
|
|
381
|
+
for evt in broadcaster.history(after_id=after_id, limit=history_limit_cap):
|
|
382
|
+
if await request.is_disconnected():
|
|
383
|
+
return
|
|
384
|
+
yield _sse_frame(evt).encode("utf-8")
|
|
385
|
+
# 2) Subscribe for live events, interleave with keepalives.
|
|
386
|
+
subscription = broadcaster.subscribe()
|
|
387
|
+
try:
|
|
388
|
+
while True:
|
|
389
|
+
if await request.is_disconnected():
|
|
390
|
+
break
|
|
391
|
+
try:
|
|
392
|
+
evt = await asyncio.wait_for(
|
|
393
|
+
subscription.__anext__(), timeout=keepalive_seconds
|
|
394
|
+
)
|
|
395
|
+
except asyncio.TimeoutError:
|
|
396
|
+
yield b": keepalive\n\n"
|
|
397
|
+
continue
|
|
398
|
+
except StopAsyncIteration:
|
|
399
|
+
break
|
|
400
|
+
yield _sse_frame(evt).encode("utf-8")
|
|
401
|
+
finally:
|
|
402
|
+
aclose = getattr(subscription, "aclose", None)
|
|
403
|
+
if aclose is not None:
|
|
404
|
+
try:
|
|
405
|
+
await aclose()
|
|
406
|
+
except Exception:
|
|
407
|
+
pass
|
|
408
|
+
|
|
409
|
+
return StreamingResponse(
|
|
410
|
+
_gen(),
|
|
411
|
+
media_type="text/event-stream",
|
|
412
|
+
headers={
|
|
413
|
+
"Cache-Control": "no-cache",
|
|
414
|
+
"X-Accel-Buffering": "no",
|
|
415
|
+
},
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
@router.get(history_path, include_in_schema=True)
|
|
419
|
+
async def events_history( # type: ignore[no-untyped-def]
|
|
420
|
+
after_id: int = Query(default=0, ge=0),
|
|
421
|
+
limit: int = Query(default=100, ge=1, le=history_limit_cap),
|
|
422
|
+
) -> JSONResponse:
|
|
423
|
+
"""Return recent configuration change events as JSON (newest last)."""
|
|
424
|
+
events = [evt.to_dict() for evt in broadcaster.history(after_id=after_id, limit=limit)]
|
|
425
|
+
return JSONResponse({"events": events})
|
|
426
|
+
|
|
427
|
+
return router
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# ---------------------------------------------------------------------------
|
|
431
|
+
# W28A-1002-EXTEND-R1 (0.11.0) — common-package additions
|
|
432
|
+
#
|
|
433
|
+
# The following four classes extend the 0.10.0 primitive to cover all 5
|
|
434
|
+
# bespoke CFG-06 architectures (expert-agent HTTP-bridge, sql-agent file-queue,
|
|
435
|
+
# imap-mcp JSONL+WS, index-retriever REST-poll, chat-client SSE) via additive
|
|
436
|
+
# adapters. The 0.10.0 API above is unchanged — file-mcp 0.10.0 adoption must
|
|
437
|
+
# continue to work without modification (backward compat binding).
|
|
438
|
+
#
|
|
439
|
+
# Canonical envelope (PS-72 §A2A-change-events — formalised by W28A-F3e-APPLY):
|
|
440
|
+
# {type: str, topic: str, timestamp: str ISO8601, payload: dict}
|
|
441
|
+
#
|
|
442
|
+
# Mapping to the 0.10.0 ConfigChangeEvent:
|
|
443
|
+
# type = action (create / update / delete / revoke)
|
|
444
|
+
# topic = resource (user / group / api_key / profile / ...)
|
|
445
|
+
# timestamp = timestamp.isoformat() with "Z" suffix
|
|
446
|
+
# payload = after (for create/update) or before (for delete) + actor/correlation_id
|
|
447
|
+
#
|
|
448
|
+
# All new adapters interoperate with BOTH InMemoryEventBroadcaster and
|
|
449
|
+
# PersistentEventBroadcaster via the EventBroadcaster Protocol.
|
|
450
|
+
# ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _serialise_event(event: ConfigChangeEvent) -> str:
|
|
454
|
+
"""JSONL-line serialisation of a ConfigChangeEvent for file-backed stores."""
|
|
455
|
+
return json.dumps(event.to_dict(), default=str, separators=(",", ":"))
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _deserialise_event(line: str) -> Optional[ConfigChangeEvent]:
|
|
459
|
+
"""Parse a JSONL line back into a ConfigChangeEvent. Returns None on malformed input."""
|
|
460
|
+
line = line.strip()
|
|
461
|
+
if not line:
|
|
462
|
+
return None
|
|
463
|
+
try:
|
|
464
|
+
data = json.loads(line)
|
|
465
|
+
except (json.JSONDecodeError, ValueError):
|
|
466
|
+
return None
|
|
467
|
+
if not isinstance(data, dict):
|
|
468
|
+
return None
|
|
469
|
+
ts_raw = data.get("timestamp")
|
|
470
|
+
ts: datetime
|
|
471
|
+
if isinstance(ts_raw, str):
|
|
472
|
+
try:
|
|
473
|
+
# Accept both trailing Z and +00:00 forms.
|
|
474
|
+
ts_str = ts_raw.replace("Z", "+00:00") if ts_raw.endswith("Z") else ts_raw
|
|
475
|
+
ts = datetime.fromisoformat(ts_str)
|
|
476
|
+
if ts.tzinfo is None:
|
|
477
|
+
ts = ts.replace(tzinfo=_UTC)
|
|
478
|
+
except ValueError:
|
|
479
|
+
ts = _utc_now()
|
|
480
|
+
else:
|
|
481
|
+
ts = _utc_now()
|
|
482
|
+
try:
|
|
483
|
+
event_id = int(data.get("event_id", 0) or 0)
|
|
484
|
+
except (TypeError, ValueError):
|
|
485
|
+
event_id = 0
|
|
486
|
+
try:
|
|
487
|
+
return ConfigChangeEvent(
|
|
488
|
+
service=str(data.get("service", "")),
|
|
489
|
+
resource=str(data.get("resource", "")),
|
|
490
|
+
action=str(data.get("action", "")),
|
|
491
|
+
identifier=str(data.get("identifier", "")),
|
|
492
|
+
actor=data.get("actor"),
|
|
493
|
+
correlation_id=data.get("correlation_id"),
|
|
494
|
+
before=data.get("before"),
|
|
495
|
+
after=data.get("after"),
|
|
496
|
+
outcome=str(data.get("outcome", "success")),
|
|
497
|
+
timestamp=ts,
|
|
498
|
+
event_id=event_id,
|
|
499
|
+
)
|
|
500
|
+
except Exception:
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
class PersistentEventBroadcaster:
|
|
505
|
+
"""File-backed JSONL broadcaster with WAL semantics, replay-on-startup + bounded retention.
|
|
506
|
+
|
|
507
|
+
Drop-in for services that need crash-safe A2A event delivery across process
|
|
508
|
+
restarts (sql-agent cross-process file-queue, imap-mcp JSONL tailing).
|
|
509
|
+
|
|
510
|
+
Storage layout::
|
|
511
|
+
|
|
512
|
+
<store_path> — append-only JSONL file (WAL) holding the
|
|
513
|
+
most recent `retention` events.
|
|
514
|
+
<store_path>.rotated.1 — optional pre-rotation snapshot (kept briefly
|
|
515
|
+
during rotation to avoid data loss on crash
|
|
516
|
+
between truncate + rewrite).
|
|
517
|
+
|
|
518
|
+
Guarantees:
|
|
519
|
+
* Every `publish` writes the event + ``fsync`` before returning; the
|
|
520
|
+
event id reflects its position in the durable log.
|
|
521
|
+
* On construction, replays the JSONL store and rebuilds in-memory
|
|
522
|
+
history up to ``retention`` most recent events.
|
|
523
|
+
* When the log exceeds ``retention * 2`` events (measured by line count
|
|
524
|
+
at startup or by in-memory counter at runtime), it rotates: snapshot
|
|
525
|
+
the latest ``retention`` events, rewrite the primary file atomically.
|
|
526
|
+
* Subscriber fan-out is identical to ``InMemoryEventBroadcaster``
|
|
527
|
+
(bounded queues, put_nowait drop-on-full with history as fallback).
|
|
528
|
+
|
|
529
|
+
Usage::
|
|
530
|
+
|
|
531
|
+
broadcaster = PersistentEventBroadcaster(Path("/var/lib/svc/events.jsonl"))
|
|
532
|
+
app.include_router(create_a2a_events_router(broadcaster))
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
store_path: Path to the JSONL store file. Parent directory is created
|
|
536
|
+
automatically if missing. Path may be str or Path.
|
|
537
|
+
retention: Maximum number of events retained (both in the file after
|
|
538
|
+
rotation and in-memory history window). Must be positive.
|
|
539
|
+
subscriber_queue_size: Per-subscriber queue capacity for live fan-out.
|
|
540
|
+
"""
|
|
541
|
+
|
|
542
|
+
def __init__(
|
|
543
|
+
self,
|
|
544
|
+
store_path: Union[str, Path],
|
|
545
|
+
*,
|
|
546
|
+
retention: int = 10_000,
|
|
547
|
+
subscriber_queue_size: int = 256,
|
|
548
|
+
) -> None:
|
|
549
|
+
if retention <= 0:
|
|
550
|
+
raise ValueError("retention must be positive")
|
|
551
|
+
if subscriber_queue_size <= 0:
|
|
552
|
+
raise ValueError("subscriber_queue_size must be positive")
|
|
553
|
+
self._store_path = Path(store_path)
|
|
554
|
+
self._store_path.parent.mkdir(parents=True, exist_ok=True)
|
|
555
|
+
self._retention = retention
|
|
556
|
+
self._queue_size = subscriber_queue_size
|
|
557
|
+
self._history: deque[ConfigChangeEvent] = deque(maxlen=retention)
|
|
558
|
+
self._subscribers: set[asyncio.Queue[ConfigChangeEvent]] = set()
|
|
559
|
+
self._lock = asyncio.Lock()
|
|
560
|
+
self._next_id = 1
|
|
561
|
+
# Replay on startup — rebuild in-memory state from the durable log.
|
|
562
|
+
self._replay_from_disk()
|
|
563
|
+
|
|
564
|
+
# ------------------------------------------------------------------ lifecycle
|
|
565
|
+
def _replay_from_disk(self) -> None:
|
|
566
|
+
"""Load persisted events into memory; update monotonic id cursor."""
|
|
567
|
+
if not self._store_path.is_file():
|
|
568
|
+
return
|
|
569
|
+
events: list[ConfigChangeEvent] = []
|
|
570
|
+
max_id = 0
|
|
571
|
+
with self._store_path.open("r", encoding="utf-8") as fh:
|
|
572
|
+
for line in fh:
|
|
573
|
+
evt = _deserialise_event(line)
|
|
574
|
+
if evt is None:
|
|
575
|
+
continue
|
|
576
|
+
events.append(evt)
|
|
577
|
+
if evt.event_id > max_id:
|
|
578
|
+
max_id = evt.event_id
|
|
579
|
+
# Retain only the newest `retention` (file may exceed it if rotation was
|
|
580
|
+
# interrupted; trim here so in-memory history matches the bound).
|
|
581
|
+
if len(events) > self._retention:
|
|
582
|
+
events = events[-self._retention :]
|
|
583
|
+
for evt in events:
|
|
584
|
+
self._history.append(evt)
|
|
585
|
+
self._next_id = max_id + 1
|
|
586
|
+
|
|
587
|
+
# ------------------------------------------------------------------ properties
|
|
588
|
+
@property
|
|
589
|
+
def store_path(self) -> Path:
|
|
590
|
+
return self._store_path
|
|
591
|
+
|
|
592
|
+
@property
|
|
593
|
+
def retention(self) -> int:
|
|
594
|
+
return self._retention
|
|
595
|
+
|
|
596
|
+
@property
|
|
597
|
+
def history_size(self) -> int:
|
|
598
|
+
return self._retention
|
|
599
|
+
|
|
600
|
+
@property
|
|
601
|
+
def subscriber_count(self) -> int:
|
|
602
|
+
return len(self._subscribers)
|
|
603
|
+
|
|
604
|
+
# ------------------------------------------------------------------ protocol
|
|
605
|
+
async def publish(self, event: ConfigChangeEvent) -> ConfigChangeEvent:
|
|
606
|
+
"""Stamp id, append to JSONL + fsync, fan out to subscribers."""
|
|
607
|
+
async with self._lock:
|
|
608
|
+
stamped = event.with_event_id(self._next_id)
|
|
609
|
+
self._next_id += 1
|
|
610
|
+
self._append_and_fsync(stamped)
|
|
611
|
+
self._history.append(stamped)
|
|
612
|
+
# Opportunistic rotation when we cross 2x retention on disk.
|
|
613
|
+
self._maybe_rotate_locked()
|
|
614
|
+
subscribers = list(self._subscribers)
|
|
615
|
+
for queue in subscribers:
|
|
616
|
+
try:
|
|
617
|
+
queue.put_nowait(stamped)
|
|
618
|
+
except asyncio.QueueFull:
|
|
619
|
+
continue
|
|
620
|
+
return stamped
|
|
621
|
+
|
|
622
|
+
async def subscribe(self) -> AsyncIterator[ConfigChangeEvent]:
|
|
623
|
+
queue: asyncio.Queue[ConfigChangeEvent] = asyncio.Queue(maxsize=self._queue_size)
|
|
624
|
+
async with self._lock:
|
|
625
|
+
self._subscribers.add(queue)
|
|
626
|
+
try:
|
|
627
|
+
while True:
|
|
628
|
+
event = await queue.get()
|
|
629
|
+
yield event
|
|
630
|
+
finally:
|
|
631
|
+
async with self._lock:
|
|
632
|
+
self._subscribers.discard(queue)
|
|
633
|
+
|
|
634
|
+
def history(self, after_id: int = 0, limit: int = 100) -> list[ConfigChangeEvent]:
|
|
635
|
+
if limit <= 0:
|
|
636
|
+
return []
|
|
637
|
+
if limit > self._retention:
|
|
638
|
+
limit = self._retention
|
|
639
|
+
out: list[ConfigChangeEvent] = [
|
|
640
|
+
evt for evt in self._history if evt.event_id > int(after_id or 0)
|
|
641
|
+
]
|
|
642
|
+
if len(out) > limit:
|
|
643
|
+
out = out[-limit:]
|
|
644
|
+
return out
|
|
645
|
+
|
|
646
|
+
# ------------------------------------------------------------------ internals
|
|
647
|
+
def _append_and_fsync(self, event: ConfigChangeEvent) -> None:
|
|
648
|
+
"""Append a single event to the JSONL file with durability."""
|
|
649
|
+
line = _serialise_event(event) + "\n"
|
|
650
|
+
# Open in append binary mode for atomic single-line writes.
|
|
651
|
+
fd = os.open(
|
|
652
|
+
str(self._store_path),
|
|
653
|
+
os.O_WRONLY | os.O_CREAT | os.O_APPEND,
|
|
654
|
+
0o644,
|
|
655
|
+
)
|
|
656
|
+
try:
|
|
657
|
+
os.write(fd, line.encode("utf-8"))
|
|
658
|
+
os.fsync(fd)
|
|
659
|
+
finally:
|
|
660
|
+
os.close(fd)
|
|
661
|
+
|
|
662
|
+
def _line_count(self) -> int:
|
|
663
|
+
if not self._store_path.is_file():
|
|
664
|
+
return 0
|
|
665
|
+
count = 0
|
|
666
|
+
with self._store_path.open("rb") as fh:
|
|
667
|
+
for _ in fh:
|
|
668
|
+
count += 1
|
|
669
|
+
return count
|
|
670
|
+
|
|
671
|
+
def _maybe_rotate_locked(self) -> None:
|
|
672
|
+
"""If the durable log has grown past 2x retention, rewrite it atomically."""
|
|
673
|
+
if self._line_count() <= self._retention * 2:
|
|
674
|
+
return
|
|
675
|
+
# Snapshot the most recent `retention` events from in-memory history
|
|
676
|
+
# (which is authoritative because replay + append keep them in sync).
|
|
677
|
+
events_to_keep = list(self._history)
|
|
678
|
+
# Write to a sibling tempfile + atomic rename.
|
|
679
|
+
tmp_fd, tmp_name = tempfile.mkstemp(
|
|
680
|
+
prefix=self._store_path.name + ".rot.",
|
|
681
|
+
dir=str(self._store_path.parent),
|
|
682
|
+
)
|
|
683
|
+
try:
|
|
684
|
+
with os.fdopen(tmp_fd, "w", encoding="utf-8") as tmp_fh:
|
|
685
|
+
for evt in events_to_keep:
|
|
686
|
+
tmp_fh.write(_serialise_event(evt) + "\n")
|
|
687
|
+
tmp_fh.flush()
|
|
688
|
+
os.fsync(tmp_fh.fileno())
|
|
689
|
+
os.replace(tmp_name, str(self._store_path))
|
|
690
|
+
except Exception:
|
|
691
|
+
# Best-effort cleanup of the tempfile on failure.
|
|
692
|
+
with contextlib.suppress(OSError):
|
|
693
|
+
os.unlink(tmp_name)
|
|
694
|
+
raise
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
class HTTPIngestAdapter:
|
|
698
|
+
"""HTTP-POST ingress adapter for cross-process publishers.
|
|
699
|
+
|
|
700
|
+
Closes the expert-agent pattern where an APIServer in one process publishes
|
|
701
|
+
ConfigChangeEvents to a separate A2AServer process that owns the broadcaster
|
|
702
|
+
and client-facing surfaces. Instead of a bespoke `/broadcast` endpoint per
|
|
703
|
+
service, mount this adapter on the A2AServer process and POST to it from
|
|
704
|
+
upstream publishers.
|
|
705
|
+
|
|
706
|
+
Routes mounted (relative to ``mount_path``)::
|
|
707
|
+
|
|
708
|
+
POST <mount_path>/{topic} — publish a single event to a topic
|
|
709
|
+
POST <mount_path>/{topic}/event — alias for explicit /event suffix
|
|
710
|
+
|
|
711
|
+
Request body (both routes)::
|
|
712
|
+
|
|
713
|
+
{
|
|
714
|
+
"action": "create", # required
|
|
715
|
+
"identifier": "user_123", # required
|
|
716
|
+
"service": "expert-agent", # optional (adapter_service default)
|
|
717
|
+
"actor": "admin", # optional
|
|
718
|
+
"correlation_id": "req-42", # optional
|
|
719
|
+
"before": {...}, # optional
|
|
720
|
+
"after": {...}, # optional
|
|
721
|
+
"outcome": "success" # optional, default "success"
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
The path segment ``{topic}`` is mapped to ``ConfigChangeEvent.resource``.
|
|
725
|
+
The optional ``auth_dependency`` callable enforces caller authentication
|
|
726
|
+
(e.g., API-key or bearer-token validation); leave None for open ingestion.
|
|
727
|
+
|
|
728
|
+
Args:
|
|
729
|
+
broadcaster: Target broadcaster (InMemoryEventBroadcaster or
|
|
730
|
+
PersistentEventBroadcaster or any EventBroadcaster).
|
|
731
|
+
mount_path: Base path for the ingress routes. Default "/broadcast".
|
|
732
|
+
adapter_service: Default value for ``ConfigChangeEvent.service`` when
|
|
733
|
+
the request body omits it. None = required in body.
|
|
734
|
+
auth_dependency: Optional FastAPI dependency callable for auth guard.
|
|
735
|
+
"""
|
|
736
|
+
|
|
737
|
+
def __init__(
|
|
738
|
+
self,
|
|
739
|
+
broadcaster: EventBroadcaster,
|
|
740
|
+
*,
|
|
741
|
+
mount_path: str = "/broadcast",
|
|
742
|
+
adapter_service: Optional[str] = None,
|
|
743
|
+
auth_dependency: Optional[Callable[..., Any]] = None,
|
|
744
|
+
# W28A-1002-EXTEND-R1b (0.12.0) — optional legacy-alias ingest.
|
|
745
|
+
accept_legacy_fields: bool = False,
|
|
746
|
+
) -> None:
|
|
747
|
+
if not mount_path.startswith("/"):
|
|
748
|
+
raise ValueError("mount_path must start with '/'")
|
|
749
|
+
self._broadcaster = broadcaster
|
|
750
|
+
self._mount_path = mount_path.rstrip("/") or "/broadcast"
|
|
751
|
+
self._adapter_service = adapter_service
|
|
752
|
+
self._auth = auth_dependency
|
|
753
|
+
# W28A-1002-EXTEND-R1b: when True, accept legacy field names on ingest
|
|
754
|
+
# (``entity_type`` as alias for topic/``resource``, ``entity_id`` as
|
|
755
|
+
# alias for ``identifier``). Default False preserves 0.11.0 strict
|
|
756
|
+
# canonical-field ingress contract.
|
|
757
|
+
self._accept_legacy_fields = accept_legacy_fields
|
|
758
|
+
|
|
759
|
+
@property
|
|
760
|
+
def mount_path(self) -> str:
|
|
761
|
+
"""Mount path for the POST ingress routes (read-only)."""
|
|
762
|
+
return self._mount_path
|
|
763
|
+
|
|
764
|
+
def router(self) -> APIRouter:
|
|
765
|
+
"""Return a FastAPI router implementing the POST endpoints."""
|
|
766
|
+
router = APIRouter()
|
|
767
|
+
auth_dep = self._auth
|
|
768
|
+
|
|
769
|
+
# Build dependencies list (FastAPI will evaluate auth_dep on each call
|
|
770
|
+
# and raise HTTPException if it returns falsy / raises).
|
|
771
|
+
deps = [Depends(auth_dep)] if auth_dep is not None else []
|
|
772
|
+
|
|
773
|
+
accept_legacy = self._accept_legacy_fields
|
|
774
|
+
|
|
775
|
+
async def _ingest(topic: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
776
|
+
# Body validation — accept dict only; required fields action + identifier.
|
|
777
|
+
if not isinstance(body, dict):
|
|
778
|
+
raise HTTPException(
|
|
779
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
780
|
+
detail="Request body must be a JSON object",
|
|
781
|
+
)
|
|
782
|
+
action = body.get("action")
|
|
783
|
+
identifier = body.get("identifier")
|
|
784
|
+
# W28A-1002-EXTEND-R1b: accept legacy field names when opted-in.
|
|
785
|
+
if accept_legacy and (identifier is None or identifier == ""):
|
|
786
|
+
identifier = body.get("entity_id")
|
|
787
|
+
if not isinstance(action, str) or not action:
|
|
788
|
+
raise HTTPException(
|
|
789
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
790
|
+
detail="Field 'action' is required and must be a non-empty string",
|
|
791
|
+
)
|
|
792
|
+
if not isinstance(identifier, str) or not identifier:
|
|
793
|
+
raise HTTPException(
|
|
794
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
795
|
+
detail="Field 'identifier' is required and must be a non-empty string",
|
|
796
|
+
)
|
|
797
|
+
service_name = body.get("service") or self._adapter_service
|
|
798
|
+
if not isinstance(service_name, str) or not service_name:
|
|
799
|
+
raise HTTPException(
|
|
800
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
801
|
+
detail="Field 'service' is required (either in body or via adapter_service default)",
|
|
802
|
+
)
|
|
803
|
+
evt = ConfigChangeEvent(
|
|
804
|
+
service=service_name,
|
|
805
|
+
resource=topic,
|
|
806
|
+
action=action,
|
|
807
|
+
identifier=identifier,
|
|
808
|
+
actor=body.get("actor"),
|
|
809
|
+
correlation_id=body.get("correlation_id"),
|
|
810
|
+
before=body.get("before"),
|
|
811
|
+
after=body.get("after"),
|
|
812
|
+
outcome=body.get("outcome", "success"),
|
|
813
|
+
)
|
|
814
|
+
stamped = await self._broadcaster.publish(evt)
|
|
815
|
+
return {
|
|
816
|
+
"event_id": stamped.event_id,
|
|
817
|
+
"topic": stamped.resource,
|
|
818
|
+
"published_at": stamped.timestamp.isoformat().replace("+00:00", "Z"),
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
@router.post(f"{self._mount_path}/{{topic}}", dependencies=deps)
|
|
822
|
+
async def broadcast_topic(
|
|
823
|
+
topic: str,
|
|
824
|
+
body: dict[str, Any] = Body(...),
|
|
825
|
+
) -> dict[str, Any]:
|
|
826
|
+
return await _ingest(topic, body)
|
|
827
|
+
|
|
828
|
+
@router.post(f"{self._mount_path}/{{topic}}/event", dependencies=deps)
|
|
829
|
+
async def broadcast_topic_event(
|
|
830
|
+
topic: str,
|
|
831
|
+
body: dict[str, Any] = Body(...),
|
|
832
|
+
) -> dict[str, Any]:
|
|
833
|
+
return await _ingest(topic, body)
|
|
834
|
+
|
|
835
|
+
return router
|
|
836
|
+
|
|
837
|
+
def mount(self, app: FastAPI) -> None:
|
|
838
|
+
"""Convenience: include the ingress router into a FastAPI app."""
|
|
839
|
+
app.include_router(self.router())
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
class WebSocketAdapter:
|
|
843
|
+
"""WebSocket subscriber surface alongside the SSE router.
|
|
844
|
+
|
|
845
|
+
Closes the imap-mcp + expert-agent WS subscriber pattern by providing a
|
|
846
|
+
standardised WebSocket endpoint that clients can use instead of (or in
|
|
847
|
+
addition to) the SSE route. The endpoint replays history first (honouring
|
|
848
|
+
``after_id`` query param), then streams live events as JSON text frames
|
|
849
|
+
until the client disconnects.
|
|
850
|
+
|
|
851
|
+
Frame format (one JSON object per text frame)::
|
|
852
|
+
|
|
853
|
+
{
|
|
854
|
+
"event_id": 42,
|
|
855
|
+
"type": "config_change",
|
|
856
|
+
"service": "...",
|
|
857
|
+
"resource": "...",
|
|
858
|
+
"action": "...",
|
|
859
|
+
"identifier": "...",
|
|
860
|
+
"timestamp": "2026-04-24T12:00:00Z",
|
|
861
|
+
"payload": { ... } # ConfigChangeEvent.to_dict() merged in
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
Args:
|
|
865
|
+
broadcaster: Source broadcaster for history + live events.
|
|
866
|
+
mount_path: WebSocket path. Default ``"/a2a/events/ws"``.
|
|
867
|
+
history_limit_cap: Maximum history replay on connect.
|
|
868
|
+
"""
|
|
869
|
+
|
|
870
|
+
def __init__(
|
|
871
|
+
self,
|
|
872
|
+
broadcaster: EventBroadcaster,
|
|
873
|
+
*,
|
|
874
|
+
mount_path: str = "/a2a/events/ws",
|
|
875
|
+
history_limit_cap: int = 1000,
|
|
876
|
+
# W28A-1002-EXTEND-R1b (0.12.0) — optional presentation-layer transform.
|
|
877
|
+
field_mapping: Optional[Mapping[str, str]] = None,
|
|
878
|
+
) -> None:
|
|
879
|
+
if not mount_path.startswith("/"):
|
|
880
|
+
raise ValueError("mount_path must start with '/'")
|
|
881
|
+
self._broadcaster = broadcaster
|
|
882
|
+
self._mount_path = mount_path
|
|
883
|
+
self._history_limit_cap = history_limit_cap
|
|
884
|
+
# W28A-1002-EXTEND-R1b: field_mapping (optional) renames canonical
|
|
885
|
+
# fields in every frame to legacy aliases. None (DEFAULT) = 0.11.0
|
|
886
|
+
# canonical field names.
|
|
887
|
+
self._field_mapping: Optional[dict[str, str]] = (
|
|
888
|
+
dict(field_mapping) if field_mapping else None
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
@property
|
|
892
|
+
def mount_path(self) -> str:
|
|
893
|
+
"""WebSocket mount path (read-only)."""
|
|
894
|
+
return self._mount_path
|
|
895
|
+
|
|
896
|
+
def router(self) -> APIRouter:
|
|
897
|
+
"""Return a FastAPI router implementing the WebSocket endpoint."""
|
|
898
|
+
router = APIRouter()
|
|
899
|
+
broadcaster = self._broadcaster
|
|
900
|
+
history_limit_cap = self._history_limit_cap
|
|
901
|
+
field_mapping = self._field_mapping
|
|
902
|
+
|
|
903
|
+
def _frame(evt: ConfigChangeEvent) -> str:
|
|
904
|
+
return json.dumps(evt.to_dict(alias_map=field_mapping), default=str)
|
|
905
|
+
|
|
906
|
+
@router.websocket(self._mount_path)
|
|
907
|
+
async def ws_endpoint(websocket: WebSocket) -> None:
|
|
908
|
+
# Parse ``after_id`` from query params (default 0).
|
|
909
|
+
after_id_raw = websocket.query_params.get("after_id", "0")
|
|
910
|
+
try:
|
|
911
|
+
after_id = max(0, int(after_id_raw))
|
|
912
|
+
except (TypeError, ValueError):
|
|
913
|
+
after_id = 0
|
|
914
|
+
await websocket.accept()
|
|
915
|
+
try:
|
|
916
|
+
# 1) Replay history strictly greater than after_id.
|
|
917
|
+
for evt in broadcaster.history(after_id=after_id, limit=history_limit_cap):
|
|
918
|
+
await websocket.send_text(_frame(evt))
|
|
919
|
+
# 2) Subscribe for live events.
|
|
920
|
+
subscription = broadcaster.subscribe()
|
|
921
|
+
try:
|
|
922
|
+
async for evt in subscription:
|
|
923
|
+
await websocket.send_text(_frame(evt))
|
|
924
|
+
finally:
|
|
925
|
+
aclose = getattr(subscription, "aclose", None)
|
|
926
|
+
if aclose is not None:
|
|
927
|
+
with contextlib.suppress(Exception):
|
|
928
|
+
await aclose()
|
|
929
|
+
except WebSocketDisconnect:
|
|
930
|
+
# Graceful client-side disconnect — nothing to do.
|
|
931
|
+
return
|
|
932
|
+
except Exception:
|
|
933
|
+
# Close server-side on unexpected failure to free subscriber slot.
|
|
934
|
+
with contextlib.suppress(Exception):
|
|
935
|
+
await websocket.close()
|
|
936
|
+
raise
|
|
937
|
+
|
|
938
|
+
return router
|
|
939
|
+
|
|
940
|
+
def mount(self, app: FastAPI) -> None:
|
|
941
|
+
"""Convenience: include the WebSocket router into a FastAPI app."""
|
|
942
|
+
app.include_router(self.router())
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
class RESTPollAdapter:
|
|
946
|
+
"""REST-poll surface over the broadcaster history.
|
|
947
|
+
|
|
948
|
+
Closes the index-retriever + admin-SPA REST-poll client contract. Returns
|
|
949
|
+
JSON ``{"events": [...], "cursor": <next>}`` envelope for clients that
|
|
950
|
+
cannot (or prefer not to) use SSE/WebSocket. Cursor is a monotonic
|
|
951
|
+
``event_id`` integer — pass back via ``?since=<cursor>`` to fetch the next
|
|
952
|
+
batch.
|
|
953
|
+
|
|
954
|
+
Default contract (0.11.0-compat)::
|
|
955
|
+
|
|
956
|
+
GET <mount_path>?since=<cursor>&limit=<n>
|
|
957
|
+
→
|
|
958
|
+
{
|
|
959
|
+
"events": [<ConfigChangeEvent.to_dict()>, ...],
|
|
960
|
+
"cursor": <event_id of last event in the batch, or echoed since>
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
- First poll (``since=0`` or omitted) returns the first ``limit`` events.
|
|
964
|
+
- Subsequent polls with ``since=<cursor>`` return events with
|
|
965
|
+
``event_id > since``.
|
|
966
|
+
- An empty result returns ``{"events": [], "cursor": <since>}`` (idempotent).
|
|
967
|
+
- ``limit`` is clamped to ``history_limit_cap`` (default 1000).
|
|
968
|
+
|
|
969
|
+
Args:
|
|
970
|
+
broadcaster: Source broadcaster.
|
|
971
|
+
mount_path: REST-poll path. Default ``"/a2a/events"``. Services that
|
|
972
|
+
also expose the SSE router on the same path should mount this
|
|
973
|
+
adapter at a distinct path (e.g. ``"/a2a/events/poll"``) to avoid
|
|
974
|
+
content-type collision.
|
|
975
|
+
history_limit_cap: Maximum ``limit`` accepted per poll.
|
|
976
|
+
default_limit: Default ``limit`` when the client omits the query param.
|
|
977
|
+
|
|
978
|
+
W28A-1002-EXTEND-R1b (0.12.0) — ALL OPTIONAL, presentation-layer only.
|
|
979
|
+
Defaults preserve 0.11.0 behaviour byte-for-byte (backward-compat
|
|
980
|
+
invariant).
|
|
981
|
+
|
|
982
|
+
envelope_shape:
|
|
983
|
+
* ``"with_cursor"`` (DEFAULT) — 0.11.0 shape ``{"events":[...],
|
|
984
|
+
"cursor": N}``.
|
|
985
|
+
* ``"events_only"`` — legacy shape ``{"events":[...]}`` (no cursor
|
|
986
|
+
key). Used by index-retriever's bespoke ``GET /a2a/events`` that
|
|
987
|
+
admin-SPA + e2e tests depend on.
|
|
988
|
+
field_mapping:
|
|
989
|
+
Optional mapping of canonical-field-name → legacy-alias applied to
|
|
990
|
+
every event dict emitted. E.g. ``{"resource": "entity_type",
|
|
991
|
+
"identifier": "entity_id", "timestamp": "created_at"}`` emits
|
|
992
|
+
events with legacy field names without changing the canonical
|
|
993
|
+
dataclass. ``None`` (DEFAULT) preserves 0.11.0 canonical field
|
|
994
|
+
names.
|
|
995
|
+
order:
|
|
996
|
+
* ``"oldest_first"`` (DEFAULT) — 0.11.0 cursor-pagination semantics.
|
|
997
|
+
* ``"newest_first"`` — reverse the batch before emitting (legacy
|
|
998
|
+
index-retriever contract). Pagination cursors still advance
|
|
999
|
+
over the full id range; ordering is a presentation transform
|
|
1000
|
+
over the batch window.
|
|
1001
|
+
event_id_format:
|
|
1002
|
+
* ``"int"`` (DEFAULT) — 0.11.0 monotonic integer.
|
|
1003
|
+
* ``"uuid_string"`` — deterministic UUID string derived from the
|
|
1004
|
+
monotonic int via uuid5. Used by legacy contracts that expect
|
|
1005
|
+
UUID-typed event_ids. The cursor remains an integer (the
|
|
1006
|
+
cursor is a pagination token, not a client-visible event key).
|
|
1007
|
+
|
|
1008
|
+
Canonical envelope (PS-72 §A2A-change-events) is AUTHORITATIVE; the
|
|
1009
|
+
above configuration surface implements PRESENTATION-LAYER transforms so
|
|
1010
|
+
services can preserve external contracts without diverging from the
|
|
1011
|
+
canonical platform representation internally.
|
|
1012
|
+
"""
|
|
1013
|
+
|
|
1014
|
+
def __init__(
|
|
1015
|
+
self,
|
|
1016
|
+
broadcaster: EventBroadcaster,
|
|
1017
|
+
*,
|
|
1018
|
+
mount_path: str = "/a2a/events",
|
|
1019
|
+
history_limit_cap: int = 1000,
|
|
1020
|
+
default_limit: int = 100,
|
|
1021
|
+
# W28A-1002-EXTEND-R1b (0.12.0) — all optional, defaults = 0.11.0 shape.
|
|
1022
|
+
envelope_shape: Literal["with_cursor", "events_only"] = "with_cursor",
|
|
1023
|
+
field_mapping: Optional[Mapping[str, str]] = None,
|
|
1024
|
+
order: Literal["oldest_first", "newest_first"] = "oldest_first",
|
|
1025
|
+
event_id_format: Literal["int", "uuid_string"] = "int",
|
|
1026
|
+
) -> None:
|
|
1027
|
+
if not mount_path.startswith("/"):
|
|
1028
|
+
raise ValueError("mount_path must start with '/'")
|
|
1029
|
+
if history_limit_cap <= 0:
|
|
1030
|
+
raise ValueError("history_limit_cap must be positive")
|
|
1031
|
+
if default_limit <= 0:
|
|
1032
|
+
raise ValueError("default_limit must be positive")
|
|
1033
|
+
if envelope_shape not in ("with_cursor", "events_only"):
|
|
1034
|
+
raise ValueError(
|
|
1035
|
+
"envelope_shape must be 'with_cursor' or 'events_only'"
|
|
1036
|
+
)
|
|
1037
|
+
if order not in ("oldest_first", "newest_first"):
|
|
1038
|
+
raise ValueError("order must be 'oldest_first' or 'newest_first'")
|
|
1039
|
+
if event_id_format not in ("int", "uuid_string"):
|
|
1040
|
+
raise ValueError("event_id_format must be 'int' or 'uuid_string'")
|
|
1041
|
+
self._broadcaster = broadcaster
|
|
1042
|
+
self._mount_path = mount_path
|
|
1043
|
+
self._history_limit_cap = history_limit_cap
|
|
1044
|
+
self._default_limit = default_limit
|
|
1045
|
+
self._envelope_shape = envelope_shape
|
|
1046
|
+
self._field_mapping: Optional[dict[str, str]] = (
|
|
1047
|
+
dict(field_mapping) if field_mapping else None
|
|
1048
|
+
)
|
|
1049
|
+
self._order = order
|
|
1050
|
+
self._event_id_format = event_id_format
|
|
1051
|
+
|
|
1052
|
+
@property
|
|
1053
|
+
def mount_path(self) -> str:
|
|
1054
|
+
"""REST-poll mount path (read-only)."""
|
|
1055
|
+
return self._mount_path
|
|
1056
|
+
|
|
1057
|
+
def router(self) -> APIRouter:
|
|
1058
|
+
"""Return a FastAPI router implementing the REST-poll endpoint."""
|
|
1059
|
+
router = APIRouter()
|
|
1060
|
+
broadcaster = self._broadcaster
|
|
1061
|
+
cap = self._history_limit_cap
|
|
1062
|
+
default_limit = self._default_limit
|
|
1063
|
+
envelope_shape = self._envelope_shape
|
|
1064
|
+
field_mapping = self._field_mapping
|
|
1065
|
+
order = self._order
|
|
1066
|
+
event_id_format = self._event_id_format
|
|
1067
|
+
|
|
1068
|
+
def _render_event(evt: ConfigChangeEvent) -> dict[str, Any]:
|
|
1069
|
+
payload = evt.to_dict(alias_map=field_mapping)
|
|
1070
|
+
if event_id_format == "uuid_string":
|
|
1071
|
+
# The canonical ``event_id`` key may have been renamed via
|
|
1072
|
+
# field_mapping — rewrite the correct output key.
|
|
1073
|
+
out_key = (
|
|
1074
|
+
field_mapping.get("event_id", "event_id")
|
|
1075
|
+
if field_mapping
|
|
1076
|
+
else "event_id"
|
|
1077
|
+
)
|
|
1078
|
+
payload[out_key] = _int_event_id_to_uuid(int(evt.event_id))
|
|
1079
|
+
return payload
|
|
1080
|
+
|
|
1081
|
+
@router.get(self._mount_path)
|
|
1082
|
+
async def poll(
|
|
1083
|
+
since: int = Query(default=0, ge=0),
|
|
1084
|
+
limit: Optional[int] = Query(default=None, ge=1),
|
|
1085
|
+
) -> JSONResponse:
|
|
1086
|
+
effective_limit = default_limit if limit is None else limit
|
|
1087
|
+
if effective_limit > cap:
|
|
1088
|
+
effective_limit = cap
|
|
1089
|
+
# REST-poll cursor semantics require OLDEST-first pagination from
|
|
1090
|
+
# the since cursor. broadcaster.history() returns the newest N when
|
|
1091
|
+
# matches exceed limit, which is correct for SSE "most recent N"
|
|
1092
|
+
# replay but NOT for paging — fetch a wider window (cap) then trim
|
|
1093
|
+
# the oldest `effective_limit`.
|
|
1094
|
+
window = broadcaster.history(after_id=since, limit=cap)
|
|
1095
|
+
events = window[:effective_limit]
|
|
1096
|
+
# Cursor is computed from the oldest-first batch regardless of
|
|
1097
|
+
# presentation order — it is a pagination token over monotonic
|
|
1098
|
+
# event_ids, not a client-ordered key.
|
|
1099
|
+
next_cursor = events[-1].event_id if events else since
|
|
1100
|
+
if order == "newest_first":
|
|
1101
|
+
events = list(reversed(events))
|
|
1102
|
+
payload_events = [_render_event(evt) for evt in events]
|
|
1103
|
+
if envelope_shape == "events_only":
|
|
1104
|
+
return JSONResponse({"events": payload_events})
|
|
1105
|
+
return JSONResponse({"events": payload_events, "cursor": next_cursor})
|
|
1106
|
+
|
|
1107
|
+
return router
|
|
1108
|
+
|
|
1109
|
+
def mount(self, app: FastAPI) -> None:
|
|
1110
|
+
"""Convenience: include the REST-poll router into a FastAPI app."""
|
|
1111
|
+
app.include_router(self.router())
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
__all__ = [
|
|
1115
|
+
"ConfigChangeEvent",
|
|
1116
|
+
"EventBroadcaster",
|
|
1117
|
+
"HTTPIngestAdapter",
|
|
1118
|
+
"InMemoryEventBroadcaster",
|
|
1119
|
+
"PersistentEventBroadcaster",
|
|
1120
|
+
"RESTPollAdapter",
|
|
1121
|
+
"WebSocketAdapter",
|
|
1122
|
+
"create_a2a_events_router",
|
|
1123
|
+
]
|