mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-21 12:06:18 +00:00
Switches production onto the hexagonal path. The legacy modules still compile and still have tests, but nothing assembles them any more; deleting them is the next commit, kept separate so it stays reviewable. Composition root ---------------- `app/composition.py::build_domain_services()` is now the only place an adapter is instantiated. It was extracted from `deps.py::langgraph_runtime` rather than added to it: wiring there was tangled with engine startup, orphan recovery and shutdown, so the one rule that governs it -- no SQL backend means no service and the routes answer 503 -- could not be tested without booting the whole application, and was held up by a single comment. It is a pure function of already-built infrastructure, so that rule is now an assertion. Feedback moved with it; doing this while adding schedule's five objects costs one change instead of two. Primary adapter --------------- The router is protocol translation only. What is gone is the giveaway: cron normalisation, `next_run_at` arithmetic, the re-arm rule and hand-written ownership checks all now live in the aggregate. Domain errors map to status codes through one table, so a new error surfaces as a 500 to be classified rather than being swallowed by whichever `except` was nearest. `spec_mapping` split in two (AWS's own layout puts the wire model under the entrypoint that owns it, and a primary adapter must not import a secondary one): `adapters/schedule/spec_column.py` for the JSON column, `routers/schedule/spec_wire.py` for the HTTP body. The two shapes are equal only by coincidence, so `test_schedule_spec_parity.py` runs every case against both and compares their outputs and messages directly. Function names differ per side so an import from the wrong one is visible. Explicit responses ------------------ Routes returned the ORM row's `to_dict()`, leaking `user_id`, `assistant_id`, `overlap_policy` and the two lease columns. The response models publish exactly the field set the frontend declares -- asserted in both directions, since an extra field is a leak and a missing one breaks a client. One wire detail was nearly changed by accident: Pydantic v2 serializes a UTC datetime as `...Z`, while the legacy `coerce_iso` path emitted `+00:00`. `UtcTimestamp` pins `isoformat()` so adopting a model does not silently alter the wire format for every client parsing these. Tests ----- 73 new cases: router behaviour driven through a real `ScheduleService` over in-memory fakes (a mocked service would let the error mapping pass without a domain error ever being raised), response shape, and the composition root. Router mappings verified by mutation -- a wrong status code or a dropped timezone fallback turns 9, 2 and 1 cases red respectively. Two lifespan tests carried a `SimpleNamespace` config that predates this change; `langgraph_runtime` now reads `config.scheduler`, so they were given one. Tolerating the gap with `getattr` was rejected: `AppConfig.scheduler` always exists, so the fallback would be unreachable in production and exist purely to excuse an incomplete test double. Full suite is back to its 24 pre-existing failures.
151 lines
4.9 KiB
Python
151 lines
4.9 KiB
Python
"""The HTTP shapes of the scheduled-task API.
|
|
|
|
The primary adapter's own model of what a client sends and receives -- the
|
|
counterpart to the domain aggregates, not a view of them. Two things follow
|
|
from that:
|
|
|
|
**Responses are an allowlist, not a dump.** The pre-migration router returned
|
|
the ORM row's ``to_dict()``, which leaked ``user_id``, ``lease_owner``,
|
|
``lease_expires_at``, ``overlap_policy`` and ``assistant_id`` -- lease fields
|
|
are scheduler-internal bookkeeping, and the other three are server-owned. None
|
|
appear in the frontend's ``ScheduledTask`` type or anywhere in its code, so
|
|
naming the fields explicitly here closes the leak without a client change. A
|
|
field added to the aggregate from now on stays invisible until it is
|
|
deliberately published.
|
|
|
|
**Timestamps keep the legacy spelling.** The legacy path emitted
|
|
``coerce_iso`` -> ``astimezone(UTC).isoformat()``, i.e.
|
|
``2026-08-01T09:00:00+00:00``. Pydantic v2 would serialize the same instant as
|
|
``...T09:00:00Z``, which is a silent wire change for every client parsing
|
|
these, so ``UtcTimestamp`` pins ``isoformat()`` explicitly. Both spellings are
|
|
valid ISO 8601 and JS ``Date`` accepts either -- the point is that changing it
|
|
is a decision, not a side effect of adopting a model.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
from typing import Annotated, Any
|
|
|
|
from pydantic import BaseModel, Field, PlainSerializer
|
|
|
|
from app.gateway.routers.schedule.spec_wire import spec_to_wire
|
|
from deerflow.domain.schedule.model import ScheduledRun, ScheduledTask
|
|
|
|
UtcTimestamp = Annotated[datetime, PlainSerializer(lambda value: value.isoformat(), return_type=str)]
|
|
|
|
|
|
def _utc(value: datetime | None) -> datetime | None:
|
|
"""Match the legacy `coerce_iso` normalisation exactly."""
|
|
if value is None:
|
|
return None
|
|
return value.astimezone(UTC) if value.tzinfo is not None else value.replace(tzinfo=UTC)
|
|
|
|
|
|
class ScheduledTaskCreateRequest(BaseModel):
|
|
thread_id: str | None = None
|
|
context_mode: str = "fresh_thread_per_run"
|
|
title: str = Field(min_length=1)
|
|
prompt: str = Field(min_length=1)
|
|
schedule_type: str
|
|
schedule_spec: dict[str, Any]
|
|
timezone: str
|
|
|
|
|
|
class ScheduledTaskUpdateRequest(BaseModel):
|
|
context_mode: str | None = None
|
|
thread_id: str | None = None
|
|
title: str | None = Field(default=None, min_length=1)
|
|
prompt: str | None = Field(default=None, min_length=1)
|
|
schedule_spec: dict[str, Any] | None = None
|
|
timezone: str | None = None
|
|
|
|
|
|
class ScheduledTaskResponse(BaseModel):
|
|
"""One scheduled task as the client sees it.
|
|
|
|
Mirrors the frontend's `ScheduledTask` type field for field.
|
|
"""
|
|
|
|
id: str
|
|
thread_id: str | None
|
|
context_mode: str
|
|
title: str
|
|
prompt: str
|
|
schedule_type: str
|
|
schedule_spec: dict[str, str]
|
|
timezone: str
|
|
status: str
|
|
next_run_at: UtcTimestamp | None
|
|
last_run_at: UtcTimestamp | None
|
|
last_run_id: str | None
|
|
last_thread_id: str | None
|
|
last_error: str | None
|
|
run_count: int
|
|
created_at: UtcTimestamp
|
|
updated_at: UtcTimestamp
|
|
|
|
@classmethod
|
|
def from_domain(cls, task: ScheduledTask) -> ScheduledTaskResponse:
|
|
return cls(
|
|
id=task.task_id,
|
|
thread_id=task.thread_id,
|
|
context_mode=str(task.context_mode),
|
|
title=task.title,
|
|
prompt=task.prompt,
|
|
schedule_type=str(task.schedule.schedule_type),
|
|
schedule_spec=spec_to_wire(task.schedule),
|
|
timezone=task.schedule.timezone,
|
|
status=str(task.status),
|
|
next_run_at=_utc(task.next_run_at),
|
|
last_run_at=_utc(task.last_run_at),
|
|
last_run_id=task.last_run_id,
|
|
last_thread_id=task.last_thread_id,
|
|
last_error=task.last_error,
|
|
run_count=task.run_count,
|
|
created_at=_utc(task.created_at),
|
|
updated_at=_utc(task.updated_at),
|
|
)
|
|
|
|
|
|
class ScheduledRunResponse(BaseModel):
|
|
"""One execution record. Mirrors the frontend's `ScheduledTaskRun` type."""
|
|
|
|
id: str
|
|
task_id: str
|
|
thread_id: str
|
|
run_id: str | None
|
|
scheduled_for: UtcTimestamp
|
|
trigger: str
|
|
status: str
|
|
error: str | None
|
|
started_at: UtcTimestamp | None
|
|
finished_at: UtcTimestamp | None
|
|
created_at: UtcTimestamp
|
|
|
|
@classmethod
|
|
def from_domain(cls, run: ScheduledRun) -> ScheduledRunResponse:
|
|
return cls(
|
|
id=run.record_id,
|
|
task_id=run.task_id,
|
|
thread_id=run.thread_id,
|
|
run_id=run.run_id,
|
|
scheduled_for=_utc(run.scheduled_for),
|
|
trigger=str(run.trigger),
|
|
status=str(run.status),
|
|
error=run.error,
|
|
started_at=_utc(run.started_at),
|
|
finished_at=_utc(run.finished_at),
|
|
created_at=_utc(run.created_at),
|
|
)
|
|
|
|
|
|
class TriggerResponse(BaseModel):
|
|
id: str
|
|
triggered: bool
|
|
|
|
|
|
class DeleteResponse(BaseModel):
|
|
id: str
|
|
deleted: bool
|