rayhpeng cb49dd67dc refactor(feedback): move the secondary adapters to app/adapters
Group them by bounded context instead of by technology, one file per
port, and align the directory name with the AWS Prescriptive Guidance
layout (entrypoints / domain-with-ports / adapters).

  app/infra/persistence/feedback.py
    -> app/adapters/feedback/feedback_repository.py   owned persistence
    -> app/adapters/feedback/run_lookup.py            anti-corruption layer

`persistence/` promised a technology-first classification that its own
contents contradicted: RunStoreRunLookup lived there while its docstring
said "no new SQL". Splitting per port makes that distinction structural.

SqlFeedbackRepository and _tz_aware move unchanged -- verified by
comparing their AST against the original rather than by eye. run_lookup.py
additionally gains a RunStore annotation behind TYPE_CHECKING (the module
is imported lazily by the composition root, so this keeps the runtime
import cost at zero), a docstring stating that this context owns no table
and writes no SQL against it, and a TODO recording the condition under
which the body is replaced: when the run context publishes a contract of
its own, the RunLookup port itself does not move.

Each module docstring opens with a fixed marker so the two kinds of
secondary adapter stay greppable:

  grep -rl "anti-corruption layer" app/adapters/

Filenames deliberately carry no sql_ / acl_ prefix: a prefix encodes an
implementation property, so switching storage would force a rename even
though the port -- and therefore the import path -- has not changed. The
class name already carries it. A prefix earns its place once one port has
several production implementations, which is not yet the case here.

app/infra/ held nothing else and is removed.
2026-07-28 17:34:10 +08:00

48 lines
2.0 KiB
Python

"""Secondary adapter (anti-corruption layer) -- narrowing RunStore to RunLookup.
Implements ``RunLookup`` from ``deerflow.domain.feedback.ports``. Unlike its
sibling ``feedback_repository.py``, this context does not own the ``runs``
table and writes no SQL against it: it asks its one question through the
component the run context already provides.
Being the downstream consumer of an upstream context is the normal shape
here, not debt. Until that context publishes a contract of its own, a thin
translation layer is what keeps its wide interface -- and its untyped dict
rows -- out of the domain.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from deerflow.domain.feedback.ports import RunLookup
if TYPE_CHECKING:
from deerflow.runtime.runs.store import RunStore
class RunStoreRunLookup(RunLookup):
"""Adapts the framework ``RunStore`` (wide interface) to the narrow
``RunLookup`` port -- reuses the existing lookup, no new SQL.
Used by the service to confirm a run belongs to the thread before
writing feedback. That is referential integrity, not authorization:
the router's ``@require_permission(..., owner_check=True)`` proves the
caller owns the thread, and this check proves the run belongs to it --
only together do they establish that the caller owns the run. Which is
why the port takes no ``user_id``.
TODO(hexagonal): this depends on ``RunStore``, an infrastructure
component, rather than on a contract published by the run context --
that context has not been through a hexagonal slice yet. When it
publishes one (a DTO, not its aggregate and not its repository),
replace the body of this class. The ``RunLookup`` port does not move.
"""
def __init__(self, run_store: RunStore) -> None:
self._run_store = run_store
async def thread_of(self, run_id: str) -> str | None:
run = await self._run_store.get(run_id)
return run.get("thread_id") if run else None