Vanzeren 095092418c
fix(gateway):unify thread id validation (#4589)
* fix(gateway): unify thread ID validation at the API boundary

Thread ID entry points accepted arbitrary strings while downstream
consumers (filesystem paths, Kubernetes Provisioner, JSONL event store)
each enforced different character restrictions, so invalid IDs were
persisted first and only failed later during sandbox/workspace init.

Centralize validation in deerflow.utils.thread_id (pattern
^[A-Za-z0-9_-]{1,64}$): validate at routers, RunCreateRequest,
scheduler dispatch, paths.py, JSONL store, embedded client, and align
the Provisioner pattern (pinned by a parity test). UUIDs are still
generated only when no ID is supplied; caller-supplied opaque IDs stay
supported.

Deliberate exceptions: DELETE /threads/{id} keeps str as the legacy
cleanup escape hatch (filesystem cleanup guarded), read-only
client.get_thread stays unvalidated, and scheduler rows with legacy
invalid IDs record a failed dispatch instead of raising out of the
poll loop.

* docs: document canonical thread ID contract

README: caller-supplied thread IDs need not be UUIDs; the canonical
pattern and per-endpoint behavior. AGENTS.md: the shared
deerflow.utils.thread_id contract, its enforcement boundaries, and the
legacy-ID escape hatches.

* fix(gateway): close thread ID validation gaps at remaining entry points

Follow-up to the canonical thread ID contract: a full audit found the
uniform-422 coverage only reached about half of the thread_id surfaces.

- routers: 18 routes still took a bare thread_id: str — 13 in
  thread_runs.py (including the five messages/events/workspace-changes
  reads that returned 500 on the JSONL event store vs 404/empty on the
  DB store), 4 read routes in threads.py, and the suggestions route
  flagged in review. DELETE /api/threads/{id} keeps str as the declared
  legacy-cleanup escape hatch.
- client: upload_files/delete_upload/list_uploads/get_artifact now
  validate up front, fulfilling the RFC's 'all mutating entry points'
  clause (get_thread stays unvalidated as the declared legacy read path).
- tui: the /resume literal-ref fallback validates against the canonical
  contract and reports a descriptive error instead of failing deep in
  the client.
- scripts/support_bundle.py: replace the drifted dot-allowing pattern
  with a byte-identical copy of THREAD_ID_PATTERN (kept local so the
  script still runs with a broken venv).

* test(gateway): guard the canonical thread ID contract against regressions

- test_thread_id_route_contract.py: static AST sweep asserting every
  route handler with a thread_id parameter annotates ThreadId
  (whitelist: the DELETE escape hatch), plus a runtime sweep hitting
  all 44 thread_id routes with a non-canonical ID and asserting a 422
  that names thread_id, plus a websocket upgrade-rejection case.
- test_thread_id_validation.py: client entry-point validation,
  support_bundle pattern parity, and TUI literal-ref fallback tests.
- Align two tests that encoded the old contract (dotted IDs).
2026-08-01 19:42:44 +08:00

104 lines
3.9 KiB
Python

"""Embedded session wiring for the TUI.
Owns construction of the ``DeerFlowClient`` (with a persistent checkpointer),
thread resolution for ``--continue`` / ``--resume`` (by id **or** title), and the
shared-persistence writer that makes terminal sessions visible in the Web UI (see
``deerflow.tui.persistence``).
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING: # avoid importing the heavy client during pure planning
from deerflow.client import DeerFlowClient
from .cli import LaunchPlan
from .persistence import ThreadMetaWriter, _LoopThread
@dataclass
class Session:
client: DeerFlowClient
writer: ThreadMetaWriter | None = None
_loop: _LoopThread | None = None
def resolve_thread(self, plan: LaunchPlan) -> str | None:
"""Resolve the thread id to run against, honoring --resume / --continue."""
if plan.thread_id:
return self.resolve_ref(plan.thread_id)
if plan.continue_recent:
threads = self.client.list_threads(limit=1).get("thread_list", [])
if threads:
return threads[0].get("thread_id")
return None
def resolve_ref(self, ref: str) -> str:
"""Resolve a thread reference (id or title) to a thread id.
Matches an existing thread by id first, then by exact title. Falls back to
the literal ref (treated as an id) when nothing matches, so an unknown id
still continues/creates that namespace — provided it satisfies the
canonical thread ID contract.
"""
try:
threads = self.client.list_threads(limit=100).get("thread_list", [])
except Exception: # noqa: BLE001 - resolution is best-effort
return self._validated_literal_ref(ref)
if any(t.get("thread_id") == ref for t in threads):
return ref
for thread in threads:
if (thread.get("title") or "") == ref:
return thread.get("thread_id") or self._validated_literal_ref(ref)
return self._validated_literal_ref(ref)
@staticmethod
def _validated_literal_ref(ref: str) -> str:
"""Validate a literal ref before it is adopted as a thread id."""
from deerflow.utils.thread_id import validate_thread_id
try:
return validate_thread_id(ref)
except ValueError as exc:
raise ValueError(f"Thread reference {ref!r} matches no existing thread and is not a valid thread id (expected 1-64 ASCII letters, digits, hyphens, or underscores).") from exc
def recent_threads(self, limit: int = 20) -> list[dict]:
return self.client.list_threads(limit=limit).get("thread_list", [])
def close(self) -> None:
"""Stop the background DB loop and dispose the engine (best-effort)."""
loop = self._loop
if loop is None:
return
self._loop = None
try:
from deerflow.persistence.engine import close_engine
loop.run(close_engine())
except Exception: # noqa: BLE001 - teardown is best-effort
pass
loop.close()
def open_session(persistence: bool = True) -> Session:
"""Build an embedded session backed by the configured checkpointer.
``persistence`` controls the shared ``threads_meta`` writer (and its background
DB loop/engine). Headless one-shots never use the writer, so they pass
``persistence=False`` to avoid standing up an event loop + connection pool only
to discard it.
"""
from deerflow.client import DeerFlowClient
from deerflow.runtime.checkpointer.provider import get_checkpointer
checkpointer = get_checkpointer()
client = DeerFlowClient(checkpointer=checkpointer)
if not persistence:
return Session(client=client)
from .persistence import build_persistence
loop, writer = build_persistence()
return Session(client=client, writer=writer, _loop=loop)