mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 14:06:18 +00:00
* feat(extensions): add request-scoped run evidence reader * fix(extensions): reject padded evidence reader identities
127 lines
4.4 KiB
Python
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
|