mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 19:06:01 +00:00
refactor(schedule): drop the UNSET sentinel for plain None defaults
Every top-level update field is non-nullable as a business value, so None can safely double as "not supplied" -- the one field whose None is meaningful, thread_id, already travels inside ContextChange where it is unambiguous. The UnsetType singleton solved a three-state problem this command does not currently have; plain `| None = None` reads better. The constraint is documented on UpdateScheduledTask: a future field whose None is meaningful must ride inside a small change object the way thread_id does, rather than reintroducing a second convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
71169c9f83
commit
376c272f52
@ -6,14 +6,12 @@ are contracts consumed by adapters and tests, not everyday call-site symbols.
|
||||
"""
|
||||
|
||||
from deerflow.domain.schedule.commands import (
|
||||
UNSET,
|
||||
ContextChange,
|
||||
CreateScheduledTask,
|
||||
DeleteTask,
|
||||
PauseTask,
|
||||
ResumeTask,
|
||||
TriggerTask,
|
||||
UnsetType,
|
||||
UpdateScheduledTask,
|
||||
)
|
||||
from deerflow.domain.schedule.exceptions import (
|
||||
@ -51,7 +49,6 @@ __all__ = [
|
||||
"ACTIVE_RUN_STATUSES",
|
||||
"TERMINAL_RUN_STATUSES",
|
||||
"TERMINAL_TASK_STATUSES",
|
||||
"UNSET",
|
||||
"ActiveRunConflictError",
|
||||
"ConcurrentUpdateError",
|
||||
"ContextChange",
|
||||
@ -82,6 +79,5 @@ __all__ = [
|
||||
"ThreadNotFoundError",
|
||||
"TriggerKind",
|
||||
"TriggerTask",
|
||||
"UnsetType",
|
||||
"UpdateScheduledTask",
|
||||
]
|
||||
|
||||
@ -28,38 +28,12 @@ the client's intent.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Final, final
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deerflow.domain.schedule.model import ContextMode, ScheduleSpec
|
||||
|
||||
|
||||
@final
|
||||
class UnsetType:
|
||||
"""The type of ``UNSET`` -- "the client did not supply this field".
|
||||
|
||||
Partial updates need three states (absent, null, value). ``None`` cannot
|
||||
carry two of them, so absence gets its own singleton; a field that is
|
||||
``UNSET`` is left untouched by the handler.
|
||||
"""
|
||||
|
||||
_instance: UnsetType | None = None
|
||||
|
||||
def __new__(cls) -> UnsetType:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "UNSET"
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
UNSET: Final[UnsetType] = UnsetType()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextChange:
|
||||
"""A requested change of execution context.
|
||||
@ -88,14 +62,21 @@ class CreateScheduledTask:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpdateScheduledTask:
|
||||
"""Partially update a task; ``UNSET`` means "not supplied"."""
|
||||
"""Partially update a task; ``None`` means "not supplied".
|
||||
|
||||
``None`` can double as the absence marker ONLY because every field here
|
||||
is non-nullable as a business value -- there is no "set the title to
|
||||
null". Keep it that way: a future field whose ``None`` is meaningful
|
||||
must travel inside a small change object instead, the way the nullable
|
||||
``thread_id`` already rides inside ``ContextChange``.
|
||||
"""
|
||||
|
||||
task_id: str
|
||||
user_id: str
|
||||
title: str | UnsetType = UNSET
|
||||
prompt: str | UnsetType = UNSET
|
||||
schedule: ScheduleSpec | UnsetType = UNSET
|
||||
context: ContextChange | UnsetType = UNSET
|
||||
title: str | None = None
|
||||
prompt: str | None = None
|
||||
schedule: ScheduleSpec | None = None
|
||||
context: ContextChange | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@ -20,7 +20,6 @@ from dataclasses import dataclass, replace
|
||||
from datetime import datetime
|
||||
|
||||
from deerflow.domain.schedule.commands import (
|
||||
UNSET,
|
||||
CreateScheduledTask,
|
||||
DeleteTask,
|
||||
PauseTask,
|
||||
@ -164,10 +163,11 @@ class ScheduleService:
|
||||
return await self._tasks.add(task)
|
||||
|
||||
async def update_scheduled_task(self, cmd: UpdateScheduledTask, *, now: datetime) -> ScheduledTask:
|
||||
"""Partially update a task; ``UNSET`` means "not supplied".
|
||||
"""Partially update a task; ``None`` means "not supplied".
|
||||
|
||||
The one field for which ``None`` is a meaningful value, `thread_id`,
|
||||
travels inside `ContextChange` where it is unambiguous.
|
||||
Safe because every command field is non-nullable as a business value;
|
||||
the one field for which ``None`` is meaningful, `thread_id`, travels
|
||||
inside `ContextChange` where it is unambiguous.
|
||||
|
||||
Context and schedule are applied through the aggregate's own
|
||||
transitions, so the re-arm rule and the running-task gate cannot be
|
||||
@ -176,14 +176,14 @@ class ScheduleService:
|
||||
|
||||
async def apply(task: ScheduledTask) -> ScheduledTask:
|
||||
task.ensure_mutable()
|
||||
if cmd.context is not UNSET:
|
||||
if cmd.context is not None:
|
||||
task = task.with_context(cmd.context.context_mode, cmd.context.thread_id)
|
||||
await self._require_thread(task)
|
||||
if cmd.schedule is not UNSET:
|
||||
if cmd.schedule is not None:
|
||||
task = task.with_schedule(cmd.schedule, now=now, policy=self._policy)
|
||||
if cmd.title is not UNSET:
|
||||
if cmd.title is not None:
|
||||
task = replace(task, title=cmd.title)
|
||||
if cmd.prompt is not UNSET:
|
||||
if cmd.prompt is not None:
|
||||
task = replace(task, prompt=cmd.prompt)
|
||||
return task
|
||||
|
||||
|
||||
@ -24,14 +24,12 @@ from schedule_fakes import (
|
||||
)
|
||||
|
||||
from deerflow.domain.schedule.commands import (
|
||||
UNSET,
|
||||
ContextChange,
|
||||
CreateScheduledTask,
|
||||
DeleteTask,
|
||||
PauseTask,
|
||||
ResumeTask,
|
||||
TriggerTask,
|
||||
UnsetType,
|
||||
UpdateScheduledTask,
|
||||
)
|
||||
from deerflow.domain.schedule.exceptions import (
|
||||
@ -947,15 +945,16 @@ class TestTaskManagement:
|
||||
cmd = CreateScheduledTask(user_id="u", title="", prompt="", schedule=CRON, context_mode="not-a-mode", thread_id=None)
|
||||
assert cmd.context_mode == "not-a-mode"
|
||||
|
||||
async def test_unset_is_a_singleton_distinct_from_none(self):
|
||||
# Three states, not two: an update field is UNSET (leave it alone),
|
||||
# None can stay a meaningful value elsewhere, and UNSET is falsy so
|
||||
# it cannot masquerade as a supplied value.
|
||||
assert UnsetType() is UNSET
|
||||
async def test_an_omitted_update_field_defaults_to_none(self):
|
||||
# None means "not supplied" on every top-level update field. That is
|
||||
# safe because none of them admits null as a business value -- the one
|
||||
# nullable field, thread_id, travels inside ContextChange where None
|
||||
# is unambiguous.
|
||||
cmd = UpdateScheduledTask(task_id="t", user_id="u")
|
||||
assert cmd.title is UNSET
|
||||
assert cmd.title is not None
|
||||
assert not UNSET
|
||||
assert cmd.title is None
|
||||
assert cmd.prompt is None
|
||||
assert cmd.schedule is None
|
||||
assert cmd.context is None
|
||||
|
||||
async def test_update_leaves_omitted_fields_alone(self):
|
||||
service = make_service()
|
||||
@ -967,7 +966,7 @@ class TestTaskManagement:
|
||||
assert updated.prompt == task.prompt
|
||||
assert updated.schedule == task.schedule
|
||||
|
||||
async def test_update_with_everything_unset_changes_nothing(self):
|
||||
async def test_update_with_everything_omitted_changes_nothing(self):
|
||||
service = make_service()
|
||||
task = await create_cron_task(service)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user