deer-flow/backend/app/gateway/scheduled_task_validation.py
Jiarui Li 505c33c793
fix(scheduler): reject nonexistent local times at admission (#7281)
* fix(scheduler): reject nonexistent local times at admission

* fix(scheduler): label run_at errors and clarify cron DST behavior
2026-10-23 09:13:37 +08:00

411 lines
21 KiB
Python

"""Scheduled-task validation shared by HTTP and conversation schedule operations.
Every rule raises a coded error (``scheduled_task_errors.scheduler_error``), so
the tasks page and the agent receive the same ``code`` for the same mistake.
Request models declare only types; value rules live here.
"""
import asyncio
from collections.abc import Awaitable, Callable, Collection
from datetime import UTC, datetime
from typing import Any
from zoneinfo import ZoneInfo
from app.gateway.scheduled_task_errors import scheduler_error
from deerflow.persistence.scheduled_task_runs.finalization import is_host_pause_marker
from deerflow.scheduler.schedules import MAX_INTERVAL_SECONDS, next_run_at, normalize_cron_expression, parse_interval_seconds, validate_timezone
from deerflow.scheduler.stop_rule import MAX_STOP_CONDITION_CHARS, normalize_stop_condition
from deerflow.utils.goal_objective import MAX_GOAL_OBJECTIVE_CHARS, normalize_goal_objective
FREQUENT_THRESHOLD_SECONDS = 3600
TERMINAL_TASK_STATUSES = frozenset({"completed", "failed", "cancelled"})
CONTEXT_MODES = frozenset({"fresh_thread_per_run", "reuse_thread"})
SCHEDULE_TYPES = frozenset({"once", "cron", "interval"})
# Fields a PATCH (or chat update) may clear by sending null.
CLEARABLE_FIELDS = frozenset({"goal_objective", "max_runs", "end_at", "stop_condition"})
_BOUND_FIELDS = frozenset({"goal_objective", "max_runs", "end_at"})
def validate_interval_seconds(schedule_spec: dict[str, Any], min_seconds: int) -> int:
try:
every_seconds = parse_interval_seconds(schedule_spec)
except ValueError as exc:
raise scheduler_error(422, "invalid_schedule", str(exc)) from exc
if every_seconds < min_seconds:
raise scheduler_error(422, "interval_too_short", f"interval schedule must be at least {min_seconds} seconds", min_seconds=min_seconds)
if every_seconds > MAX_INTERVAL_SECONDS:
raise scheduler_error(422, "interval_too_long", f"interval schedule must be at most {MAX_INTERVAL_SECONDS} seconds", max_seconds=MAX_INTERVAL_SECONDS)
return every_seconds
def is_frequent_schedule(schedule_type: str, schedule_spec: dict[str, Any]) -> bool:
"""More often than hourly: a sub-hour interval, or a cron with a non-fixed minute."""
if schedule_type == "interval":
every_seconds = schedule_spec.get("every_seconds")
return isinstance(every_seconds, int) and not isinstance(every_seconds, bool) and every_seconds < FREQUENT_THRESHOLD_SECONDS
if schedule_type == "cron":
fields = str(schedule_spec.get("cron", "")).split()
minute = fields[0] if fields else ""
return not (minute.isascii() and minute.isdigit())
return False
def _as_datetime(value: datetime | str | None) -> datetime | None:
"""Read a stored timestamp (``_row_to_dict`` returns ISO strings) as aware UTC."""
if value is None:
return None
if isinstance(value, str):
value = datetime.fromisoformat(f"{value[:-1]}+00:00" if value.endswith("Z") else value)
return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
def _resolve_local_datetime(value: datetime, timezone: str) -> datetime:
"""Resolve a submitted wall time without silently moving it across a clock gap."""
if value.tzinfo is not None:
return value.astimezone(UTC)
zone = ZoneInfo(timezone)
resolved = value.replace(tzinfo=zone).astimezone(UTC)
if resolved.astimezone(zone).replace(tzinfo=None) != value:
raise ValueError(f"Local time {value.isoformat()} does not exist in timezone {timezone}; choose a valid local time or an explicit instant with a UTC offset")
return resolved
def _validate_once_wall_time(schedule_spec: dict[str, Any], timezone: str) -> None:
run_at = schedule_spec.get("run_at")
if isinstance(run_at, str):
try:
_resolve_local_datetime(datetime.fromisoformat(run_at), timezone)
except ValueError as exc:
raise ValueError(f"run_at: {exc}") from exc
def resolve_end_at(value: datetime | str | None, timezone: str) -> datetime | None:
"""Return ``end_at`` as aware UTC; a naive value is wall-clock time in the task's timezone.
UTC because SQLite drops the offset of DateTime(timezone=True) columns.
"""
if value is None:
return None
if isinstance(value, str):
try:
value = datetime.fromisoformat(f"{value[:-1]}+00:00" if value.endswith("Z") else value)
except ValueError as exc:
raise scheduler_error(422, "invalid_request", "end_at must be an ISO 8601 date-time") from exc
if not isinstance(value, datetime):
raise scheduler_error(422, "invalid_request", "end_at must be an ISO 8601 date-time")
try:
return _resolve_local_datetime(value, timezone)
except ValueError as exc:
raise scheduler_error(422, "invalid_request", f"end_at: {exc}") from exc
def validate_task_bounds(
*,
goal_objective: str | None,
max_runs: int | None,
end_at: datetime | str | None,
context_mode: str,
schedule_type: str,
schedule_spec: dict[str, Any],
next_run_at: datetime | str | None,
now: datetime,
used: int = 0,
require_limit_for_frequent: bool,
changed: Collection[str] | None = None,
) -> None:
"""Check goal and safety-cap rules on a (merged) task definition.
``changed`` limits the max_runs/end_at value checks to the fields a
request sets, so renaming a task whose cap is used up or whose end time
passed is not rejected; ``None`` checks everything (creation). A changed
``next_run_at`` (schedule edit, resume) re-checks that a still-future
``end_at`` leaves room for the next run; an ``end_at`` that already passed
is left to the repository's reactivation check (``limits_exhausted``).
"""
def checks(field: str) -> bool:
return changed is None or field in changed
if goal_objective is not None:
if not isinstance(goal_objective, str):
raise scheduler_error(422, "invalid_goal", "goal_objective must be a string", max_chars=MAX_GOAL_OBJECTIVE_CHARS)
try:
normalize_goal_objective(goal_objective)
except ValueError as exc:
raise scheduler_error(422, "invalid_goal", str(exc), max_chars=MAX_GOAL_OBJECTIVE_CHARS) from exc
if context_mode != "fresh_thread_per_run":
raise scheduler_error(422, "goal_requires_fresh_thread", "goal-backed schedules require fresh_thread_per_run")
if max_runs is not None and checks("max_runs"):
if isinstance(max_runs, bool) or not isinstance(max_runs, int) or max_runs < 1:
raise scheduler_error(422, "invalid_max_runs", "max_runs must be a positive integer")
if used > 0 and max_runs <= used:
raise scheduler_error(422, "max_runs_not_above_used", f"max_runs must be greater than the {used} automatic runs already used", used=used)
if end_at is not None and (checks("end_at") or checks("next_run_at")):
end = _as_datetime(end_at)
if end <= now and checks("end_at"):
raise scheduler_error(422, "end_at_in_past", "end_at must be in the future")
upcoming = _as_datetime(next_run_at)
if end > now and upcoming is not None and end <= upcoming:
raise scheduler_error(422, "end_at_before_first_run", "end_at must allow at least one scheduled occurrence")
if require_limit_for_frequent and max_runs is None and end_at is None and is_frequent_schedule(schedule_type, schedule_spec):
raise scheduler_error(422, "frequent_requires_limit", "Schedules more frequent than hourly require max_runs or end_at")
def _normalized_stop_condition(value: Any) -> str | None:
if value is not None and not isinstance(value, str):
raise scheduler_error(422, "invalid_stop_condition", "stop_condition must be a string", max_chars=MAX_STOP_CONDITION_CHARS)
try:
return normalize_stop_condition(value)
except ValueError as exc:
raise scheduler_error(422, "invalid_stop_condition", str(exc), max_chars=MAX_STOP_CONDITION_CHARS) from exc
def _check_once_time(schedule_type: str, upcoming: datetime | None, *, scheduler_config: Any, now: datetime) -> None:
if schedule_type != "once":
return
if upcoming is None:
raise scheduler_error(422, "once_in_past", "once schedule must be in the future")
min_seconds = scheduler_config.min_once_delay_seconds
if (upcoming - now).total_seconds() < min_seconds:
raise scheduler_error(422, "once_too_soon", f"once schedule must be at least {min_seconds} seconds in the future", min_seconds=min_seconds)
def _validate_schedule(schedule_type: str, schedule_spec: dict[str, Any], timezone: str, *, scheduler_config: Any, now: datetime) -> tuple[dict[str, Any], datetime | None]:
schedule_spec = dict(schedule_spec)
try:
validate_timezone(timezone)
except ValueError as exc:
raise scheduler_error(422, "invalid_timezone", str(exc)) from exc
try:
if schedule_type == "once":
_validate_once_wall_time(schedule_spec, timezone)
if schedule_type == "cron":
raw_cron = schedule_spec.get("cron")
if not isinstance(raw_cron, str):
raise scheduler_error(422, "invalid_schedule", "cron schedule requires schedule_spec.cron")
schedule_spec["cron"] = normalize_cron_expression(raw_cron)
if schedule_type == "interval":
validate_interval_seconds(schedule_spec, scheduler_config.min_once_delay_seconds)
upcoming = next_run_at(schedule_type, schedule_spec, timezone, now=now)
except (ValueError, OverflowError) as exc:
raise scheduler_error(422, "invalid_schedule", str(exc)) from exc
_check_once_time(schedule_type, upcoming, scheduler_config=scheduler_config, now=now)
return schedule_spec, upcoming
async def validate_scheduled_task_create(
body: Any,
*,
user_id: str,
thread_store: Any,
scheduler_config: Any,
assistant_resolver: Callable[..., Awaitable[str]],
now: datetime | None = None,
conversation_rules: bool = False,
) -> dict[str, Any]:
"""Return repository ``create`` fields after the shared checks.
The prompt is stored exactly as given; the stop condition is normalized
into its own field. ``conversation_rules`` applies the chat-only rule
that sub-hourly schedules need a safety cap.
"""
reference = now or datetime.now(UTC)
if body.context_mode not in CONTEXT_MODES:
raise scheduler_error(422, "invalid_context_mode", "Unsupported context_mode")
if body.context_mode == "reuse_thread":
if not body.thread_id:
raise scheduler_error(422, "reuse_thread_requires_thread", "reuse_thread requires thread_id")
if not await thread_store.check_access(body.thread_id, user_id, require_existing=True):
raise scheduler_error(404, "thread_not_found", "Thread not found")
if body.schedule_type not in SCHEDULE_TYPES:
raise scheduler_error(422, "invalid_schedule_type", "Unsupported schedule_type")
schedule_spec, upcoming = await asyncio.to_thread(_validate_schedule, body.schedule_type, body.schedule_spec, body.timezone, scheduler_config=scheduler_config, now=reference)
assistant_id = await assistant_resolver(body.assistant_id, user_id=user_id)
stop_condition = _normalized_stop_condition(getattr(body, "stop_condition", None))
goal_objective = getattr(body, "goal_objective", None)
max_runs = getattr(body, "max_runs", None)
end_at = await asyncio.to_thread(resolve_end_at, getattr(body, "end_at", None), body.timezone)
validate_task_bounds(
goal_objective=goal_objective,
max_runs=max_runs,
end_at=end_at,
context_mode=body.context_mode,
schedule_type=body.schedule_type,
schedule_spec=schedule_spec,
next_run_at=upcoming,
now=reference,
require_limit_for_frequent=conversation_rules,
)
return {
"thread_id": body.thread_id,
"context_mode": body.context_mode,
"assistant_id": assistant_id,
"title": body.title,
"prompt": body.prompt,
"stop_condition": stop_condition,
"schedule_type": body.schedule_type,
"schedule_spec": schedule_spec,
"timezone": body.timezone,
"next_run_at": upcoming,
"goal_objective": goal_objective,
"max_runs": max_runs,
"end_at": end_at,
}
def _recompute_schedule(existing: dict[str, Any], updates: dict[str, Any], *, scheduler_config: Any, now: datetime) -> None:
schedule_type = str(updates.get("schedule_type", existing["schedule_type"]))
if schedule_type != existing["schedule_type"] and not isinstance(updates.get("schedule_spec"), dict):
raise scheduler_error(422, "invalid_schedule", "changing schedule_type requires schedule_spec")
schedule_spec = dict(updates["schedule_spec"]) if isinstance(updates.get("schedule_spec"), dict) else dict(existing["schedule_spec"])
timezone = str(updates.get("timezone", existing["timezone"]))
try:
if schedule_type == "once":
# Preserve already admitted legacy wall times on no-op edits. New
# definitions and terminal reactivation must pass today's validation.
unchanged_definition = (
existing["schedule_type"] == "once"
and schedule_spec.get("run_at") == existing["schedule_spec"].get("run_at")
and timezone == existing["timezone"]
and existing["status"] not in TERMINAL_TASK_STATUSES
and existing.get("next_run_at") is not None
)
if not unchanged_definition:
_validate_once_wall_time(schedule_spec, timezone)
if schedule_type == "cron":
raw_cron = schedule_spec.get("cron")
if not isinstance(raw_cron, str):
raise scheduler_error(422, "invalid_schedule", "cron schedule requires schedule_spec.cron")
schedule_spec["cron"] = normalize_cron_expression(raw_cron)
if schedule_type == "interval":
every_seconds = validate_interval_seconds(schedule_spec, scheduler_config.min_once_delay_seconds)
try:
previous_seconds = parse_interval_seconds(dict(existing["schedule_spec"])) if existing["schedule_type"] == "interval" else None
except ValueError:
previous_seconds = None
# Re-saving an unchanged cadence keeps the next occurrence.
if previous_seconds == every_seconds and existing.get("next_run_at") is not None:
upcoming = existing["next_run_at"]
else:
upcoming = next_run_at(schedule_type, schedule_spec, timezone, now=now)
else:
upcoming = next_run_at(schedule_type, schedule_spec, timezone, now=now)
except (ValueError, OverflowError) as exc:
raise scheduler_error(422, "invalid_schedule", str(exc)) from exc
# An unchanged future instant already passed admission; editing other fields does not re-apply the creation-time minimum delay.
unchanged_once = schedule_type == existing["schedule_type"] == "once" and existing["status"] not in TERMINAL_TASK_STATUSES and upcoming is not None and upcoming == _as_datetime(existing["next_run_at"])
if not unchanged_once:
_check_once_time(schedule_type, _as_datetime(upcoming), scheduler_config=scheduler_config, now=now)
updates["schedule_spec"] = schedule_spec
updates["next_run_at"] = upcoming
# A terminal task whose schedule now has a future occurrence is re-armed:
# claim_due_tasks only admits "enabled" rows. The repository then checks
# its safety cap (limits_exhausted).
if upcoming is not None and existing["status"] in TERMINAL_TASK_STATUSES:
updates["status"] = "enabled"
async def validate_scheduled_task_update(
existing: dict[str, Any],
fields: dict[str, Any],
*,
user_id: str,
thread_store: Any,
scheduler_config: Any,
assistant_resolver: Callable[..., Awaitable[str]],
used: int,
now: datetime,
) -> dict[str, Any]:
"""Return repository ``updates`` for a PATCH or chat update.
``fields`` holds only what the request set. ``None`` clears
``goal_objective``, ``max_runs``, ``end_at`` and ``stop_condition``; for
other fields it means "unchanged", except ``assistant_id`` where it
restores the default agent.
"""
updates = {key: value for key, value in fields.items() if value is not None or key in CLEARABLE_FIELDS}
if "assistant_id" in fields:
updates["assistant_id"] = await assistant_resolver(fields["assistant_id"], user_id=user_id)
if "context_mode" in updates and updates["context_mode"] not in CONTEXT_MODES:
raise scheduler_error(422, "invalid_context_mode", "Unsupported context_mode")
if "schedule_type" in updates and updates["schedule_type"] not in SCHEDULE_TYPES:
raise scheduler_error(422, "invalid_schedule_type", "Unsupported schedule_type")
effective_context_mode = str(updates.get("context_mode", existing["context_mode"]))
effective_thread_id = updates.get("thread_id", existing.get("thread_id"))
if effective_context_mode == "reuse_thread":
if not effective_thread_id:
raise scheduler_error(422, "reuse_thread_requires_thread", "reuse_thread requires thread_id")
if not await thread_store.check_access(str(effective_thread_id), user_id, require_existing=True):
raise scheduler_error(404, "thread_not_found", "Thread not found")
elif effective_context_mode == "fresh_thread_per_run":
updates["thread_id"] = None
if "timezone" in updates:
try:
await asyncio.to_thread(validate_timezone, str(updates["timezone"]))
except ValueError as exc:
raise scheduler_error(422, "invalid_timezone", str(exc)) from exc
if {"schedule_spec", "timezone", "schedule_type"} & updates.keys():
await asyncio.to_thread(_recompute_schedule, existing, updates, scheduler_config=scheduler_config, now=now)
timezone = str(updates.get("timezone", existing["timezone"]))
if "stop_condition" in updates:
updates["stop_condition"] = _normalized_stop_condition(updates["stop_condition"])
if "end_at" in updates:
updates["end_at"] = await asyncio.to_thread(resolve_end_at, updates["end_at"], timezone)
validate_task_bounds(
goal_objective=updates.get("goal_objective", existing.get("goal_objective")),
max_runs=updates.get("max_runs", existing.get("max_runs")),
end_at=updates.get("end_at", existing.get("end_at")),
context_mode=effective_context_mode,
schedule_type=str(updates.get("schedule_type", existing["schedule_type"])),
schedule_spec=updates.get("schedule_spec", existing["schedule_spec"]),
next_run_at=updates.get("next_run_at", existing.get("next_run_at")),
now=now,
used=used,
require_limit_for_frequent=existing.get("origin_thread_id") is not None,
changed=(_BOUND_FIELDS | {"next_run_at"}) & updates.keys(),
)
return updates
def prepare_resume_updates(existing: dict[str, Any], renewal: dict[str, Any], *, used: int, now: datetime) -> dict[str, Any]:
"""Repository ``updates`` that resume a paused or finished task.
The next run is the stored one when still ahead, else recomputed from
``now`` (no catch-up run); a one-time task whose time passed cannot
resume. ``renewal`` may set or clear (``None``) ``max_runs`` / ``end_at``
in the same request; the repository rejects a reactivation whose cap is
still used up. The unmet streak is kept. An enabled task needs nothing.
"""
if existing.get("status") == "enabled":
return {}
renewal = {key: value for key, value in renewal.items() if key in {"max_runs", "end_at"}}
upcoming = _as_datetime(existing.get("next_run_at"))
if upcoming is None or upcoming <= now:
try:
upcoming = next_run_at(existing["schedule_type"], existing["schedule_spec"], existing["timezone"], now=now)
except (ValueError, OverflowError) as exc:
raise scheduler_error(422, "invalid_schedule", str(exc)) from exc
if upcoming is None:
raise scheduler_error(422, "once_time_passed", "This one-time task's time has passed; set a new run_at to run it again")
updates: dict[str, Any] = {"status": "enabled", "next_run_at": upcoming}
if is_host_pause_marker(existing.get("last_error")):
# A later manual pause must not read as "Paused by agent".
updates["last_error"] = None
if "end_at" in renewal:
renewal["end_at"] = resolve_end_at(renewal["end_at"], existing["timezone"])
updates.update(renewal)
validate_task_bounds(
goal_objective=existing.get("goal_objective"),
max_runs=updates.get("max_runs", existing.get("max_runs")),
end_at=updates.get("end_at", existing.get("end_at")),
context_mode=existing["context_mode"],
schedule_type=existing["schedule_type"],
schedule_spec=existing["schedule_spec"],
next_run_at=upcoming,
now=now,
used=used,
require_limit_for_frequent=existing.get("origin_thread_id") is not None,
changed={*renewal, "next_run_at"},
)
return updates