diff --git a/backend/app/adapters/schedule/scheduled_task_repository.py b/backend/app/adapters/schedule/scheduled_task_repository.py
index 1d65e89ca..a7ff6a1fd 100644
--- a/backend/app/adapters/schedule/scheduled_task_repository.py
+++ b/backend/app/adapters/schedule/scheduled_task_repository.py
@@ -5,9 +5,10 @@ This context owns the `scheduled_tasks` table and writes its own queries, so
SQL/ORM vocabulary stops at this file: methods exchange domain objects and
normalize SQLite's tz-naive reads.
-Sibling of `scheduled_run_repository.py`. The stored `schedule_spec` JSON
-column is translated by `spec_column.py`, which belongs to this side of the
-boundary only -- the HTTP shape has its own translation next to the router.
+Sibling of `scheduled_run_repository.py`. Translating the row is this class's
+own private business, `_to_domain` / `_apply` and the two `schedule_spec`
+helpers beside them -- the HTTP shape has its own translation next to the
+router, and neither side imports the other.
**The queries are migrated unchanged from the legacy repository.** The claim
statement's `FOR UPDATE SKIP LOCKED` and the `protect_terminal` conditional
@@ -25,12 +26,13 @@ from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
-from app.adapters.schedule.spec_column import column_to_spec, spec_to_column
from deerflow.domain.schedule.model import (
TERMINAL_TASK_STATUSES,
ContextMode,
InvalidScheduleError,
ScheduledTask,
+ ScheduleSpec,
+ ScheduleType,
TaskStatus,
)
from deerflow.domain.schedule.ports import ScheduledTaskRepository
@@ -63,6 +65,36 @@ class SqlScheduledTaskRepository(ScheduledTaskRepository):
# ------------------------------------------------------------ conversion
+ @staticmethod
+ def _spec_from_row(row: ScheduledTaskRow) -> ScheduleSpec:
+ """The stored triple -> the value object.
+
+ All this side owns is which two keys this table's JSON uses; the rule
+ for turning them into a schedule belongs to the domain, which is why
+ the router's own mapping can state the same thing without either
+ importing the other.
+ """
+ stored = row.schedule_spec or {}
+ return ScheduleSpec.from_primitives(
+ row.schedule_type,
+ cron=stored.get("cron"),
+ run_at=stored.get("run_at"),
+ timezone=row.timezone,
+ )
+
+ @staticmethod
+ def _spec_to_column(spec: ScheduleSpec) -> dict[str, str]:
+ """The value object -> the stored JSON.
+
+ Emits the normalized value rather than echoing the caller's bytes: a
+ trailing-Z input is stored as "+00:00". Both forms parse back, so this
+ is deliberate -- preferable to carrying the raw dict on the value
+ object just to preserve the exact input spelling.
+ """
+ if spec.schedule_type is ScheduleType.CRON:
+ return {"cron": spec.cron or ""}
+ return {"run_at": spec.run_at.isoformat() if spec.run_at else ""}
+
@staticmethod
def _to_domain(row: ScheduledTaskRow) -> ScheduledTask:
"""ORM row -> aggregate.
@@ -78,7 +110,7 @@ class SqlScheduledTaskRepository(ScheduledTaskRepository):
user_id=row.user_id,
title=row.title,
prompt=row.prompt,
- schedule=column_to_spec(row.schedule_type, row.schedule_spec, row.timezone),
+ schedule=SqlScheduledTaskRepository._spec_from_row(row),
context_mode=ContextMode(row.context_mode),
thread_id=row.thread_id,
assistant_id=row.assistant_id,
@@ -110,7 +142,7 @@ class SqlScheduledTaskRepository(ScheduledTaskRepository):
row.title = task.title
row.prompt = task.prompt
row.schedule_type = str(task.schedule.schedule_type)
- row.schedule_spec = spec_to_column(task.schedule)
+ row.schedule_spec = SqlScheduledTaskRepository._spec_to_column(task.schedule)
row.timezone = task.schedule.timezone
row.context_mode = str(task.context_mode)
row.thread_id = task.thread_id
diff --git a/backend/app/adapters/schedule/spec_column.py b/backend/app/adapters/schedule/spec_column.py
deleted file mode 100644
index d0fc32c74..000000000
--- a/backend/app/adapters/schedule/spec_column.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""Boundary mapping (not a port implementation) -- the schedule_spec JSON column.
-
-Unlike its siblings in this package, this module implements no port: it is the
-translation `scheduled_task_repository` needs between the stored column and the
-value object, so the domain never grows a `Mapping[str, Any]` in its
-signatures.
-
-Its counterpart on the other side of the application is
-`app/gateway/routers/schedule/spec_wire.py`, which does the same job for the
-HTTP request/response body. The two are near-identical today and are still kept
-apart on purpose: a primary adapter must not import a secondary one, and the
-two shapes are only equal by coincidence -- the day the API grows a field the
-column does not have, they diverge without either side having to be untangled
-first. The function names differ (`column_to_spec` here, `wire_to_spec` there)
-so an import from the wrong side is visible rather than silently working.
-
-The duplication is bounded because the split inside each is deliberate:
-**structural** checks (is the key present? is it a str?) belong to the
-boundary, **value** rules (5-field cron, resolvable timezone, run_at present)
-belong to `ScheduleSpec.__post_init__`. Only the structural half is repeated,
-and `tests/test_schedule_spec_parity.py` feeds both the same malformed inputs
-so a drift between them fails a test rather than reaching production.
-"""
-
-from __future__ import annotations
-
-from collections.abc import Mapping
-from datetime import datetime
-from typing import Any
-
-from deerflow.domain.schedule.model import InvalidScheduleError, ScheduleSpec, ScheduleType
-
-
-def column_to_spec(schedule_type: str, spec: Mapping[str, Any] | None, timezone: str) -> ScheduleSpec:
- """Parse the stored/submitted triple into the value object.
-
- Raises:
- InvalidScheduleError: unknown schedule type, or the type's required key
- is missing or not a string. Raising a *domain* error from an
- adapter is intentional -- domain errors are the vocabulary the
- outer ring uses to say "this violates a domain rule", and the
- router maps this one family uniformly.
- """
- try:
- kind = ScheduleType(schedule_type)
- except ValueError as exc:
- raise InvalidScheduleError(f"Unsupported schedule_type: {schedule_type}") from exc
-
- fields = spec or {}
- if kind is ScheduleType.CRON:
- raw_cron = fields.get("cron")
- if not isinstance(raw_cron, str):
- raise InvalidScheduleError("cron schedule requires schedule_spec.cron")
- return ScheduleSpec.cron_schedule(raw_cron, timezone)
-
- raw_run_at = fields.get("run_at")
- if not isinstance(raw_run_at, str):
- raise InvalidScheduleError("once schedule requires run_at")
- try:
- run_at = datetime.fromisoformat(raw_run_at)
- except ValueError as exc:
- raise InvalidScheduleError(f"once schedule has an unparseable run_at: {raw_run_at!r}") from exc
- return ScheduleSpec.once_at(run_at, timezone)
-
-
-def spec_to_column(spec: ScheduleSpec) -> dict[str, str]:
- """Rebuild the persisted/wire JSON shape.
-
- Note this normalizes the stored string rather than echoing the caller's
- bytes: the frontend submits an already-UTC-aware ISO value
- (`zonedLocalToUtcIso`), so a trailing-Z input round-trips out as "+00:00".
- Both forms parse on either side, so the normalization is deliberate --
- preferable to carrying the raw dict on the value object just to preserve
- the exact input spelling.
- """
- if spec.schedule_type is ScheduleType.CRON:
- return {"cron": spec.cron or ""}
- return {"run_at": spec.run_at.isoformat() if spec.run_at else ""}
diff --git a/backend/app/gateway/routers/schedule/models.py b/backend/app/gateway/routers/schedule/models.py
index 51da2a13d..0d21312c1 100644
--- a/backend/app/gateway/routers/schedule/models.py
+++ b/backend/app/gateway/routers/schedule/models.py
@@ -29,8 +29,7 @@ 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
+from deerflow.domain.schedule.model import ScheduledRun, ScheduledTask, ScheduleSpec, ScheduleType
UtcTimestamp = Annotated[datetime, PlainSerializer(lambda value: value.isoformat(), return_type=str)]
@@ -42,6 +41,25 @@ def _utc(value: datetime | None) -> datetime | None:
return value.astimezone(UTC) if value.tzinfo is not None else value.replace(tzinfo=UTC)
+def _spec_to_wire(spec: ScheduleSpec) -> dict[str, str]:
+ """The value object -> the `schedule_spec` body field.
+
+ Emits the normalized value rather than echoing the caller's bytes: the
+ frontend submits an already-UTC-aware ISO value (`zonedLocalToUtcIso`), so
+ a trailing-Z input comes back as "+00:00". Both forms parse on either side,
+ so the normalization is deliberate.
+
+ All this side owns is which two keys the body uses. Its counterpart is
+ `SqlScheduledTaskRepository._spec_to_column`; the two are near-identical
+ today by coincidence, and are kept apart because a primary adapter must not
+ import a secondary one and because the day the API grows a field the column
+ does not have, they diverge without either having to be untangled.
+ """
+ if spec.schedule_type is ScheduleType.CRON:
+ return {"cron": spec.cron or ""}
+ return {"run_at": spec.run_at.isoformat() if spec.run_at else ""}
+
+
class ScheduledTaskCreateRequest(BaseModel):
thread_id: str | None = None
context_mode: str = "fresh_thread_per_run"
@@ -51,6 +69,22 @@ class ScheduledTaskCreateRequest(BaseModel):
schedule_spec: dict[str, Any]
timezone: str
+ def to_schedule(self) -> ScheduleSpec:
+ """Parse the submitted triple into the value object.
+
+ Raises `InvalidScheduleError` -- a *domain* error out of a primary
+ adapter, on purpose: it is the vocabulary the outer ring uses to say
+ "this violates a domain rule", and the router maps that one family onto
+ 422. Structural problems (key missing, wrong type) and value problems
+ (5-field cron, resolvable timezone) both arrive as it.
+ """
+ return ScheduleSpec.from_primitives(
+ self.schedule_type,
+ cron=self.schedule_spec.get("cron"),
+ run_at=self.schedule_spec.get("run_at"),
+ timezone=self.timezone,
+ )
+
class ScheduledTaskUpdateRequest(BaseModel):
context_mode: str | None = None
@@ -60,6 +94,30 @@ class ScheduledTaskUpdateRequest(BaseModel):
schedule_spec: dict[str, Any] | None = None
timezone: str | None = None
+ def to_schedule(self, current: ScheduleSpec) -> ScheduleSpec:
+ """Build the replacement spec, taking what was omitted from `current`.
+
+ The schedule *type* is not patchable; only its spec and its zone are.
+ Omitted parts are read straight off the current value object rather
+ than round-tripped through the wire shape and back.
+
+ `None` means "not supplied" on this endpoint -- an explicit `null` has
+ always meant that here, and unbinding is expressed by switching
+ `context_mode`, not by nulling a field.
+ """
+ if self.schedule_spec is not None:
+ cron = self.schedule_spec.get("cron")
+ run_at = self.schedule_spec.get("run_at")
+ else:
+ cron = current.cron
+ run_at = current.run_at.isoformat() if current.run_at else None
+ return ScheduleSpec.from_primitives(
+ str(current.schedule_type),
+ cron=cron,
+ run_at=run_at,
+ timezone=self.timezone if self.timezone is not None else current.timezone,
+ )
+
class ScheduledTaskResponse(BaseModel):
"""One scheduled task as the client sees it.
@@ -94,7 +152,7 @@ class ScheduledTaskResponse(BaseModel):
title=task.title,
prompt=task.prompt,
schedule_type=str(task.schedule.schedule_type),
- schedule_spec=spec_to_wire(task.schedule),
+ schedule_spec=_spec_to_wire(task.schedule),
timezone=task.schedule.timezone,
status=str(task.status),
next_run_at=_utc(task.next_run_at),
diff --git a/backend/app/gateway/routers/schedule/router.py b/backend/app/gateway/routers/schedule/router.py
index badf99401..6476712df 100644
--- a/backend/app/gateway/routers/schedule/router.py
+++ b/backend/app/gateway/routers/schedule/router.py
@@ -30,7 +30,6 @@ from app.gateway.routers.schedule.models import (
ScheduledTaskUpdateRequest,
TriggerResponse,
)
-from app.gateway.routers.schedule.spec_wire import spec_to_wire, wire_to_spec
from deerflow.domain.schedule.model import (
DispatchOutcome,
InvalidContextModeError,
@@ -104,7 +103,7 @@ async def create_scheduled_task(request: Request, body: ScheduledTaskCreateReque
user_id=user_id,
title=body.title,
prompt=body.prompt,
- schedule=wire_to_spec(body.schedule_type, body.schedule_spec, body.timezone),
+ schedule=body.to_schedule(),
context_mode=body.context_mode,
thread_id=body.thread_id,
now=datetime.now(UTC),
@@ -145,14 +144,7 @@ async def update_scheduled_task(
# of a patch of loose fields.
current = await service.get_task(task_id, user_id=user_id) if changes_schedule or changes_context else None
- schedule = None
- if current is not None and changes_schedule:
- schedule = wire_to_spec(
- # The schedule *type* is not patchable; only its spec and zone are.
- str(current.schedule.schedule_type),
- supplied.get("schedule_spec", spec_to_wire(current.schedule)),
- supplied.get("timezone", current.schedule.timezone),
- )
+ schedule = body.to_schedule(current.schedule) if current is not None and changes_schedule else None
context = None
if current is not None and changes_context:
diff --git a/backend/app/gateway/routers/schedule/spec_wire.py b/backend/app/gateway/routers/schedule/spec_wire.py
deleted file mode 100644
index ca891b483..000000000
--- a/backend/app/gateway/routers/schedule/spec_wire.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""Boundary mapping -- the schedule_spec HTTP body field.
-
-The primary adapter's own translation between the request/response shape and
-`ScheduleSpec`. Its counterpart is `app/adapters/schedule/spec_column.py`, which
-does the same job for the stored JSON column; see that module for why the two
-are kept apart rather than shared.
-
-The split is deliberate: **structural** checks (is the key present? is it a
-str?) belong here, **value** rules (5-field cron, resolvable timezone, run_at
-present) belong to `ScheduleSpec.__post_init__`. That is why this module is
-thin -- most of what could go wrong is caught one layer in, and reported with
-the same domain error, so the router maps one family onto 422.
-"""
-
-from __future__ import annotations
-
-from collections.abc import Mapping
-from datetime import datetime
-from typing import Any
-
-from deerflow.domain.schedule.model import InvalidScheduleError, ScheduleSpec, ScheduleType
-
-
-def wire_to_spec(schedule_type: str, spec: Mapping[str, Any] | None, timezone: str) -> ScheduleSpec:
- """Parse the submitted triple into the value object.
-
- Raises:
- InvalidScheduleError: unknown schedule type, or the type's required key
- is missing or not a string. Raising a *domain* error from a primary
- adapter is intentional -- domain errors are the vocabulary the outer
- ring uses to say "this violates a domain rule", and the router maps
- that one family uniformly onto 422.
- """
- try:
- kind = ScheduleType(schedule_type)
- except ValueError as exc:
- raise InvalidScheduleError(f"Unsupported schedule_type: {schedule_type}") from exc
-
- fields = spec or {}
- if kind is ScheduleType.CRON:
- raw_cron = fields.get("cron")
- if not isinstance(raw_cron, str):
- raise InvalidScheduleError("cron schedule requires schedule_spec.cron")
- return ScheduleSpec.cron_schedule(raw_cron, timezone)
-
- raw_run_at = fields.get("run_at")
- if not isinstance(raw_run_at, str):
- raise InvalidScheduleError("once schedule requires run_at")
- try:
- run_at = datetime.fromisoformat(raw_run_at)
- except ValueError as exc:
- raise InvalidScheduleError(f"once schedule has an unparseable run_at: {raw_run_at!r}") from exc
- return ScheduleSpec.once_at(run_at, timezone)
-
-
-def spec_to_wire(spec: ScheduleSpec) -> dict[str, str]:
- """Rebuild the response body shape.
-
- Note this emits the normalized value rather than echoing the caller's
- bytes: the frontend submits an already-UTC-aware ISO value
- (`zonedLocalToUtcIso`), so a trailing-Z input comes back as "+00:00". Both
- forms parse on either side, so the normalization is deliberate --
- preferable to carrying the raw dict on the value object just to preserve
- the exact input spelling.
- """
- if spec.schedule_type is ScheduleType.CRON:
- return {"cron": spec.cron or ""}
- return {"run_at": spec.run_at.isoformat() if spec.run_at else ""}
diff --git a/backend/docs/SCHEDULE_DESIGN_zh.md b/backend/docs/SCHEDULE_DESIGN_zh.md
index 1a3918a7f..2541cdd60 100644
--- a/backend/docs/SCHEDULE_DESIGN_zh.md
+++ b/backend/docs/SCHEDULE_DESIGN_zh.md
@@ -55,11 +55,11 @@ flowchart LR
SV --> M
end
subgraph PRIM["✅ 主适配器 · app"]
- R["gateway/routers/schedule/
router · models · spec_wire"]
+ R["gateway/routers/schedule/
router · models"]
PL["scheduler/poller.py
轮询时钟"]
end
subgraph SEC["✅ 从适配器 · app"]
- AD["adapters/schedule/
两个仓储 · run_launcher · thread_lookup
spec_column · run_outcome_mapping"]
+ AD["adapters/schedule/
两个仓储 · run_launcher · thread_lookup
run_outcome_mapping"]
end
subgraph CR["✅ 组合根"]
CO["composition.py
build_domain_services()"]
@@ -239,7 +239,8 @@ America/New_York 的 "0 9 * * *"
{"cron": "0 9 * * *"} ←──→ ScheduleSpec(CRON, "Asia/Shanghai", cron="0 9 * * *")
(JSON 列 / HTTP 字段) (值对象)
↑
- app/adapters/schedule/spec_mapping.py
+ ScheduleSpec.from_primitives()(领域,一份)
+ + 每个适配器自己那几行「本格式用哪两个键」
```
这不是洁癖,是一条可执行的判据:**一旦领域方法的签名里出现 `Mapping[str, Any]`,就说明领域在处理持久化/传输格式了**。解析这件事天然可以切成两半——结构校验(键在不在?值是不是字符串?)属于边界,值校验(cron 是不是 5 段、时区认不认识、`run_at` 有没有)属于 `__post_init__`。切开之后领域完全不需要看见 dict,签名全部强类型。
@@ -522,7 +523,7 @@ await service.update_task(
1. `model/enums.py` 的 `ScheduleType` 加成员
2. `model/spec.py`:加承载参数的字段(如 `interval_seconds`)、在 `__post_init__` 加校验、在 `next_after` 加一个分支
-3. 适配器的 `spec_mapping`(见 §4.5):进出两个方向各加一个分支
+3. `ScheduleSpec.from_primitives`(见 §4.5)加一个分支;两个适配器各自的 `_spec_to_column` / `_spec_to_wire` 也各加一个
4. **逐个检查 `task.py` 里四个 `status_after_*`**——它们目前都在问"是不是 ONCE",新类型会落进 else 分支。确认那是你要的语义(大概率是:interval 与 cron 同属周期性)
5. 域测试:新类型在 §5.4 四张表里各补一行
@@ -627,8 +628,7 @@ await service.update_task(
| 文件 | 内容 |
|---|---|
| [`gateway/routers/schedule/router.py`](../app/gateway/routers/schedule/router.py) | 10 个 HTTP 端点,只做协议转换 + 领域错误→状态码 |
-| [`gateway/routers/schedule/models.py`](../app/gateway/routers/schedule/models.py) | 请求/响应模型;响应是白名单,不是 ORM 转储 |
-| [`gateway/routers/schedule/spec_wire.py`](../app/gateway/routers/schedule/spec_wire.py) | HTTP body ↔ `ScheduleSpec` |
+| [`gateway/routers/schedule/models.py`](../app/gateway/routers/schedule/models.py) | 请求/响应模型;响应是白名单,不是 ORM 转储;`schedule_spec` 的进出转换是模型自己的方法 |
| [`app/scheduler/poller.py`](../app/scheduler/poller.py) | 轮询时钟 + 启动恢复 |
**从适配器**
@@ -639,7 +639,6 @@ await service.update_task(
| [`adapters/schedule/scheduled_run_repository.py`](../app/adapters/schedule/scheduled_run_repository.py) | 自有持久化;`IntegrityError → ActiveRunConflictError` 的翻译点 |
| [`adapters/schedule/run_launcher.py`](../app/adapters/schedule/run_launcher.py) | 防腐层;`ConflictError` / `HTTPException(409)` → `ThreadBusyError` |
| [`adapters/schedule/thread_lookup.py`](../app/adapters/schedule/thread_lookup.py) | 防腐层;`check_access(require_existing=True)` |
-| [`adapters/schedule/spec_column.py`](../app/adapters/schedule/spec_column.py) | JSON 列 ↔ `ScheduleSpec`(与 `spec_wire` 成对,见其 docstring) |
| [`adapters/schedule/run_outcome_mapping.py`](../app/adapters/schedule/run_outcome_mapping.py) | `RunRecord → RunOutcome \| None`,承接旧完成钩子的内联过滤 |
**组合根**
diff --git a/backend/packages/harness/deerflow/domain/schedule/model/spec.py b/backend/packages/harness/deerflow/domain/schedule/model/spec.py
index 84223af04..e3189abed 100644
--- a/backend/packages/harness/deerflow/domain/schedule/model/spec.py
+++ b/backend/packages/harness/deerflow/domain/schedule/model/spec.py
@@ -96,6 +96,50 @@ class ScheduleSpec:
"""Readability sugar — all validation lives in __post_init__."""
return cls(ScheduleType.ONCE, timezone, run_at=run_at)
+ @classmethod
+ def from_primitives(cls, schedule_type: str, *, cron: str | None, run_at: str | None, timezone: str) -> ScheduleSpec:
+ """Build from the loose strings both boundaries arrive as.
+
+ The HTTP body and the stored JSON column both carry a schedule type
+ plus a mapping, so the rule for turning that into a value object lives
+ here instead of being written out once per adapter. Each adapter keeps
+ only what is genuinely its own — which keys its own format uses — and
+ the errors below stay one family the router maps uniformly.
+
+ Four strings rather than a `Mapping[str, Any]` on purpose: a mapping in
+ this signature would mean the domain is handling a transport or storage
+ format, which is the thing this class exists to avoid.
+
+ `cron` and `run_at` are annotated as the contract expects them but
+ checked rather than trusted — both callers read from data a client can
+ influence, so a non-string must be rejected here instead of reaching
+ `fromisoformat` or the cron parser. The field the given type does not
+ use is ignored: a boundary that carries both keys is not an error.
+
+ Raises:
+ InvalidScheduleError: unknown schedule type, the type's field
+ missing or not a string, or an unparseable `run_at`. Value
+ rules — 5-field cron, resolvable timezone — are left to
+ __post_init__ and surface as the same error.
+ """
+ try:
+ kind = ScheduleType(schedule_type)
+ except ValueError as exc:
+ raise InvalidScheduleError(f"Unsupported schedule_type: {schedule_type}") from exc
+
+ if kind is ScheduleType.CRON:
+ if not isinstance(cron, str):
+ raise InvalidScheduleError("cron schedule requires schedule_spec.cron")
+ return cls.cron_schedule(cron, timezone)
+
+ if not isinstance(run_at, str):
+ raise InvalidScheduleError("once schedule requires run_at")
+ try:
+ parsed = datetime.fromisoformat(run_at)
+ except ValueError as exc:
+ raise InvalidScheduleError(f"once schedule has an unparseable run_at: {run_at!r}") from exc
+ return cls.once_at(parsed, timezone)
+
def next_after(self, now: datetime) -> datetime | None:
"""Next fire time in UTC, or None when there is no future occurrence.
diff --git a/backend/tests/test_schedule_domain.py b/backend/tests/test_schedule_domain.py
index d7a1b74cf..2a86bf894 100644
--- a/backend/tests/test_schedule_domain.py
+++ b/backend/tests/test_schedule_domain.py
@@ -112,6 +112,75 @@ class TestScheduleSpecInvariants:
assert spec.run_at == datetime(2026, 8, 1, 1, 0, tzinfo=UTC)
+# ---------------------------------------------------------------- A2. from_primitives
+
+
+class TestFromPrimitives:
+ """The one construction path that starts from untrusted strings.
+
+ Both boundaries -- the HTTP body and the stored JSON column -- arrive as a
+ schedule type plus a loose mapping, so the rule for turning that into a
+ value object lives here rather than being written out once per adapter.
+ Each adapter is left with the part that is genuinely its own: which keys
+ its format uses.
+
+ Values are type-checked rather than assumed. The signature says `str |
+ None` because that is the contract, but both callers read from data a
+ client can influence, so a non-string has to be rejected here rather than
+ reaching `datetime.fromisoformat` or the cron parser.
+ """
+
+ def test_builds_a_cron_schedule(self):
+ spec = ScheduleSpec.from_primitives("cron", cron="0 9 * * *", run_at=None, timezone="Asia/Shanghai")
+ assert spec.schedule_type is ScheduleType.CRON
+ assert spec.cron == "0 9 * * *"
+ assert spec.timezone == "Asia/Shanghai"
+
+ def test_builds_a_once_schedule_from_an_iso_string(self):
+ spec = ScheduleSpec.from_primitives("once", cron=None, run_at="2026-08-01T09:00:00", timezone="Asia/Shanghai")
+ assert spec.schedule_type is ScheduleType.ONCE
+ assert spec.run_at == datetime(2026, 8, 1, 1, 0, tzinfo=UTC)
+
+ def test_a_trailing_z_run_at_is_the_same_instant(self):
+ """The shape the frontend submits (`zonedLocalToUtcIso`)."""
+ spec = ScheduleSpec.from_primitives("once", cron=None, run_at="2026-08-01T01:00:00Z", timezone="Asia/Shanghai")
+ assert spec.run_at == datetime(2026, 8, 1, 1, 0, tzinfo=UTC)
+
+ def test_unknown_schedule_type_is_rejected(self):
+ with pytest.raises(InvalidScheduleError, match="Unsupported schedule_type"):
+ ScheduleSpec.from_primitives("teleport", cron="0 9 * * *", run_at=None, timezone="UTC")
+
+ @pytest.mark.parametrize("cron", [None, 5, ""])
+ def test_cron_without_a_usable_expression_is_rejected(self, cron):
+ with pytest.raises(InvalidScheduleError, match="requires schedule_spec"):
+ ScheduleSpec.from_primitives("cron", cron=cron, run_at=None, timezone="UTC")
+
+ @pytest.mark.parametrize("run_at", [None, 5])
+ def test_once_without_a_string_run_at_is_rejected(self, run_at):
+ with pytest.raises(InvalidScheduleError, match="requires run_at"):
+ ScheduleSpec.from_primitives("once", cron=None, run_at=run_at, timezone="UTC")
+
+ def test_an_unparseable_run_at_is_rejected(self):
+ with pytest.raises(InvalidScheduleError, match="unparseable run_at"):
+ ScheduleSpec.from_primitives("once", cron=None, run_at="next tuesday", timezone="UTC")
+
+ def test_the_irrelevant_field_is_ignored(self):
+ """A boundary that carries both keys must not be rejected for it."""
+ spec = ScheduleSpec.from_primitives("cron", cron="0 9 * * *", run_at="2026-08-01T09:00:00", timezone="UTC")
+ assert spec.run_at is None
+
+ def test_value_rules_still_come_from_post_init(self):
+ """Not re-implemented here -- this is why each adapter stays thin."""
+ with pytest.raises(InvalidScheduleError, match="exactly 5 fields"):
+ ScheduleSpec.from_primitives("cron", cron="0 9 * *", run_at=None, timezone="UTC")
+ with pytest.raises(InvalidScheduleError, match="Unknown timezone"):
+ ScheduleSpec.from_primitives("cron", cron="0 9 * * *", run_at=None, timezone="Mars/Olympus_Mons")
+
+ def test_whitespace_in_a_cron_expression_is_normalized(self):
+ spec = ScheduleSpec.from_primitives("cron", cron=" 0 9 * * * ", run_at=None, timezone="UTC")
+ assert spec.cron == "0 9 * * *"
+
+
# ---------------------------------------------------------------- B. next_after
diff --git a/backend/tests/test_schedule_router.py b/backend/tests/test_schedule_router.py
index 36611c86a..7120a7c94 100644
--- a/backend/tests/test_schedule_router.py
+++ b/backend/tests/test_schedule_router.py
@@ -302,6 +302,30 @@ class TestUpdate:
assert updated.schedule_spec == created.schedule_spec
assert updated.timezone == "Asia/Shanghai"
+ @pytest.mark.asyncio
+ async def test_a_timezone_change_on_a_once_task_keeps_the_same_instant(self, service, as_user):
+ """The `once` half of the fallback above.
+
+ The omitted `run_at` is read straight off the current value object, and
+ the stored instant is already offset-aware, so re-zoning the schedule
+ must relabel it without moving it.
+ """
+ created = await _create(
+ service,
+ schedule_type="once",
+ schedule_spec={"run_at": "2026-08-01T09:00:00+00:00"},
+ timezone="UTC",
+ )
+ updated = await _call(
+ router_module.update_scheduled_task,
+ task_id=created.id,
+ body=_update_body(timezone="Asia/Shanghai"),
+ service=service,
+ )
+ assert updated.schedule_spec == created.schedule_spec
+ assert updated.timezone == "Asia/Shanghai"
+ assert updated.next_run_at == created.next_run_at
+
@pytest.mark.asyncio
async def test_a_running_task_cannot_be_updated(self, service, tasks, as_user):
"""Red line: the mutability gate lives in the aggregate now, and the
diff --git a/backend/tests/test_schedule_spec_parity.py b/backend/tests/test_schedule_spec_parity.py
deleted file mode 100644
index bcc9b269f..000000000
--- a/backend/tests/test_schedule_spec_parity.py
+++ /dev/null
@@ -1,162 +0,0 @@
-"""Boundary tests for the two schedule_spec mappings, run against both.
-
-`schedule_spec` crosses two boundaries -- an HTTP body field and a JSON column
--- and each side owns its own translation, because a primary adapter must not
-import a secondary one. The shapes are equal today only by coincidence.
-
-Coincidence is exactly what needs a test. Every case here runs against both
-implementations, so the day one side is changed without the other, this file
-fails instead of production accepting a spec the repository cannot read back.
-That is the same N-cases-x-2-implementations shape `test_schedule_fakes.py`
-uses for the repository ports.
-
-The split each side enforces is the point: **structural** problems (key
-missing, wrong type, unknown schedule type) are caught at the boundary,
-**value** problems (5-field cron, resolvable timezone) are left to
-`ScheduleSpec.__post_init__` -- and both surface as the same domain error, so
-the router maps one family.
-"""
-
-from __future__ import annotations
-
-from datetime import UTC, datetime
-
-import pytest
-
-from app.adapters.schedule.spec_column import column_to_spec, spec_to_column
-from app.gateway.routers.schedule.spec_wire import spec_to_wire, wire_to_spec
-from deerflow.domain.schedule.model import InvalidScheduleError, ScheduleSpec, ScheduleType
-
-
-@pytest.fixture(
- params=[
- pytest.param((column_to_spec, spec_to_column), id="column"),
- pytest.param((wire_to_spec, spec_to_wire), id="wire"),
- ]
-)
-def mapping(request):
- """The (parse, emit) pair under test, once per boundary."""
- return request.param
-
-
-@pytest.fixture
-def parse(mapping):
- return mapping[0]
-
-
-@pytest.fixture
-def emit(mapping):
- return mapping[1]
-
-
-class TestStructuralChecks:
- def test_unknown_schedule_type_is_rejected(self, parse):
- """The one rule the domain cannot state: ScheduleSpec only accepts the
- enum, so a bad string has to be caught at the boundary."""
- with pytest.raises(InvalidScheduleError, match="Unsupported schedule_type"):
- parse("teleport", {"cron": "0 9 * * *"}, "UTC")
-
- @pytest.mark.parametrize("spec", [{}, None, {"cron": 5}, {"run_at": "..."}])
- def test_cron_without_a_string_cron_is_rejected(self, parse, spec):
- with pytest.raises(InvalidScheduleError, match="requires schedule_spec"):
- parse("cron", spec, "UTC")
-
- @pytest.mark.parametrize("spec", [{}, None, {"run_at": 5}, {"cron": "0 9 * * *"}])
- def test_once_without_a_string_run_at_is_rejected(self, parse, spec):
- with pytest.raises(InvalidScheduleError, match="requires run_at"):
- parse("once", spec, "UTC")
-
- def test_an_unparseable_run_at_is_rejected(self, parse):
- with pytest.raises(InvalidScheduleError, match="unparseable run_at"):
- parse("once", {"run_at": "next tuesday"}, "UTC")
-
-
-class TestValueChecksStayInTheDomain:
- """These are not re-implemented on either side -- they arrive from
- __post_init__, which is why each boundary can stay thin and why the
- duplication between them is bounded."""
-
- def test_a_bad_cron_expression_still_raises(self, parse):
- with pytest.raises(InvalidScheduleError, match="exactly 5 fields"):
- parse("cron", {"cron": "0 9 * *"}, "UTC")
-
- def test_a_bad_timezone_still_raises(self, parse):
- with pytest.raises(InvalidScheduleError, match="Unknown timezone"):
- parse("cron", {"cron": "0 9 * * *"}, "Mars/Olympus_Mons")
-
-
-class TestRoundTrip:
- def test_cron_round_trips_normalized(self, parse, emit):
- spec = parse("cron", {"cron": " 0 9 * * * "}, "Asia/Shanghai")
- assert spec.schedule_type is ScheduleType.CRON
- assert emit(spec) == {"cron": "0 9 * * *"}
- assert parse("cron", emit(spec), "Asia/Shanghai") == spec
-
- def test_once_round_trips_to_the_same_instant(self, parse, emit):
- spec = parse("once", {"run_at": "2026-08-01T09:00:00"}, "Asia/Shanghai")
- assert spec.run_at == datetime(2026, 8, 1, 1, 0, tzinfo=UTC)
- assert parse("once", emit(spec), "Asia/Shanghai") == spec
-
- def test_a_trailing_z_input_parses_and_re_emits_as_an_offset(self, parse, emit):
- """The shape the frontend actually submits (`zonedLocalToUtcIso`).
- Both spellings parse on either side, so normalizing is deliberate."""
- spec = parse("once", {"run_at": "2026-08-01T01:00:00Z"}, "Asia/Shanghai")
- emitted = emit(spec)["run_at"]
- assert emitted.endswith("+00:00")
- assert parse("once", {"run_at": emitted}, "Asia/Shanghai") == spec
-
- def test_emitted_output_is_idempotent(self, parse, emit):
- spec = ScheduleSpec.once_at(datetime(2026, 8, 1, 9, 0, tzinfo=UTC), "UTC")
- once = emit(spec)
- assert emit(parse("once", once, "UTC")) == once
-
-
-class TestCrossBoundaryParity:
- """The two sides must agree, not merely each be self-consistent.
-
- The cases above run against both and would catch a behavioural drift;
- these compare the outputs directly, which is what catches a *silent* one --
- a side that starts emitting a different but still-self-consistent shape.
- """
-
- @pytest.mark.parametrize(
- ("schedule_type", "spec", "timezone"),
- [
- ("cron", {"cron": "0 9 * * *"}, "UTC"),
- ("cron", {"cron": " 30 2 * * 1 "}, "Asia/Shanghai"),
- ("once", {"run_at": "2026-08-01T09:00:00"}, "Asia/Shanghai"),
- ("once", {"run_at": "2026-08-01T01:00:00Z"}, "UTC"),
- ],
- )
- def test_both_sides_parse_to_the_same_value_object(self, schedule_type, spec, timezone):
- assert column_to_spec(schedule_type, spec, timezone) == wire_to_spec(schedule_type, spec, timezone)
-
- @pytest.mark.parametrize(
- "spec",
- [
- ScheduleSpec.cron_schedule("0 9 * * *", "UTC"),
- ScheduleSpec.once_at(datetime(2026, 8, 1, 9, 0, tzinfo=UTC), "Asia/Shanghai"),
- ],
- )
- def test_both_sides_emit_the_same_shape(self, spec):
- assert spec_to_column(spec) == spec_to_wire(spec)
-
- @pytest.mark.parametrize(
- ("schedule_type", "spec"),
- [
- ("teleport", {"cron": "0 9 * * *"}),
- ("cron", {}),
- ("cron", {"cron": 5}),
- ("once", {}),
- ("once", {"run_at": 5}),
- ("once", {"run_at": "next tuesday"}),
- ],
- )
- def test_both_sides_reject_the_same_inputs_with_the_same_message(self, schedule_type, spec):
- """Same message, not just same type: the router turns this text into a
- 422 detail, so a divergence here is user-visible."""
- with pytest.raises(InvalidScheduleError) as from_column:
- column_to_spec(schedule_type, spec, "UTC")
- with pytest.raises(InvalidScheduleError) as from_wire:
- wire_to_spec(schedule_type, spec, "UTC")
- assert str(from_column.value) == str(from_wire.value)