mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-21 03:56:20 +00:00
* fix(gateway): preserve owner isolation when thread metadata is missing Follow-up to the #5448 review P1 (post-merge finding): owner_check=True also authorizes threads whose meta row is missing (legacy compatibility) or NULL-owner (shared/pre-auth data). _run_scope_user_id returned None for every trusted internal caller, which dropped the only remaining per-user filter on those threads and let an internal caller acting for owner A read owner B's persisted runs. _run_scope_user_id now takes the thread_id and consults the thread meta store: when an existing meta row establishes ownership, the authorized thread's runs are still read unfiltered (merged #5448 semantics, including owner-header-less internal callers); when the meta row is missing or NULL-owner, the filter falls back to the acting owner's raw stamp (the exact value start_run writes) — or the synthetic "default" identity without an owner header — so cross-user runs stay hidden. Isolation coverage uses the real MemoryThreadMetaStore with no metadata row (and a NULL-owner row) plus another user's persisted run: /runs and /runs/page must be empty and /runs/{run_id} must 404 for internal callers, while an established-ownership thread keeps the unfiltered read. * fix(gateway): gate run-scoped sub-resource reads for internal callers Review follow-up on #5484: the P1 owner-isolation class remained reachable through run-scoped sibling reads that apply no per-user filter at all — /runs/{run_id}/messages, /events, /join, /stream and /workspace-changes query by (thread_id, run_id) directly, so on missing/NULL-owner threads an internal caller acting for owner A could still read owner B's run content by id (verified 200 at the previous head). - Extract _thread_ownership_established (shared meta-row check) and add _require_run_visible_to_scope: for internal callers on threads without established ownership, the run's own user_id stamp must match the acting owner's raw value (or the legacy "default" stamp) or the read 404s. Established-ownership threads and every non-internal caller keep their existing thread-scoped semantics. - Wire the gate into join, stream, messages, events and workspace-changes; reword the now-stale messages comment to track the new scoping semantics. Regression tests: sub-resource reads 404 for a mismatched internal owner while the matching owner reads them normally, and the owner-less fallback branch (synthetic "default" filter on missing-meta threads) is pinned. Red confirmed against the pre-gate head. * fix(gateway): gate cancel and artifact archive for internal callers Review follow-up on #5484 round 2: POST /cancel resolved runs unscoped (require_existing=True only closes the missing-meta case — NULL-owner meta rows still pass), so an internal caller acting for a different owner could interrupt another owner's active run on a shared thread while /join and /stream were already gated. The archive manifest and download pair likewise leaked the other owner's delivered-file count and a 200-vs-409 delivery oracle on NULL-owner threads (missing-meta threads were already denied by require_existing=True). All three routes now call _require_run_visible_to_scope; its docstring records the extended coverage. NULL-owner-thread regression tests pin: a mismatched internal owner gets 404 from cancel, manifest and archive download, while the acting owner reaches the real conflict path (409 on a terminal run) and reads the manifest (file_count 2). * fix(gateway): tolerate state-less request stand-ins in the scope helpers The new owner-isolation gate and _run_scope_user_id read request.state directly, which crashed the FakeRequest-based unit suites for the run events, workspace-changes and scope endpoints (backend-unit-tests shards 1/2/4 on #5484). Read the state object defensively first: a request without state is simply not an internal caller, so those paths keep their pre-gate semantics. * fix(gateway): scope the thread token-usage aggregate by owner Review follow-up on #5484 round 4: GET /{thread_id}/token-usage called aggregate_tokens_by_thread(thread_id) with no user filter at all, so on missing/NULL-owner threads an internal caller acting for owner A read owner B's spend, model names, run count and (with include_active=true) live activity; the NULL-owner variant reached browser sessions too. build_context_usage's latest-model lookup was unfiltered as well. aggregate_tokens_by_thread gains an optional user_id (mirroring list_by_thread: explicit None = unfiltered, AUTO resolves the contextvar) in the memory store, the SQL repository and the store base; build_context_usage/_resolve_thread_model_name thread the scope through the latest-run lookup; the token-usage endpoint passes _run_scope_user_id's value. Established-ownership threads aggregate unfiltered as before; shared/missing-meta threads narrow to the acting identity. Stale helper-test comment reworded after the #5482 merge adaptation. * test(gateway): pin the unfiltered aggregate on established-ownership threads Review follow-up on #5484 round 5: the established-ownership branch of the token-usage scoping (store receives user_id=None) was the only unpinned half of the contract — the round-4 call-assertions never set app.state.thread_store, so their None came from the user-less stand-in path. test_token_usage_unfiltered_on_established_ownership_for_ internal_callers seeds an established meta row plus runs stamped by two different identities and asserts the totals fold (166 = 111 + 55); together with the isolation tests it now catches both failure modes (always-stamp narrowing and always-None leak).
492 lines
16 KiB
Python
492 lines
16 KiB
Python
"""Abstract interface for run metadata storage.
|
|
|
|
RunManager depends on this interface. Implementations:
|
|
- MemoryRunStore: in-memory dict (development, tests)
|
|
- Future: RunRepository backed by SQLAlchemy ORM
|
|
|
|
All methods accept an optional user_id for user isolation.
|
|
When user_id is None, no user filtering is applied (single-user mode).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import abc
|
|
from dataclasses import dataclass, field
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from deerflow.utils.time import coerce_iso
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class EditReplayVisibility:
|
|
hidden_source_run_ids: set[str] = field(default_factory=set)
|
|
hidden_attempt_run_ids: set[str] = field(default_factory=set)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class LeaseRenewal:
|
|
"""Result of renewing a run lease.
|
|
|
|
``cancel_action`` carries a durable cancellation request to the owning
|
|
worker without transferring lease ownership.
|
|
"""
|
|
|
|
renewed: bool
|
|
cancel_action: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class StatusFinalization:
|
|
"""Result of completing a run only if cancellation has not won."""
|
|
|
|
finalized: bool
|
|
cancel_action: str | None = None
|
|
|
|
|
|
class RunIdempotencyConflict(RuntimeError):
|
|
"""A run with the requested process-wide idempotency key already exists."""
|
|
|
|
def __init__(self, existing: dict[str, Any]) -> None:
|
|
super().__init__(f"Run idempotency key already belongs to {existing.get('run_id')}")
|
|
self.existing = existing
|
|
|
|
|
|
def normalize_run_created_at_iso(value: str) -> str:
|
|
"""Make a run timestamp parseable as ISO-8601.
|
|
|
|
``Z`` becomes ``+00:00``. An unencoded ``+`` in a query string arrives as a
|
|
space (``...T00:00:00 00:00``); restore the offset ``+``.
|
|
"""
|
|
value = value.strip().replace("Z", "+00:00")
|
|
if "T" in value and " " in value and "+" not in value.split("T", 1)[1]:
|
|
date, _, rest = value.partition("T")
|
|
time_part, sep, offset = rest.rpartition(" ")
|
|
if sep and offset.replace(":", "").isdigit():
|
|
value = f"{date}T{time_part}+{offset}"
|
|
return value
|
|
|
|
|
|
def format_run_cursor_created_at(value: str) -> str:
|
|
"""UTC keyset cursor using ``Z`` so ``+`` is not decoded as space in query strings."""
|
|
dt = datetime.fromisoformat(normalize_run_created_at_iso(value))
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=UTC)
|
|
else:
|
|
dt = dt.astimezone(UTC)
|
|
return dt.isoformat().replace("+00:00", "Z")
|
|
|
|
|
|
def parse_run_created_at(value: object) -> datetime:
|
|
"""Parse a stored run timestamp into an aware UTC datetime for keyset order."""
|
|
iso = coerce_iso(value)
|
|
if not iso:
|
|
return datetime.min.replace(tzinfo=UTC)
|
|
try:
|
|
dt = datetime.fromisoformat(normalize_run_created_at_iso(iso))
|
|
except ValueError:
|
|
return datetime.min.replace(tzinfo=UTC)
|
|
if dt.tzinfo is None:
|
|
return dt.replace(tzinfo=UTC)
|
|
return dt.astimezone(UTC)
|
|
|
|
|
|
def run_sort_key(created_at: object, run_id: str) -> tuple[datetime, str]:
|
|
"""Total order for newest-first run listings: ``created_at`` then ``run_id``."""
|
|
return (parse_run_created_at(created_at), run_id)
|
|
|
|
|
|
def run_is_before_cursor(
|
|
created_at: object,
|
|
run_id: str,
|
|
*,
|
|
before_created_at: str | None,
|
|
before_run_id: str | None,
|
|
) -> bool:
|
|
"""Return True when ``(created_at, run_id)`` is older than the keyset cursor."""
|
|
if not before_created_at or not before_run_id:
|
|
return True
|
|
return run_sort_key(created_at, run_id) < run_sort_key(before_created_at, before_run_id)
|
|
|
|
|
|
class RunStore(abc.ABC):
|
|
async def list_changed(
|
|
self,
|
|
*,
|
|
after_change_seq: int,
|
|
after_run_id: str,
|
|
user_id: str | None = None,
|
|
limit: int = 100,
|
|
) -> list[dict[str, Any]]:
|
|
"""List public run-record changes in stable ascending cursor order."""
|
|
raise NotImplementedError
|
|
|
|
@abc.abstractmethod
|
|
async def put(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
thread_id: str,
|
|
assistant_id: str | None = None,
|
|
user_id: str | None = None,
|
|
model_name: str | None = None,
|
|
status: str = "pending",
|
|
operation_kind: str = "run",
|
|
multitask_strategy: str = "reject",
|
|
metadata: dict[str, Any] | None = None,
|
|
kwargs: dict[str, Any] | None = None,
|
|
error: str | None = None,
|
|
stop_reason: str | None = None,
|
|
created_at: str | None = None,
|
|
owner_worker_id: str | None = None,
|
|
lease_expires_at: str | None = None,
|
|
idempotency_key: str | None = None,
|
|
) -> None:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def get(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
user_id: str | None = None,
|
|
) -> dict[str, Any] | None:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def list_by_thread(
|
|
self,
|
|
thread_id: str,
|
|
*,
|
|
user_id: str | None = None,
|
|
limit: int = 100,
|
|
before_created_at: str | None = None,
|
|
before_run_id: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
pass
|
|
|
|
async def list_successful_regenerate_sources(
|
|
self,
|
|
thread_id: str,
|
|
*,
|
|
user_id: str | None = None,
|
|
) -> set[str]:
|
|
"""Return source run IDs superseded by successful regenerations.
|
|
|
|
Implementations must inspect the complete thread and must not apply the
|
|
normal bounded run-list limit.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
async def list_edit_regenerate_runs(
|
|
self,
|
|
thread_id: str,
|
|
*,
|
|
user_id: str | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
"""Return all edit-regenerate attempt runs for one thread, oldest first."""
|
|
raise NotImplementedError
|
|
|
|
async def get_many_by_thread(
|
|
self,
|
|
thread_id: str,
|
|
run_ids: set[str],
|
|
*,
|
|
user_id: str | None = None,
|
|
) -> dict[str, dict[str, Any]]:
|
|
"""Batch-load selected runs belonging to one thread."""
|
|
raise NotImplementedError
|
|
|
|
@abc.abstractmethod
|
|
async def update_status(
|
|
self,
|
|
run_id: str,
|
|
status: str,
|
|
*,
|
|
error: str | None = None,
|
|
stop_reason: str | None = None,
|
|
) -> bool | None:
|
|
"""Update a run status.
|
|
|
|
Returns ``False`` when the store can prove no row was updated. Older or
|
|
lightweight stores may return ``None`` when they cannot report rowcount.
|
|
"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def start_run(self, run_id: str) -> bool:
|
|
"""Atomically transition a pending run to running.
|
|
|
|
Returns ``False`` when the row is missing or no longer pending.
|
|
"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def delete(self, run_id: str) -> None:
|
|
pass
|
|
|
|
async def delete_thread_operation(self, run_id: str, *, user_id: str | None) -> None:
|
|
"""Release an admitted thread operation for its recorded owner.
|
|
|
|
The default keeps legacy stores compatible: older implementations only
|
|
accepted ``run_id``. User-aware stores should override this method so
|
|
cleanup never depends on ambient request context.
|
|
"""
|
|
await self.delete(run_id)
|
|
|
|
@abc.abstractmethod
|
|
async def update_model_name(
|
|
self,
|
|
run_id: str,
|
|
model_name: str | None,
|
|
) -> None:
|
|
"""Update the model_name field for an existing run."""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def update_run_completion(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
status: str,
|
|
total_input_tokens: int = 0,
|
|
total_output_tokens: int = 0,
|
|
total_tokens: int = 0,
|
|
llm_call_count: int = 0,
|
|
lead_agent_tokens: int = 0,
|
|
subagent_tokens: int = 0,
|
|
middleware_tokens: int = 0,
|
|
token_usage_by_model: dict[str, dict[str, int]] | None = None,
|
|
message_count: int = 0,
|
|
last_ai_message: str | None = None,
|
|
first_human_message: str | None = None,
|
|
error: str | None = None,
|
|
) -> bool | None:
|
|
"""Persist final completion fields.
|
|
|
|
Implementations must not replace a different terminal status. Returns
|
|
``False`` when the row is missing or already has a conflicting terminal
|
|
outcome.
|
|
"""
|
|
pass
|
|
|
|
async def update_run_progress(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
total_input_tokens: int | None = None,
|
|
total_output_tokens: int | None = None,
|
|
total_tokens: int | None = None,
|
|
llm_call_count: int | None = None,
|
|
lead_agent_tokens: int | None = None,
|
|
subagent_tokens: int | None = None,
|
|
middleware_tokens: int | None = None,
|
|
token_usage_by_model: dict[str, dict[str, int]] | None = None,
|
|
message_count: int | None = None,
|
|
last_ai_message: str | None = None,
|
|
first_human_message: str | None = None,
|
|
) -> None:
|
|
"""Persist a best-effort running snapshot without changing run status."""
|
|
return None
|
|
|
|
@abc.abstractmethod
|
|
async def list_pending(self, *, before: str | None = None) -> list[dict[str, Any]]:
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def list_inflight(self, *, before: str | None = None) -> list[dict[str, Any]]:
|
|
"""Return persisted runs that are still ``pending`` or ``running``."""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def aggregate_tokens_by_thread(self, thread_id: str, *, include_active: bool = False, user_id: str | None = None) -> dict[str, Any]:
|
|
"""Aggregate token usage for completed runs in a thread.
|
|
|
|
Returns a dict with keys: total_tokens, total_input_tokens,
|
|
total_output_tokens, total_runs, by_model (model_name → {tokens, runs}),
|
|
by_caller ({lead_agent, subagent, middleware}).
|
|
"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def update_lease(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
owner_worker_id: str,
|
|
lease_expires_at: str,
|
|
) -> bool:
|
|
"""Renew the lease on an active run. Returns ``False`` when no row matched."""
|
|
pass
|
|
|
|
async def renew_lease(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
owner_worker_id: str,
|
|
lease_expires_at: str,
|
|
) -> LeaseRenewal:
|
|
"""Renew ownership and return any durable cancellation request.
|
|
|
|
The default wraps the legacy ``update_lease`` method and returns no
|
|
cancellation action, so third-party stores remain source-compatible
|
|
without adding a background read. Stores that support multi-process
|
|
cancellation must override this method to renew and observe the
|
|
request atomically.
|
|
"""
|
|
renewed = await self.update_lease(
|
|
run_id,
|
|
owner_worker_id=owner_worker_id,
|
|
lease_expires_at=lease_expires_at,
|
|
)
|
|
return LeaseRenewal(renewed=renewed)
|
|
|
|
async def request_cancel(self, run_id: str, *, action: str) -> str | None:
|
|
"""Persist the first cancellation action for an active run.
|
|
|
|
Implementations must update only ``pending`` or ``running`` rows and
|
|
return the winning action, or ``None`` when no active row matched.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
async def finalize_if_not_cancelled(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
status: str,
|
|
error: str | None = None,
|
|
stop_reason: str | None = None,
|
|
) -> StatusFinalization:
|
|
"""Atomically finalize an active run unless cancellation won.
|
|
|
|
The compatibility default is safe for stores that do not implement
|
|
durable cancellation.
|
|
"""
|
|
updated = await self.update_status(
|
|
run_id,
|
|
status,
|
|
error=error,
|
|
stop_reason=stop_reason,
|
|
)
|
|
return StatusFinalization(finalized=updated is not False)
|
|
|
|
@abc.abstractmethod
|
|
async def claim_for_takeover(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
grace_seconds: int,
|
|
error: str,
|
|
stop_reason: str | None = None,
|
|
) -> bool:
|
|
"""Atomically mark an expired-lease active run as ``error``.
|
|
|
|
Only rows whose lease has expired past *grace_seconds* (or whose
|
|
lease is NULL — pre-ownership data) are updated. The conditional
|
|
WHERE closes the race between the caller's stale read of the lease
|
|
and a concurrent heartbeat renewal by the owning worker. When
|
|
provided, *stop_reason* is persisted in the same atomic update.
|
|
|
|
Returns ``False`` when:
|
|
- the run is no longer ``pending`` / ``running``,
|
|
- the lease is still valid (owner heartbeat is alive), or
|
|
- the row doesn't exist.
|
|
"""
|
|
pass
|
|
|
|
@abc.abstractmethod
|
|
async def list_inflight_with_expired_lease(
|
|
self,
|
|
*,
|
|
before: str | None = None,
|
|
grace_seconds: int = 10,
|
|
) -> list[dict[str, Any]]:
|
|
"""Return active runs whose lease has expired (or is NULL for pre-ownership rows)."""
|
|
pass
|
|
|
|
async def create_thread_operation_atomic(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
thread_id: str,
|
|
owner_worker_id: str,
|
|
lease_expires_at: str | None,
|
|
operation_kind: str = "run",
|
|
multitask_strategy: str = "reject",
|
|
assistant_id: str | None = None,
|
|
user_id: str | None = None,
|
|
model_name: str | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
kwargs: dict[str, Any] | None = None,
|
|
created_at: str | None = None,
|
|
grace_seconds: int = 10,
|
|
idempotency_key: str | None = None,
|
|
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
"""Atomically create an active thread operation with cross-process uniqueness.
|
|
|
|
The default implementation preserves compatibility with stores that
|
|
still implement the former ``create_run_atomic`` interface. Legacy
|
|
stores support only normal run rows; internal operation kinds require
|
|
an implementation of this method.
|
|
|
|
Returns ``(new_run_dict, claimed_run_dicts)``.
|
|
Raises ``IntegrityError`` on conflict for ``reject`` strategy.
|
|
"""
|
|
legacy_impl = type(self).create_run_atomic
|
|
if legacy_impl is RunStore.create_run_atomic:
|
|
raise NotImplementedError("RunStore must implement create_thread_operation_atomic() or create_run_atomic()")
|
|
if operation_kind != "run":
|
|
raise NotImplementedError("Legacy RunStore.create_run_atomic() cannot create non-run thread operations")
|
|
if idempotency_key is not None:
|
|
raise NotImplementedError("Legacy RunStore.create_run_atomic() cannot guarantee idempotent admission")
|
|
return await self.create_run_atomic(
|
|
run_id,
|
|
thread_id=thread_id,
|
|
owner_worker_id=owner_worker_id,
|
|
lease_expires_at=lease_expires_at,
|
|
multitask_strategy=multitask_strategy,
|
|
assistant_id=assistant_id,
|
|
user_id=user_id,
|
|
model_name=model_name,
|
|
metadata=metadata,
|
|
kwargs=kwargs,
|
|
created_at=created_at,
|
|
grace_seconds=grace_seconds,
|
|
)
|
|
|
|
async def create_run_atomic(
|
|
self,
|
|
run_id: str,
|
|
*,
|
|
thread_id: str,
|
|
owner_worker_id: str,
|
|
lease_expires_at: str | None,
|
|
multitask_strategy: str = "reject",
|
|
assistant_id: str | None = None,
|
|
user_id: str | None = None,
|
|
model_name: str | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
kwargs: dict[str, Any] | None = None,
|
|
created_at: str | None = None,
|
|
grace_seconds: int = 10,
|
|
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
"""Deprecated compatibility alias for normal-run admission."""
|
|
operation_impl = type(self).create_thread_operation_atomic
|
|
if operation_impl is RunStore.create_thread_operation_atomic:
|
|
raise NotImplementedError("RunStore must implement create_thread_operation_atomic() or create_run_atomic()")
|
|
return await self.create_thread_operation_atomic(
|
|
run_id,
|
|
thread_id=thread_id,
|
|
owner_worker_id=owner_worker_id,
|
|
lease_expires_at=lease_expires_at,
|
|
operation_kind="run",
|
|
multitask_strategy=multitask_strategy,
|
|
assistant_id=assistant_id,
|
|
user_id=user_id,
|
|
model_name=model_name,
|
|
metadata=metadata,
|
|
kwargs=kwargs,
|
|
created_at=created_at,
|
|
grace_seconds=grace_seconds,
|
|
)
|