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.
Files changed (98) hide show
  1. cloud_dog_api_kit/__init__.py +170 -0
  2. cloud_dog_api_kit/a2a/__init__.py +53 -0
  3. cloud_dog_api_kit/a2a/card.py +138 -0
  4. cloud_dog_api_kit/a2a/events.py +1123 -0
  5. cloud_dog_api_kit/a2a/gateway.py +105 -0
  6. cloud_dog_api_kit/a2a/skill_audit.py +107 -0
  7. cloud_dog_api_kit/auth/__init__.py +35 -0
  8. cloud_dog_api_kit/auth/dependency.py +121 -0
  9. cloud_dog_api_kit/auth/rbac.py +107 -0
  10. cloud_dog_api_kit/auth/service_auth.py +54 -0
  11. cloud_dog_api_kit/clients/__init__.py +29 -0
  12. cloud_dog_api_kit/clients/circuit_breaker.py +39 -0
  13. cloud_dog_api_kit/clients/http_client.py +127 -0
  14. cloud_dog_api_kit/clients/retry.py +83 -0
  15. cloud_dog_api_kit/compat/__init__.py +37 -0
  16. cloud_dog_api_kit/compat/envelope.py +120 -0
  17. cloud_dog_api_kit/compat/profile.py +102 -0
  18. cloud_dog_api_kit/compat/routes.py +90 -0
  19. cloud_dog_api_kit/config.py +54 -0
  20. cloud_dog_api_kit/correlation/__init__.py +50 -0
  21. cloud_dog_api_kit/correlation/context.py +118 -0
  22. cloud_dog_api_kit/correlation/middleware.py +133 -0
  23. cloud_dog_api_kit/envelopes/__init__.py +37 -0
  24. cloud_dog_api_kit/envelopes/error.py +87 -0
  25. cloud_dog_api_kit/envelopes/success.py +84 -0
  26. cloud_dog_api_kit/errors/__init__.py +51 -0
  27. cloud_dog_api_kit/errors/exceptions.py +184 -0
  28. cloud_dog_api_kit/errors/handler.py +102 -0
  29. cloud_dog_api_kit/errors/taxonomy.py +62 -0
  30. cloud_dog_api_kit/factory.py +157 -0
  31. cloud_dog_api_kit/idempotency/__init__.py +28 -0
  32. cloud_dog_api_kit/idempotency/middleware.py +118 -0
  33. cloud_dog_api_kit/idempotency/store.py +100 -0
  34. cloud_dog_api_kit/lifecycle/__init__.py +39 -0
  35. cloud_dog_api_kit/lifecycle/hooks.py +75 -0
  36. cloud_dog_api_kit/lifecycle/shutdown.py +178 -0
  37. cloud_dog_api_kit/mcp/__init__.py +122 -0
  38. cloud_dog_api_kit/mcp/async_jobs.py +126 -0
  39. cloud_dog_api_kit/mcp/client_sdk.py +235 -0
  40. cloud_dog_api_kit/mcp/client_transport/__init__.py +47 -0
  41. cloud_dog_api_kit/mcp/client_transport/base.py +98 -0
  42. cloud_dog_api_kit/mcp/client_transport/exceptions.py +37 -0
  43. cloud_dog_api_kit/mcp/client_transport/http_jsonrpc.py +405 -0
  44. cloud_dog_api_kit/mcp/client_transport/legacy_sse.py +320 -0
  45. cloud_dog_api_kit/mcp/client_transport/stdio.py +322 -0
  46. cloud_dog_api_kit/mcp/client_transport/streamable_http.py +748 -0
  47. cloud_dog_api_kit/mcp/contract.py +113 -0
  48. cloud_dog_api_kit/mcp/error_mapper.py +84 -0
  49. cloud_dog_api_kit/mcp/gateway.py +117 -0
  50. cloud_dog_api_kit/mcp/legacy_sse.py +129 -0
  51. cloud_dog_api_kit/mcp/session.py +96 -0
  52. cloud_dog_api_kit/mcp/sync_handler.py +269 -0
  53. cloud_dog_api_kit/mcp/tool_audit.py +136 -0
  54. cloud_dog_api_kit/mcp/tool_router.py +180 -0
  55. cloud_dog_api_kit/mcp/transport.py +1041 -0
  56. cloud_dog_api_kit/middleware/__init__.py +39 -0
  57. cloud_dog_api_kit/middleware/cors.py +74 -0
  58. cloud_dog_api_kit/middleware/logging.py +98 -0
  59. cloud_dog_api_kit/middleware/request_size_limit.py +86 -0
  60. cloud_dog_api_kit/middleware/timeout.py +78 -0
  61. cloud_dog_api_kit/middleware/timing.py +52 -0
  62. cloud_dog_api_kit/openapi/__init__.py +30 -0
  63. cloud_dog_api_kit/openapi/customise.py +69 -0
  64. cloud_dog_api_kit/openapi/route.py +46 -0
  65. cloud_dog_api_kit/routers/__init__.py +41 -0
  66. cloud_dog_api_kit/routers/crud.py +173 -0
  67. cloud_dog_api_kit/routers/health.py +160 -0
  68. cloud_dog_api_kit/routers/jobs.py +69 -0
  69. cloud_dog_api_kit/routers/version.py +46 -0
  70. cloud_dog_api_kit/schemas/__init__.py +36 -0
  71. cloud_dog_api_kit/schemas/envelopes.py +37 -0
  72. cloud_dog_api_kit/schemas/filters.py +103 -0
  73. cloud_dog_api_kit/schemas/pagination.py +148 -0
  74. cloud_dog_api_kit/streaming/__init__.py +28 -0
  75. cloud_dog_api_kit/streaming/events.py +47 -0
  76. cloud_dog_api_kit/streaming/jsonl.py +68 -0
  77. cloud_dog_api_kit/streaming/sse.py +102 -0
  78. cloud_dog_api_kit/testing/__init__.py +46 -0
  79. cloud_dog_api_kit/testing/conformance.py +156 -0
  80. cloud_dog_api_kit/testing/fixtures.py +90 -0
  81. cloud_dog_api_kit/testing/flows/__init__.py +32 -0
  82. cloud_dog_api_kit/testing/flows/auth_flow.py +41 -0
  83. cloud_dog_api_kit/testing/flows/crud_flow.py +50 -0
  84. cloud_dog_api_kit/testing/flows/job_flow.py +42 -0
  85. cloud_dog_api_kit/testing/flows/streaming_flow.py +42 -0
  86. cloud_dog_api_kit/traceability_ids.py +84 -0
  87. cloud_dog_api_kit/versioning/__init__.py +30 -0
  88. cloud_dog_api_kit/versioning/header.py +52 -0
  89. cloud_dog_api_kit/web/__init__.py +7 -0
  90. cloud_dog_api_kit/web/proxy.py +222 -0
  91. cloud_dog_api_kit/webhook/__init__.py +29 -0
  92. cloud_dog_api_kit/webhook/signature.py +149 -0
  93. cloud_dog_api_kit-0.13.0.dist-info/METADATA +27 -0
  94. cloud_dog_api_kit-0.13.0.dist-info/RECORD +98 -0
  95. cloud_dog_api_kit-0.13.0.dist-info/WHEEL +4 -0
  96. cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENCE +190 -0
  97. cloud_dog_api_kit-0.13.0.dist-info/licenses/LICENSE +176 -0
  98. 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
+ ]