feat: 统一自动化任务调度配置并增强聊天流恢复
This commit is contained in:
@@ -7,12 +7,7 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from core.agentscope.tools.tool_config import AgentTool
|
||||
from models.automation_jobs import ScheduleType
|
||||
from schemas.automation import (
|
||||
AutomationJobConfig,
|
||||
MessageContextConfig,
|
||||
)
|
||||
from schemas.automation import AutomationJobConfig
|
||||
|
||||
_CONFIG_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
@@ -37,18 +32,4 @@ def load_static_automation_job_config(*, config_name: str) -> AutomationJobConfi
|
||||
loaded: Any = yaml.safe_load(file) or {}
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError(f"invalid automation config format: {path}")
|
||||
config = AutomationJobConfig.model_validate(loaded)
|
||||
if config_name == "memory_extraction":
|
||||
if config.enabled_tools != [AgentTool.MEMORY_WRITE, AgentTool.MEMORY_FORGET]:
|
||||
raise ValueError(
|
||||
"memory_extraction enabled_tools must be [memory.write, memory.forget]"
|
||||
)
|
||||
if config.context != MessageContextConfig(window_count=2):
|
||||
raise ValueError(
|
||||
"memory_extraction context must be latest_chat/day with window_count=2"
|
||||
)
|
||||
if config.schedule is None:
|
||||
raise ValueError("memory_extraction schedule must be configured")
|
||||
if config.schedule.type != ScheduleType.DAILY:
|
||||
raise ValueError("memory_extraction schedule type must be daily")
|
||||
return config
|
||||
return AutomationJobConfig.model_validate(loaded)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime, time, timedelta
|
||||
from typing import Protocol
|
||||
from uuid import UUID, uuid4
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
@@ -13,7 +13,7 @@ from core.logging import get_logger
|
||||
from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType
|
||||
from models.memories import MemoryType
|
||||
from models.profile import Profile
|
||||
from schemas.automation import AutomationJobConfig
|
||||
from schemas.automation import AutomationJobConfig, ScheduleConfig
|
||||
from schemas.memories.memory_content import UserMemoryContent, WorkProfileContent
|
||||
from schemas.user.context import parse_profile_settings
|
||||
from v1.auth.automation_static_config import load_static_automation_job_config
|
||||
@@ -44,9 +44,7 @@ class RegistrationBootstrapRepository:
|
||||
title: str,
|
||||
config: AutomationJobConfig,
|
||||
timezone_name: str,
|
||||
run_at: datetime,
|
||||
next_run_at: datetime,
|
||||
schedule_type: ScheduleType,
|
||||
) -> bool:
|
||||
stmt = (
|
||||
insert(AutomationJob)
|
||||
@@ -56,8 +54,6 @@ class RegistrationBootstrapRepository:
|
||||
bootstrap_key=bootstrap_key,
|
||||
title=title,
|
||||
config=config.model_dump(mode="json"),
|
||||
schedule_type=schedule_type,
|
||||
run_at=run_at,
|
||||
next_run_at=next_run_at,
|
||||
timezone=timezone_name,
|
||||
status=AutomationJobStatus.ACTIVE,
|
||||
@@ -103,9 +99,7 @@ class RegistrationBootstrapRepositoryLike(Protocol):
|
||||
title: str,
|
||||
config: AutomationJobConfig,
|
||||
timezone_name: str,
|
||||
run_at: datetime,
|
||||
next_run_at: datetime,
|
||||
schedule_type: ScheduleType,
|
||||
) -> bool: ...
|
||||
|
||||
async def upsert_initial_memory(
|
||||
@@ -123,35 +117,48 @@ class SessionLike(Protocol):
|
||||
async def rollback(self) -> None: ...
|
||||
|
||||
|
||||
def compute_next_local_time_utc(
|
||||
def compute_first_run_at_utc(
|
||||
*,
|
||||
now_utc: datetime,
|
||||
timezone_name: str,
|
||||
local_hour: int,
|
||||
local_minute: int,
|
||||
schedule_type: ScheduleType,
|
||||
) -> tuple[datetime, datetime]:
|
||||
schedule: ScheduleConfig,
|
||||
) -> datetime:
|
||||
try:
|
||||
timezone_obj = ZoneInfo(timezone_name)
|
||||
except ZoneInfoNotFoundError:
|
||||
timezone_obj = ZoneInfo("UTC")
|
||||
|
||||
local_now = now_utc.astimezone(timezone_obj)
|
||||
today_run_local = local_now.replace(
|
||||
hour=local_hour,
|
||||
minute=local_minute,
|
||||
second=0,
|
||||
microsecond=0,
|
||||
run_clock = time(
|
||||
hour=schedule.run_at.hour,
|
||||
minute=schedule.run_at.minute,
|
||||
tzinfo=timezone_obj,
|
||||
)
|
||||
run_local = (
|
||||
today_run_local
|
||||
if local_now <= today_run_local
|
||||
else today_run_local + timedelta(days=1)
|
||||
)
|
||||
if schedule_type == ScheduleType.WEEKLY:
|
||||
next_local = run_local + timedelta(weeks=1)
|
||||
else:
|
||||
next_local = run_local + timedelta(days=1)
|
||||
return run_local.astimezone(UTC), next_local.astimezone(UTC)
|
||||
|
||||
if schedule.type == ScheduleType.DAILY:
|
||||
candidate_local = datetime.combine(local_now.date(), run_clock)
|
||||
if candidate_local <= local_now:
|
||||
candidate_local = candidate_local + timedelta(days=1)
|
||||
return candidate_local.astimezone(UTC)
|
||||
|
||||
weekdays = schedule.weekdays or []
|
||||
if not weekdays:
|
||||
raise ValueError("weekly schedule requires weekdays")
|
||||
|
||||
normalized_weekdays = sorted(set(weekdays))
|
||||
for day_offset in range(0, 8):
|
||||
candidate_day = local_now.date() + timedelta(days=day_offset)
|
||||
if candidate_day.isoweekday() not in normalized_weekdays:
|
||||
continue
|
||||
candidate_local = datetime.combine(candidate_day, run_clock)
|
||||
if candidate_local > local_now:
|
||||
return candidate_local.astimezone(UTC)
|
||||
|
||||
fallback_day = local_now.date() + timedelta(days=7)
|
||||
while fallback_day.isoweekday() not in normalized_weekdays:
|
||||
fallback_day = fallback_day + timedelta(days=1)
|
||||
fallback_local = datetime.combine(fallback_day, run_clock)
|
||||
return fallback_local.astimezone(UTC)
|
||||
|
||||
|
||||
class RegistrationAutomationBootstrapService:
|
||||
@@ -203,12 +210,10 @@ class RegistrationAutomationBootstrapService:
|
||||
raise ValueError(
|
||||
f"bootstrap job {bootstrap_key} has no schedule configured"
|
||||
)
|
||||
run_at, next_run_at = compute_next_local_time_utc(
|
||||
next_run_at = compute_first_run_at_utc(
|
||||
now_utc=datetime.now(UTC),
|
||||
timezone_name=timezone_name,
|
||||
local_hour=schedule.run_at.hour,
|
||||
local_minute=schedule.run_at.minute,
|
||||
schedule_type=schedule.type,
|
||||
schedule=schedule,
|
||||
)
|
||||
inserted = (
|
||||
await self._repository.insert_bootstrap_automation_job_if_absent(
|
||||
@@ -217,9 +222,7 @@ class RegistrationAutomationBootstrapService:
|
||||
title=str(definition["title"]),
|
||||
config=job_config,
|
||||
timezone_name=timezone_name,
|
||||
run_at=run_at,
|
||||
next_run_at=next_run_at,
|
||||
schedule_type=schedule.type,
|
||||
)
|
||||
)
|
||||
inserted_any = inserted_any or inserted
|
||||
|
||||
@@ -11,6 +11,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
from core.db.base_repository import BaseRepository
|
||||
from models.agent_chat_session import AgentChatSession, SessionType
|
||||
from models.automation_jobs import AutomationJob, AutomationJobStatus, ScheduleType
|
||||
from schemas.automation import AutomationJobConfig, ScheduleConfig
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from v1.automation_jobs.schemas import (
|
||||
@@ -107,26 +108,45 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
|
||||
except ZoneInfoNotFoundError:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
def _compute_initial_next_run_at(
|
||||
def _compute_next_run_at(
|
||||
self,
|
||||
*,
|
||||
run_at: time,
|
||||
schedule: ScheduleConfig,
|
||||
timezone_str: str,
|
||||
now_utc: datetime,
|
||||
schedule_type: ScheduleType,
|
||||
) -> datetime:
|
||||
tz = self._resolve_timezone(timezone_str)
|
||||
local_now = now_utc.astimezone(tz)
|
||||
run_at_local = datetime.combine(local_now.date(), run_at, tz)
|
||||
if run_at_local.tzinfo is None:
|
||||
run_at_local = run_at_local.replace(tzinfo=tz)
|
||||
next_run_at = run_at_local
|
||||
if next_run_at <= local_now:
|
||||
if schedule_type == ScheduleType.DAILY:
|
||||
next_run_at = next_run_at + timedelta(days=1)
|
||||
else:
|
||||
next_run_at = next_run_at + timedelta(weeks=1)
|
||||
return next_run_at.astimezone(timezone.utc)
|
||||
run_clock = time(
|
||||
hour=schedule.run_at.hour,
|
||||
minute=schedule.run_at.minute,
|
||||
tzinfo=tz,
|
||||
)
|
||||
|
||||
if schedule.type == ScheduleType.DAILY:
|
||||
candidate_local = datetime.combine(local_now.date(), run_clock)
|
||||
if candidate_local <= local_now:
|
||||
candidate_local = candidate_local + timedelta(days=1)
|
||||
return candidate_local.astimezone(timezone.utc)
|
||||
|
||||
weekdays = schedule.weekdays or []
|
||||
if not weekdays:
|
||||
raise ValueError("weekly schedule requires weekdays")
|
||||
|
||||
normalized_weekdays = sorted(set(weekdays))
|
||||
for day_offset in range(0, 8):
|
||||
candidate_day = local_now.date() + timedelta(days=day_offset)
|
||||
if candidate_day.isoweekday() not in normalized_weekdays:
|
||||
continue
|
||||
candidate_local = datetime.combine(candidate_day, run_clock)
|
||||
if candidate_local > local_now:
|
||||
return candidate_local.astimezone(timezone.utc)
|
||||
|
||||
fallback_day = local_now.date() + timedelta(days=7)
|
||||
while fallback_day.isoweekday() not in normalized_weekdays:
|
||||
fallback_day = fallback_day + timedelta(days=1)
|
||||
fallback_local = datetime.combine(fallback_day, run_clock)
|
||||
return fallback_local.astimezone(timezone.utc)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
@@ -134,16 +154,14 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
|
||||
data: AutomationJobCreateRequest,
|
||||
) -> AutomationJob:
|
||||
now_utc = datetime.now(tz=timezone.utc)
|
||||
timezone_obj = self._resolve_timezone(data.timezone)
|
||||
local_now = now_utc.astimezone(timezone_obj)
|
||||
date_ref = local_now.date()
|
||||
local_dt = datetime.combine(date_ref, data.run_at, timezone_obj)
|
||||
run_at_datetime = local_dt.astimezone(timezone.utc)
|
||||
next_run_at = self._compute_initial_next_run_at(
|
||||
run_at=data.run_at,
|
||||
schedule = data.config.schedule
|
||||
if schedule is None:
|
||||
raise ValueError("config.schedule is required")
|
||||
|
||||
next_run_at = self._compute_next_run_at(
|
||||
schedule=schedule,
|
||||
timezone_str=data.timezone,
|
||||
now_utc=now_utc,
|
||||
schedule_type=data.schedule_type,
|
||||
)
|
||||
|
||||
new_job = AutomationJob(
|
||||
@@ -151,8 +169,6 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
|
||||
created_by=owner_id,
|
||||
bootstrap_key=None,
|
||||
title=data.title,
|
||||
schedule_type=data.schedule_type,
|
||||
run_at=run_at_datetime,
|
||||
timezone=data.timezone,
|
||||
status=data.status,
|
||||
config=data.config.model_dump(mode="json"),
|
||||
@@ -168,69 +184,44 @@ class AutomationJobsRepository(BaseRepository[AutomationJob]):
|
||||
data: AutomationJobUpdateRequest,
|
||||
) -> AutomationJob | None:
|
||||
update_values: dict[str, object] = {}
|
||||
existing_job: AutomationJob | None = None
|
||||
existing_job = await self.get_by_id(job_id)
|
||||
if existing_job is None:
|
||||
return None
|
||||
|
||||
if data.title is not None:
|
||||
update_values["title"] = data.title
|
||||
if data.schedule_type is not None:
|
||||
update_values["schedule_type"] = data.schedule_type
|
||||
|
||||
should_recompute_schedule = (
|
||||
data.run_at is not None
|
||||
or data.schedule_type is not None
|
||||
or data.timezone is not None
|
||||
)
|
||||
if should_recompute_schedule:
|
||||
now_utc = datetime.now(tz=timezone.utc)
|
||||
if existing_job is None:
|
||||
existing_job = await self.get_by_id(job_id)
|
||||
if existing_job is None:
|
||||
return None
|
||||
|
||||
effective_timezone = data.timezone or existing_job.timezone
|
||||
effective_timezone_obj = self._resolve_timezone(effective_timezone)
|
||||
effective_schedule_type = data.schedule_type or existing_job.schedule_type
|
||||
|
||||
if data.run_at is not None:
|
||||
effective_run_at = data.run_at
|
||||
else:
|
||||
existing_timezone_obj = self._resolve_timezone(existing_job.timezone)
|
||||
effective_run_at = (
|
||||
existing_job.run_at.astimezone(existing_timezone_obj)
|
||||
.time()
|
||||
.replace(microsecond=0)
|
||||
)
|
||||
|
||||
local_now = now_utc.astimezone(effective_timezone_obj)
|
||||
local_dt = datetime.combine(
|
||||
local_now.date(),
|
||||
effective_run_at,
|
||||
effective_timezone_obj,
|
||||
)
|
||||
update_values["run_at"] = local_dt.astimezone(timezone.utc)
|
||||
update_values["next_run_at"] = self._compute_initial_next_run_at(
|
||||
run_at=effective_run_at,
|
||||
timezone_str=effective_timezone,
|
||||
now_utc=now_utc,
|
||||
schedule_type=effective_schedule_type,
|
||||
)
|
||||
if data.timezone is not None:
|
||||
update_values["timezone"] = data.timezone
|
||||
if data.status is not None:
|
||||
update_values["status"] = data.status
|
||||
|
||||
merged_config_raw: dict[str, object] = dict(existing_job.config or {})
|
||||
if data.config is not None:
|
||||
if existing_job is None:
|
||||
existing_job = await self.get_by_id(job_id)
|
||||
if existing_job is None:
|
||||
return None
|
||||
merged_config = {
|
||||
**existing_job.config,
|
||||
merged_config_raw = {
|
||||
**merged_config_raw,
|
||||
**data.config.model_dump(mode="json", exclude_unset=True),
|
||||
}
|
||||
update_values["config"] = merged_config
|
||||
normalized_config = AutomationJobConfig.model_validate(merged_config_raw)
|
||||
update_values["config"] = normalized_config.model_dump(mode="json")
|
||||
else:
|
||||
normalized_config = AutomationJobConfig.model_validate(merged_config_raw)
|
||||
|
||||
schedule_changed = data.config is not None and (
|
||||
"schedule" in data.config.model_dump(mode="json", exclude_unset=True)
|
||||
)
|
||||
if data.timezone is not None or schedule_changed:
|
||||
if normalized_config.schedule is None:
|
||||
raise ValueError("config.schedule is required")
|
||||
effective_timezone = data.timezone or existing_job.timezone
|
||||
update_values["next_run_at"] = self._compute_next_run_at(
|
||||
schedule=normalized_config.schedule,
|
||||
timezone_str=effective_timezone,
|
||||
now_utc=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
|
||||
if not update_values:
|
||||
return await self.get_by_id(job_id)
|
||||
return existing_job
|
||||
|
||||
return await self.update_by_id(job_id, update_values)
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, time
|
||||
from datetime import datetime
|
||||
from typing import Self
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from models.automation_jobs import AutomationJob as OrmAutomationJob
|
||||
from models.automation_jobs import AutomationJobStatus, ScheduleType
|
||||
from models.automation_jobs import AutomationJobStatus
|
||||
from schemas.automation import AutomationJobConfig
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ class AutomationJobResponse(BaseModel):
|
||||
owner_id: UUID
|
||||
bootstrap_key: str | None = None
|
||||
title: str
|
||||
schedule_type: ScheduleType
|
||||
run_at: time
|
||||
timezone: str
|
||||
status: AutomationJobStatus
|
||||
is_system: bool
|
||||
@@ -37,8 +35,6 @@ class AutomationJobResponse(BaseModel):
|
||||
owner_id=obj.owner_id,
|
||||
bootstrap_key=obj.bootstrap_key,
|
||||
title=obj.title,
|
||||
schedule_type=obj.schedule_type,
|
||||
run_at=obj.run_at.time(),
|
||||
timezone=obj.timezone,
|
||||
status=obj.status,
|
||||
is_system=obj.bootstrap_key is not None,
|
||||
@@ -54,12 +50,16 @@ class AutomationJobCreateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
title: str = Field(..., min_length=1, max_length=255)
|
||||
schedule_type: ScheduleType
|
||||
run_at: time = Field(..., description="Local time in HH:MM:SS format")
|
||||
timezone: str = Field(..., min_length=1, max_length=50)
|
||||
status: AutomationJobStatus = Field(default=AutomationJobStatus.ACTIVE)
|
||||
config: AutomationJobConfig
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_schedule_required(self) -> "AutomationJobCreateRequest":
|
||||
if self.config.schedule is None:
|
||||
raise ValueError("config.schedule is required")
|
||||
return self
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_timezone(cls, value: str) -> str:
|
||||
@@ -74,8 +74,6 @@ class AutomationJobUpdateRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
title: str | None = Field(None, min_length=1, max_length=255)
|
||||
schedule_type: ScheduleType | None = None
|
||||
run_at: time | None = None
|
||||
timezone: str | None = Field(None, min_length=1, max_length=50)
|
||||
status: AutomationJobStatus | None = None
|
||||
config: AutomationJobConfig | None = None
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Protocol
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from models.automation_jobs import ScheduleType
|
||||
@@ -11,6 +12,7 @@ from schemas.automation import (
|
||||
AutomationJob as AutomationJobSchema,
|
||||
MessageContextConfig,
|
||||
RuntimeConfig,
|
||||
ScheduleConfig,
|
||||
)
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
@@ -68,15 +70,53 @@ class DispatchFn(Protocol):
|
||||
|
||||
def _compute_next_run_at(
|
||||
*,
|
||||
current_next_run_at: datetime,
|
||||
schedule: ScheduleConfig,
|
||||
timezone_str: str,
|
||||
now_utc: datetime,
|
||||
schedule_type: ScheduleType,
|
||||
) -> datetime:
|
||||
delta = timedelta(days=1 if schedule_type == ScheduleType.DAILY else 7)
|
||||
next_run_at = current_next_run_at
|
||||
while next_run_at <= now_utc:
|
||||
next_run_at = next_run_at + delta
|
||||
return next_run_at
|
||||
try:
|
||||
tz = ZoneInfo(timezone_str)
|
||||
except ZoneInfoNotFoundError:
|
||||
tz = ZoneInfo("UTC")
|
||||
|
||||
local_now = now_utc.astimezone(tz)
|
||||
run_clock = time(
|
||||
hour=schedule.run_at.hour,
|
||||
minute=schedule.run_at.minute,
|
||||
tzinfo=tz,
|
||||
)
|
||||
|
||||
if schedule.type == ScheduleType.DAILY:
|
||||
candidate_local = datetime.combine(local_now.date(), run_clock)
|
||||
if candidate_local <= local_now:
|
||||
candidate_local = candidate_local + timedelta(days=1)
|
||||
return candidate_local.astimezone(timezone.utc)
|
||||
|
||||
weekdays = schedule.weekdays or []
|
||||
if not weekdays:
|
||||
raise ValueError("weekly schedule requires weekdays")
|
||||
|
||||
normalized_weekdays = sorted(set(weekdays))
|
||||
for day_offset in range(0, 8):
|
||||
candidate_day = local_now.date() + timedelta(days=day_offset)
|
||||
if candidate_day.isoweekday() not in normalized_weekdays:
|
||||
continue
|
||||
candidate_local = datetime.combine(candidate_day, run_clock)
|
||||
if candidate_local > local_now:
|
||||
return candidate_local.astimezone(timezone.utc)
|
||||
|
||||
fallback_day = local_now.date() + timedelta(days=7)
|
||||
while fallback_day.isoweekday() not in normalized_weekdays:
|
||||
fallback_day = fallback_day + timedelta(days=1)
|
||||
fallback_local = datetime.combine(fallback_day, run_clock)
|
||||
return fallback_local.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _ensure_schedule(job: AutomationJobSchema) -> ScheduleConfig:
|
||||
schedule = job.config.schedule
|
||||
if schedule is None:
|
||||
raise ValueError(f"job {job.id} config.schedule is missing")
|
||||
return schedule
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -129,9 +169,9 @@ class AutomationJobsService:
|
||||
await self._repository.update_job_schedule(
|
||||
job_id=job.id,
|
||||
next_run_at=_compute_next_run_at(
|
||||
current_next_run_at=job.next_run_at,
|
||||
schedule=_ensure_schedule(job),
|
||||
timezone_str=job.timezone,
|
||||
now_utc=now_utc,
|
||||
schedule_type=job.schedule_type,
|
||||
),
|
||||
last_run_at=now_utc,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user