Xuehao Xu 4fe64ac916
feat(extensions): add request-scoped run evidence access (#5727)
* feat(extensions): add request-scoped run evidence reader

* fix(extensions): reject padded evidence reader identities
2026-09-23 15:25:45 +08:00

127 lines
4.4 KiB
Python

"""Stable, read-only run evidence contracts for extension services."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Protocol
RUN_EVIDENCE_READER_RESOLVER_KEY = "deerflow_extension_run_evidence_reader_resolver"
class InvalidRunEvidenceCursor(ValueError):
"""The cursor is malformed, unsupported, or belongs to another scope."""
@dataclass(frozen=True)
class RunStatusView:
"""Authoritative lifecycle projection for one visible run."""
thread_id: str = ""
run_id: str = ""
status: str = ""
created_at: str = ""
updated_at: str = ""
error: str | None = None
stop_reason: str | None = None
@dataclass(frozen=True)
class RunEventView:
"""Persisted evidence envelope; ``seq`` is monotonic within its thread.
Reader-returned content and metadata are detached from host storage.
Fields are frozen, but nested payload containers may be modified locally.
"""
thread_id: str = ""
run_id: str = ""
seq: int = 0
event_type: str = ""
category: str = ""
content: Any = None
metadata: dict[str, Any] = field(default_factory=dict)
created_at: str = ""
@dataclass(frozen=True)
class RunPage:
"""Changed runs plus the opaque cursor represented by this page."""
items: tuple[RunStatusView, ...] = ()
next_cursor: str | None = None
has_more: bool = False
@dataclass(frozen=True)
class RunEventPage:
"""One forward page from a known run's persisted event stream."""
items: tuple[RunEventView, ...] = ()
next_after_seq: int | None = None
has_more: bool = False
class RunEvidenceReader(Protocol):
"""Host-bound evidence reader; implementations never expose writes."""
async def list_changed_runs(self, *, cursor: str | None, limit: int) -> RunPage:
"""Return visible runs changed after ``cursor`` in stable order.
Callers persist ``next_cursor`` only after their own output is durable.
Reusing the input cursor is valid and may replay items. An empty page
means caught up; unsupported hosts omit the reader instead. Deletions
do not produce tombstones in this feed. Consumers reconciling a run
they already know must treat ``get_run_status(...) is None`` as absent.
"""
raise NotImplementedError("the host does not provide changed-run discovery")
async def list_run_events(
self,
*,
thread_id: str,
run_id: str,
after_seq: int | None,
limit: int,
) -> RunEventPage:
"""Return events with thread-scoped ``seq > after_seq``.
A missing or invisible run returns an empty page, preventing identity
probing across the reader's host-bound scope.
"""
raise NotImplementedError("the host does not provide run-event reading")
async def get_run_status(self, *, thread_id: str, run_id: str) -> RunStatusView | None:
"""Return authoritative status, or ``None`` when not visible."""
raise NotImplementedError("the host does not provide run-status reading")
def resolve_run_evidence_reader(request: object) -> RunEvidenceReader | None:
"""Resolve a reader bound to the authenticated request, if supported.
The resolver receives the request rather than a caller-supplied user ID or
principal, so the host remains responsible for authentication and scope
binding. Extensions should use this for user-facing routes; the global
reader injected into ``ExtensionRuntimeDeps`` is for trusted services.
Unsupported hosts return ``None``; denied authentication/authorization
raises ``PermissionError``. Unexpected resolver errors propagate.
"""
app = getattr(request, "app", None)
state = getattr(app, "state", None)
resolver = getattr(state, RUN_EVIDENCE_READER_RESOLVER_KEY, None)
if not callable(resolver):
return None
return resolver(request)
def require_run_evidence_reader(request: object) -> RunEvidenceReader:
"""Return a reader; unsupported hosts raise ``NotImplementedError``.
Authentication/authorization denial raises ``PermissionError``. Extensions
may translate these to HTTP 503 and 403 respectively. Resolver failures
propagate, never falling back to the global reader.
"""
reader = resolve_run_evidence_reader(request)
if reader is None:
raise NotImplementedError("request-scoped run evidence is unavailable")
return reader